@telorun/analyzer 0.52.0 → 0.54.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 (80) hide show
  1. package/dist/analysis-registry.d.ts +8 -0
  2. package/dist/analysis-registry.d.ts.map +1 -1
  3. package/dist/analysis-registry.js +21 -3
  4. package/dist/analyzer.d.ts +3 -2
  5. package/dist/analyzer.d.ts.map +1 -1
  6. package/dist/analyzer.js +193 -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 +15 -1
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +11 -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-module-metadata.d.ts +38 -0
  40. package/dist/validate-module-metadata.d.ts.map +1 -0
  41. package/dist/validate-module-metadata.js +256 -0
  42. package/dist/validate-observed-state.d.ts +14 -13
  43. package/dist/validate-observed-state.d.ts.map +1 -1
  44. package/dist/validate-observed-state.js +21 -88
  45. package/dist/validate-ref-slots.d.ts +48 -0
  46. package/dist/validate-ref-slots.d.ts.map +1 -0
  47. package/dist/validate-ref-slots.js +219 -0
  48. package/dist/validate-references.d.ts.map +1 -1
  49. package/dist/validate-references.js +8 -1
  50. package/dist/validate-zone-slots.d.ts +39 -0
  51. package/dist/validate-zone-slots.d.ts.map +1 -0
  52. package/dist/validate-zone-slots.js +114 -0
  53. package/dist/zone-module-documents.d.ts +27 -0
  54. package/dist/zone-module-documents.d.ts.map +1 -0
  55. package/dist/zone-module-documents.js +1 -0
  56. package/dist/zone-slot.d.ts +61 -0
  57. package/dist/zone-slot.d.ts.map +1 -0
  58. package/dist/zone-slot.js +91 -0
  59. package/package.json +3 -3
  60. package/src/analysis-registry.ts +20 -2
  61. package/src/analyzer.ts +211 -24
  62. package/src/builtins.ts +32 -12
  63. package/src/call-graph.ts +827 -0
  64. package/src/dependency-graph.ts +34 -68
  65. package/src/flatten-for-analyzer.ts +32 -0
  66. package/src/index.ts +51 -0
  67. package/src/manifest-navigation.ts +91 -0
  68. package/src/manifest-visitor.ts +1 -1
  69. package/src/ref-slot.ts +273 -0
  70. package/src/reference-field-map.ts +39 -36
  71. package/src/resolve-schema-ref-kinds.ts +34 -7
  72. package/src/resolve-zone-requirements.ts +781 -0
  73. package/src/types.ts +8 -0
  74. package/src/validate-module-metadata.ts +335 -0
  75. package/src/validate-observed-state.ts +26 -92
  76. package/src/validate-ref-slots.ts +293 -0
  77. package/src/validate-references.ts +8 -1
  78. package/src/validate-zone-slots.ts +175 -0
  79. package/src/zone-module-documents.ts +27 -0
  80. package/src/zone-slot.ts +116 -0
@@ -0,0 +1,827 @@
1
+ /**
2
+ * The typed reference graph — one model of "what calls what", replacing the
3
+ * private walkers each analysis used to build for itself.
4
+ *
5
+ * **Two node kinds.** Resource nodes carry their declaration-site identity. Step
6
+ * nodes carry name, lexical order, enclosing array and nesting parent, and
7
+ * *optionally* an outgoing edge. Steps are nodes rather than edge decorations
8
+ * because a pure `value:` step produces `steps.<name>.result` while referencing
9
+ * nothing — it has no edge to hang on — and because step identity, ordering and
10
+ * nesting are exactly what `steps.<name>.result` typing, per-step throws
11
+ * coverage and the editor's step rendering consume.
12
+ *
13
+ * **Lexical order and containment, not execution order.** Order is the written
14
+ * order of the array; which branch actually runs is decided by runtime
15
+ * predicates and is not statically derivable. That is sufficient by
16
+ * construction: result typing needs step names, throws coverage needs
17
+ * `try` / `catch` containment rather than which arm fires, and the editor
18
+ * renders rows as written.
19
+ *
20
+ * **Edges are `(from, slot, to, use)` and the graph is a MULTIGRAPH.** The slot
21
+ * path is part of an edge's identity, so a kind declaring several ref slots
22
+ * emits one edge per slot, each with its own `use` — `Cache.View` holds its
23
+ * `store:` as a `dependency` while its `invoke:` is a `call` — and two slots may
24
+ * name the same target without collapsing. Array slots emit one edge per
25
+ * element. This is a requirement, not a detail: the old `dependency-graph.ts`
26
+ * kept a set-valued adjacency map, which erases parallel edges and would
27
+ * silently merge a dependency with a call. The init-order consumer projects the
28
+ * multigraph down to unique pairs itself, since that is the only consumer for
29
+ * which the distinction genuinely does not matter.
30
+ *
31
+ * **Three discovery mechanics, one graph.** Field-map sites (Phase-5 injection
32
+ * sites — `edge.injected`), schema-driven step slots behind the local `$ref`s
33
+ * the field map deliberately does not descend, and a value-tree scan for `!ref`
34
+ * anywhere else — so a ref in a structure no annotation anticipated is still an
35
+ * edge (with no declared `use`, read conservatively). Inline declarations
36
+ * inside `x-telo-scope` arrays become nodes of their own and their slots are
37
+ * walked, so a `with:`-scoped resource's references are part of the one model.
38
+ *
39
+ * Browser-safe: no Node built-ins.
40
+ */
41
+ import type { ResourceDefinition, ResourceManifest } from "@telorun/sdk";
42
+ import { isRefSentinel, isTaggedSentinel } from "@telorun/templating";
43
+ import type { AliasResolver } from "./alias-resolver.js";
44
+ import type { DefinitionRegistry } from "./definition-registry.js";
45
+ import {
46
+ enclosingOf,
47
+ propertySchemas,
48
+ resolveLocalRef,
49
+ } from "./manifest-navigation.js";
50
+ import { visitManifest } from "./manifest-visitor.js";
51
+ import {
52
+ possibleUses,
53
+ readRefSlot,
54
+ transfersControl,
55
+ type RefUse,
56
+ type RefUseCases,
57
+ } from "./ref-slot.js";
58
+ import { isRefEntry, resolveFieldEntries, type RefFieldEntry } from "./reference-field-map.js";
59
+ import { DEPENDENCY_GRAPH_SKIP_KINDS as SYSTEM_KINDS } from "./system-kinds.js";
60
+
61
+ export interface ResourceGraphNode {
62
+ type: "resource";
63
+ id: string;
64
+ kind: string;
65
+ name: string;
66
+ manifest: ResourceManifest;
67
+ /** Declared inside another resource's `x-telo-scope` array: created when the
68
+ * scope opens rather than at boot, so it takes no part in init ordering. Its
69
+ * declaration-site identity is the scope site, never the module. */
70
+ scoped?: boolean;
71
+ /** Node id of the resource whose scope declares this one (set iff `scoped`). */
72
+ scopeOwner?: string;
73
+ /** The scope field's JSON Pointer on the owner (set iff `scoped`). */
74
+ scopeSite?: string;
75
+ }
76
+
77
+ export interface StepGraphNode {
78
+ type: "step";
79
+ id: string;
80
+ /** The step's declared `name:`, when it has one. */
81
+ name?: string;
82
+ /** Node id of the resource whose body declares this step. */
83
+ owner: string;
84
+ /** Concrete path within the owner (`steps[0].do[1]`). */
85
+ path: string;
86
+ /** Concrete path of the enclosing step array (`steps`, `steps[0].do`). */
87
+ array: string;
88
+ /** Enclosing step node, when this step nests inside another's branch. */
89
+ parent?: string;
90
+ /** Lexical index within its own array. */
91
+ index: number;
92
+ /** The step value as written. */
93
+ step: Record<string, unknown>;
94
+ }
95
+
96
+ export type CallGraphNode = ResourceGraphNode | StepGraphNode;
97
+
98
+ export interface CallGraphEdge {
99
+ /** Node id of the resource or step that declares the slot. */
100
+ from: string;
101
+ /** The referenced resource's NAME, always present — including when no node
102
+ * carries it. A `!ref` to a name that does not exist is a real edge some
103
+ * other validator reports; dropping it would make the graph disagree with
104
+ * the manifest about what was written. */
105
+ toName: string;
106
+ /** Node id of the target, when the name resolves to one. A name declared in
107
+ * the source's own scope resolves to the SCOPED node (scope-local first, the
108
+ * order `ScopeContext` and `!ref` already agree on), never to a same-named
109
+ * module-level resource. */
110
+ to?: string;
111
+ /** Field-map path of the slot — part of the edge's identity. */
112
+ slot: string;
113
+ /** Concrete path of this site (`routes[2].handler`). */
114
+ path: string;
115
+ /**
116
+ * What the declaring resource does with the target at this site. Resolved
117
+ * against a case map's selector when the graph could read it; otherwise every
118
+ * use the slot could take (see {@link CallGraphEdge.unresolved}).
119
+ */
120
+ use: RefUse[];
121
+ /** Set when a case map's selector could not be resolved statically, so `use`
122
+ * is the union of the map's cases rather than the one that holds. */
123
+ unresolved?: RefUseCases;
124
+ /** Why the selector did not resolve: written in CEL (`dynamic` — a
125
+ * diagnostic, see `validate-ref-slots.ts`), absent with no schema default
126
+ * (`absent`), or a literal matching no case (`unmatched`). */
127
+ unresolvedReason?: "dynamic" | "absent" | "unmatched";
128
+ /** JSON Pointer to the field carrying this call's arguments, when declared. */
129
+ inputs?: string;
130
+ /** This site is a Phase-5 injection site — the reference field map reaches
131
+ * it, so the kernel puts the live instance into the field before `init()`.
132
+ * THE init-order criterion: injection is what forces construct-before-use,
133
+ * regardless of whether the slot is declared at resource level or inside an
134
+ * inline step array (`Telo.Application.targets`). Step slots behind a local
135
+ * `$ref` and value-tree-discovered refs are not injection sites — those
136
+ * resolve at dispatch. */
137
+ injected?: boolean;
138
+ /** Found by the value-tree scan rather than a declared slot — no schema, no
139
+ * declared `use` (read conservatively as control-transferring). */
140
+ nested?: boolean;
141
+ /** The target is declared INSIDE the source's own `x-telo-scope`, so it is
142
+ * created on demand when the scope opens rather than at boot. Recorded rather
143
+ * than dropped: it is a real edge, and only the init-order consumer wants it
144
+ * gone. */
145
+ scoped?: boolean;
146
+ }
147
+
148
+ export interface CallGraph {
149
+ nodes: ReadonlyMap<string, CallGraphNode>;
150
+ edges: readonly CallGraphEdge[];
151
+ /** Edges leaving a node, in declaration order. */
152
+ edgesFrom(id: string): CallGraphEdge[];
153
+ /** Edges arriving at a resource node. */
154
+ edgesTo(id: string): CallGraphEdge[];
155
+ resource(kind: string, name: string): ResourceGraphNode | undefined;
156
+ resourceByName(name: string): ResourceGraphNode | undefined;
157
+ /** Step nodes declared by a resource, in lexical order. */
158
+ steps(resourceId: string): StepGraphNode[];
159
+ /** Every edge whose `use` includes at least one control transfer, plus every
160
+ * edge whose slot declares no `use` at all — see {@link CallGraph.controlEdges}. */
161
+ controlEdges(): CallGraphEdge[];
162
+ }
163
+
164
+ export const resourceId = (kind: string, name: string): string => `${kind}\0${name}`;
165
+
166
+ /**
167
+ * Does control reach this edge's target?
168
+ *
169
+ * An edge whose slot declares NO use — the bare-string form, still accepted
170
+ * while the ecosystem migrates, and every value-tree-discovered ref — counts as
171
+ * control-transferring. That is the conservative direction for every consumer
172
+ * of this predicate: the cost of a false "control reaches here" is a check that
173
+ * stays silent, while the cost of a false "it never does" is a valid manifest
174
+ * rejected. It is also exactly what the walkers this replaced did, so an
175
+ * unannotated third-party kind behaves as it did before. The branch disappears
176
+ * when `use` becomes mandatory.
177
+ */
178
+ function reachesTarget(edge: CallGraphEdge): boolean {
179
+ return edge.use.length === 0 || edge.use.some(transfersControl);
180
+ }
181
+
182
+ /** Navigate a JSON Pointer relative to the object enclosing the annotated slot.
183
+ *
184
+ * One rule serves a resource-level sibling and an array item's sibling, and
185
+ * nothing can address across an array boundary — if a case for root anchoring
186
+ * ever appears it gets its own spelling, the split `x-telo-context-from` /
187
+ * `x-telo-context-from-root` already make. */
188
+ function navigatePointer(enclosing: unknown, pointer: string): unknown {
189
+ if (!pointer.startsWith("/")) return undefined;
190
+ let current: unknown = enclosing;
191
+ for (const rawSegment of pointer.slice(1).split("/")) {
192
+ if (current == null || typeof current !== "object") return undefined;
193
+ const segment = rawSegment.replace(/~1/g, "/").replace(/~0/g, "~");
194
+ current = Array.isArray(current)
195
+ ? (current as unknown[])[Number(segment)]
196
+ : (current as Record<string, unknown>)[segment];
197
+ }
198
+ return current;
199
+ }
200
+
201
+ type SchemaDefault = (pointer: string) => unknown;
202
+
203
+ const NO_DEFAULT: SchemaDefault = () => undefined;
204
+
205
+ /** Schema-declared `default:` for a selector pointer, resolved against the
206
+ * schema of the object ENCLOSING the annotated slot — the same anchoring the
207
+ * runtime value walk uses. This is what classifies the common spelling: a
208
+ * `Lease.Critical` that omits `detach:` takes the schema's `default: false`
209
+ * and is a `call` edge, not an unresolved one. */
210
+ function schemaDefaultOf(enclosingSchema: Record<string, any> | undefined): SchemaDefault {
211
+ if (!enclosingSchema) return NO_DEFAULT;
212
+ return (pointer) => {
213
+ if (!pointer.startsWith("/")) return undefined;
214
+ let current: Record<string, any> | undefined = enclosingSchema;
215
+ let value: unknown;
216
+ for (const rawSegment of pointer.slice(1).split("/")) {
217
+ if (!current) return undefined;
218
+ const segment = rawSegment.replace(/~1/g, "/").replace(/~0/g, "~");
219
+ const next: Record<string, any> | undefined = propertySchemas(current).find(
220
+ ([k]) => k === segment,
221
+ )?.[1];
222
+ if (!next) return undefined;
223
+ value = next.default;
224
+ current = next;
225
+ }
226
+ return value;
227
+ };
228
+ }
229
+
230
+ /** The schema node describing the object that ENCLOSES a slot, from the slot's
231
+ * field-map path (`routes[].handler` → `routes`' item schema). Follows `[]`
232
+ * into `items` and `{}` into `additionalProperties`, resolving local `$ref`s. */
233
+ function enclosingSchemaOf(
234
+ rootSchema: Record<string, any>,
235
+ slotFieldPath: string,
236
+ ): Record<string, any> | undefined {
237
+ const segments = slotFieldPath.split(".");
238
+ segments.pop(); // the slot itself — we want its parent object
239
+ let current: Record<string, any> | undefined = rootSchema;
240
+ for (const segment of segments) {
241
+ if (!current) return undefined;
242
+ const bare = segment.replace(/(\[\]|\{\})+$/g, "");
243
+ let next: Record<string, any> | undefined = propertySchemas(current).find(
244
+ ([k]) => k === bare,
245
+ )?.[1];
246
+ if (!next) return undefined;
247
+ for (const marker of segment.slice(bare.length).match(/\[\]|\{\}/g) ?? []) {
248
+ next =
249
+ marker === "[]"
250
+ ? (next?.items as Record<string, any> | undefined)
251
+ : (next?.additionalProperties as Record<string, any> | undefined);
252
+ next = resolveLocalRef(next, rootSchema);
253
+ if (!next || typeof next !== "object") return undefined;
254
+ }
255
+ current = resolveLocalRef(next, rootSchema);
256
+ }
257
+ return current;
258
+ }
259
+
260
+ /**
261
+ * Resolve a slot's declared use at one concrete site.
262
+ *
263
+ * A case map's selector must be statically resolvable — a literal or a schema
264
+ * default. There is deliberately no fallback: no single value is conservative
265
+ * for every consumer, since the throws union must assume `call` to keep an error
266
+ * path and a zone requirement must assume the opposite to avoid inventing one.
267
+ * When the selector cannot be read the edge reports every case's use AND says
268
+ * why (`unresolvedReason`), so a consumer chooses its own reading — and
269
+ * `validate-ref-slots.ts` turns the `dynamic` reason into a diagnostic, because
270
+ * a call graph known only at runtime is not statically analyzable.
271
+ */
272
+ function resolveUseAtSite(
273
+ entry: RefFieldEntry,
274
+ root: unknown,
275
+ concretePath: string,
276
+ schemaDefault: SchemaDefault,
277
+ ): Pick<CallGraphEdge, "use" | "unresolved" | "unresolvedReason"> {
278
+ if (!entry.useCases) return { use: entry.uses };
279
+ const enclosing = enclosingOf(root, concretePath);
280
+ let selector = navigatePointer(enclosing, entry.useCases.by);
281
+ if (selector === undefined) selector = schemaDefault(entry.useCases.by);
282
+ if (selector !== undefined && typeof selector !== "object") {
283
+ const resolved = entry.useCases.cases[String(selector)];
284
+ if (resolved) return { use: resolved };
285
+ }
286
+ const slot = { kinds: entry.refs, uses: entry.uses, useCases: entry.useCases, inline: false };
287
+ const unresolvedReason = isTaggedSentinel(selector)
288
+ ? "dynamic"
289
+ : selector === undefined
290
+ ? "absent"
291
+ : "unmatched";
292
+ return { use: possibleUses(slot), unresolved: entry.useCases, unresolvedReason };
293
+ }
294
+
295
+ /** Names the step-list annotation on an array property, if any. */
296
+ function stepContextOf(schema: Record<string, any> | undefined): Record<string, any> | undefined {
297
+ const annotation = schema?.["x-telo-step-context"];
298
+ return annotation && typeof annotation === "object" ? annotation : undefined;
299
+ }
300
+
301
+ /** A resolved plain reference value (`{kind, name}`, optionally `alias`) — the
302
+ * shape `resolveRefSentinels` leaves at a ref site. NOT a step: a bare boot
303
+ * target written `!ref X` must not mint a step node. */
304
+ function isPlainRefValue(value: Record<string, unknown>): boolean {
305
+ if (typeof value.kind !== "string" || typeof value.name !== "string") return false;
306
+ return Object.keys(value).every((k) => k === "kind" || k === "name" || k === "alias" || k === "__ref");
307
+ }
308
+
309
+ interface StepWalkContext {
310
+ owner: ResourceGraphNode;
311
+ rootSchema: Record<string, any>;
312
+ itemSchema: Record<string, any> | undefined;
313
+ /** Field-map-style prefix for slots on a step of this list (`steps[]`). */
314
+ slotPrefix: string;
315
+ nodes: Map<string, CallGraphNode>;
316
+ order: StepGraphNode[];
317
+ resolveName: (name: string) => ResourceGraphNode | undefined;
318
+ edges: CallGraphEdge[];
319
+ /** `${ownerId}\0${concretePath}` → the edge a step slot emitted, so the
320
+ * field-map walk can stamp `injected` on sites it also reaches
321
+ * (`Telo.Application.targets`). */
322
+ stepEdgesByPath: Map<string, CallGraphEdge>;
323
+ }
324
+
325
+ /**
326
+ * Emit the edges a single step's own ref slots declare.
327
+ *
328
+ * Read from the step ITEM SCHEMA rather than from the reference field map: a
329
+ * step array's items sit behind a local `$ref`, and the field map deliberately
330
+ * does not descend one (descending it there would turn every step's `invoke`
331
+ * into a Phase-5 injection site). The schema is already in hand here, so the
332
+ * graph sees these slots at no cost to the kernel's injection surface.
333
+ */
334
+ function emitStepEdges(node: StepGraphNode, ctx: StepWalkContext): void {
335
+ if (!ctx.itemSchema) return;
336
+ const schemaDefault = schemaDefaultOf(ctx.itemSchema);
337
+ for (const [key, propSchema] of propertySchemas(ctx.itemSchema)) {
338
+ const slot = readRefSlot(propSchema);
339
+ if (!slot || slot.kinds.length === 0) continue;
340
+ const targetName = refTargetName(node.step[key]);
341
+ if (targetName === undefined) continue;
342
+ const entry: RefFieldEntry = {
343
+ refs: slot.kinds,
344
+ uses: slot.uses,
345
+ isArray: false,
346
+ ...(slot.useCases ? { useCases: slot.useCases } : {}),
347
+ ...(slot.inputs !== undefined ? { inputs: slot.inputs } : {}),
348
+ };
349
+ // The step itself is the enclosing object, so a `use` case map and an
350
+ // `inputs` pointer both resolve against the step's own siblings.
351
+ const { use, unresolved, unresolvedReason } = resolveUseAtSite(
352
+ entry,
353
+ node.step,
354
+ key,
355
+ schemaDefault,
356
+ );
357
+ const edge: CallGraphEdge = {
358
+ from: node.id,
359
+ toName: targetName,
360
+ slot: `${ctx.slotPrefix}.${key}`,
361
+ path: `${node.path}.${key}`,
362
+ use,
363
+ };
364
+ const target = ctx.resolveName(targetName);
365
+ if (target) edge.to = target.id;
366
+ if (unresolved) edge.unresolved = unresolved;
367
+ if (unresolvedReason) edge.unresolvedReason = unresolvedReason;
368
+ if (slot.inputs !== undefined) edge.inputs = slot.inputs;
369
+ ctx.edges.push(edge);
370
+ ctx.stepEdgesByPath.set(`${node.owner}\0${edge.path}`, edge);
371
+ }
372
+ }
373
+
374
+ /**
375
+ * The single step-array recursion in the analyzer.
376
+ *
377
+ * A step's position is manifest data, never schema data: no definition declares
378
+ * a next or previous step, and none needs to — order is the written order of the
379
+ * array, and this is a manifest × schema co-traversal, so the array is in hand
380
+ * exactly where step nodes are minted. The schema's whole contribution is to
381
+ * mark an array as a step list and to name the fields that nest further steps
382
+ * (`branch`, `branch-list`, `case-map`).
383
+ */
384
+ function walkSteps(
385
+ steps: unknown[],
386
+ arrayPath: string,
387
+ parent: string | undefined,
388
+ ctx: StepWalkContext,
389
+ ): void {
390
+ const dispatchRole = (
391
+ data: unknown,
392
+ role: string,
393
+ itemsSchema: Record<string, any> | undefined,
394
+ path: string,
395
+ stepId: string,
396
+ ): void => {
397
+ if (role === "branch" && Array.isArray(data)) {
398
+ walkSteps(data, path, stepId, ctx);
399
+ } else if (role === "case-map" && data && typeof data === "object" && !Array.isArray(data)) {
400
+ for (const [caseKey, arr] of Object.entries(data as Record<string, unknown>)) {
401
+ if (Array.isArray(arr)) walkSteps(arr, `${path}.${caseKey}`, stepId, ctx);
402
+ }
403
+ } else if (role === "branch-list" && Array.isArray(data)) {
404
+ const entrySchema = resolveLocalRef(itemsSchema, ctx.rootSchema);
405
+ if (!entrySchema) return;
406
+ data.forEach((entry, i) => {
407
+ if (!entry || typeof entry !== "object") return;
408
+ for (const [subKey, subSchema] of propertySchemas(entrySchema)) {
409
+ const subRole = subSchema["x-telo-topology-role"];
410
+ if (typeof subRole !== "string") continue;
411
+ dispatchRole(
412
+ (entry as Record<string, any>)[subKey],
413
+ subRole,
414
+ subSchema.items as Record<string, any> | undefined,
415
+ `${path}[${i}].${subKey}`,
416
+ stepId,
417
+ );
418
+ }
419
+ });
420
+ }
421
+ };
422
+
423
+ steps.forEach((step, index) => {
424
+ if (!step || typeof step !== "object" || Array.isArray(step)) return;
425
+ // A bare reference in a step position (`targets: [!ref X]`, or the resolved
426
+ // `{kind, name}` it becomes) is a target, not a step — the field-map walk
427
+ // owns that edge. Minting a node here would put ref noise in the step model.
428
+ if (isRefSentinel(step)) return;
429
+ const value = step as Record<string, unknown>;
430
+ if (isPlainRefValue(value)) return;
431
+ const path = `${arrayPath}[${index}]`;
432
+ const id = `${ctx.owner.id}#${path}`;
433
+ const node: StepGraphNode = {
434
+ type: "step",
435
+ id,
436
+ owner: ctx.owner.id,
437
+ path,
438
+ array: arrayPath,
439
+ index,
440
+ step: value,
441
+ };
442
+ if (typeof value.name === "string") node.name = value.name;
443
+ if (parent) node.parent = parent;
444
+ ctx.nodes.set(id, node);
445
+ ctx.order.push(node);
446
+ emitStepEdges(node, ctx);
447
+
448
+ if (!ctx.itemSchema) return;
449
+ for (const [key, propSchema] of propertySchemas(ctx.itemSchema)) {
450
+ const role = propSchema["x-telo-topology-role"];
451
+ if (typeof role !== "string") continue;
452
+ dispatchRole(
453
+ value[key],
454
+ role,
455
+ propSchema.items as Record<string, any> | undefined,
456
+ `${path}.${key}`,
457
+ id,
458
+ );
459
+ }
460
+ });
461
+ }
462
+
463
+ /** The concrete step path a nested site belongs to, or undefined for a
464
+ * resource-level site. Longest-prefix match, so a site inside `steps[0].do[1]`
465
+ * attaches to that step rather than to `steps[0]`. */
466
+ function ownerStepOf(steps: StepGraphNode[], concretePath: string): StepGraphNode | undefined {
467
+ let best: StepGraphNode | undefined;
468
+ for (const step of steps) {
469
+ if (!concretePath.startsWith(`${step.path}.`)) continue;
470
+ if (!best || step.path.length > best.path.length) best = step;
471
+ }
472
+ return best;
473
+ }
474
+
475
+ export interface BuildCallGraphOptions {
476
+ aliases?: AliasResolver;
477
+ aliasesByModule?: Map<string, AliasResolver>;
478
+ }
479
+
480
+ /**
481
+ * Build the call graph for one manifest set.
482
+ *
483
+ * Reference discovery is three-fold. Field-map sites come from `visitManifest`
484
+ * — the same walk the reference validators use — and are stamped `injected`,
485
+ * because those and only those are Phase-5 injection sites. Step slots are read
486
+ * from the step item schema, because they sit behind local `$ref`s the field
487
+ * map deliberately does not descend. Everything else is caught by the value-
488
+ * tree scan (`discoverNestedRefs`): a `!ref` is an explicit marker, so a ref in
489
+ * a structure no annotation anticipated is still an edge — with no declared
490
+ * `use`, read conservatively — instead of a blind spot. Inline declarations in
491
+ * `x-telo-scope` arrays become scoped nodes with edges of their own.
492
+ */
493
+ export function buildCallGraph(
494
+ resources: ResourceManifest[],
495
+ registry: DefinitionRegistry,
496
+ options: BuildCallGraphOptions = {},
497
+ ): CallGraph {
498
+ const nodes = new Map<string, CallGraphNode>();
499
+ const edges: CallGraphEdge[] = [];
500
+ const byName = new Map<string, ResourceGraphNode>();
501
+ const stepsByOwner = new Map<string, StepGraphNode[]>();
502
+ const stepEdgesByPath = new Map<string, CallGraphEdge>();
503
+
504
+ /**
505
+ * A resource's definition, resolved in the scope of the module that DECLARED
506
+ * it. A manifest carries the kind as AUTHORED (`Run.Sequence`), while the
507
+ * registry is keyed canonically (`run.Sequence`), so a raw lookup misses for
508
+ * every alias-form kind — which is every kind in a real manifest. That miss
509
+ * is silent and costly: step collection would find no step list (so a step's
510
+ * declared `use` never reaches its edge, and the site degrades to an untyped
511
+ * value-tree edge), and a case map's selector would find no schema `default`
512
+ * (so a slot resolved by an omitted field reads as unresolved). Same scope
513
+ * selection as `expandedFieldMapForResource`.
514
+ */
515
+ const definitionFor = (manifest: ResourceManifest): ResourceDefinition | undefined => {
516
+ const direct = registry.resolve(manifest.kind as string);
517
+ if (direct) return direct;
518
+ const module = (manifest.metadata as { module?: string } | undefined)?.module;
519
+ const scope = (module ? options.aliasesByModule?.get(module) : undefined) ?? options.aliases;
520
+ const canonical = scope?.resolveKind(manifest.kind as string);
521
+ return canonical ? registry.resolve(canonical) : undefined;
522
+ };
523
+
524
+ for (const manifest of resources) {
525
+ const name = manifest.metadata?.name;
526
+ if (!name || !manifest.kind || SYSTEM_KINDS.has(manifest.kind)) continue;
527
+ const node: ResourceGraphNode = {
528
+ type: "resource",
529
+ id: resourceId(manifest.kind, name as string),
530
+ kind: manifest.kind,
531
+ name: name as string,
532
+ manifest,
533
+ };
534
+ nodes.set(node.id, node);
535
+ byName.set(node.name, node);
536
+ }
537
+
538
+ // --- step nodes ---
539
+ const collectStepsFor = (
540
+ node: ResourceGraphNode,
541
+ resolveName: (name: string) => ResourceGraphNode | undefined,
542
+ ): void => {
543
+ const definition = definitionFor(node.manifest);
544
+ const schema = definition?.schema as Record<string, any> | undefined;
545
+ if (!schema) return;
546
+ const collected: StepGraphNode[] = [];
547
+ for (const [key, propSchema] of propertySchemas(schema)) {
548
+ const annotation = stepContextOf(propSchema);
549
+ if (!annotation) continue;
550
+ const value = (node.manifest as Record<string, unknown>)[key];
551
+ if (!Array.isArray(value)) continue;
552
+ walkSteps(value, key, undefined, {
553
+ owner: node,
554
+ rootSchema: schema,
555
+ itemSchema: resolveLocalRef(propSchema.items as Record<string, any>, schema),
556
+ slotPrefix: `${key}[]`,
557
+ nodes,
558
+ order: collected,
559
+ resolveName,
560
+ edges,
561
+ stepEdgesByPath,
562
+ });
563
+ }
564
+ if (collected.length > 0) stepsByOwner.set(node.id, collected);
565
+ };
566
+
567
+ for (const node of [...nodes.values()] as ResourceGraphNode[]) {
568
+ collectStepsFor(node, (name) => byName.get(name));
569
+ }
570
+
571
+ // --- edges ---
572
+ // Scope-local nodes of the CURRENT resource. The visitor fires `onScope`
573
+ // before that resource's ref sites, so both are set before any edge they
574
+ // qualify is added. Scope-local names win over module-level ones — the order
575
+ // `ScopeContext` and `!ref` already agree on.
576
+ let scopedNames = new Set<string>();
577
+ let scopeLocal = new Map<string, ResourceGraphNode>();
578
+
579
+ const fieldMapFor = (manifest: ResourceManifest) => {
580
+ if (options.aliases && options.aliasesByModule) {
581
+ return registry.expandedFieldMapForResource(manifest, options.aliases, options.aliasesByModule);
582
+ }
583
+ if (options.aliases) return registry.getFieldMapForKind(manifest.kind, options.aliases);
584
+ return registry.getFieldMap(manifest.kind);
585
+ };
586
+
587
+ visitManifest(
588
+ resources,
589
+ registry,
590
+ {
591
+ onScope: (event) => {
592
+ scopedNames = event.enclosedNames;
593
+ scopeLocal = new Map();
594
+ const ownerName = event.source.metadata?.name as string | undefined;
595
+ if (!ownerName || !event.source.kind) return;
596
+ const ownerId = resourceId(event.source.kind, ownerName);
597
+
598
+ // Inline declarations inside `x-telo-scope` arrays become nodes of
599
+ // their own, keyed by their scope site — the declaration-site identity
600
+ // the zones plan correlates on. They are excluded from init ordering
601
+ // (created when the scope opens), but their own references are real
602
+ // edges of the one model.
603
+ for (const [pointer, manifests] of event.manifestsByPointer) {
604
+ for (const manifest of manifests) {
605
+ const name = manifest.metadata?.name;
606
+ if (typeof name !== "string" || !manifest.kind) continue;
607
+ const scopedNode: ResourceGraphNode = {
608
+ type: "resource",
609
+ id: `${ownerId}#${pointer}#${resourceId(manifest.kind, name)}`,
610
+ kind: manifest.kind,
611
+ name,
612
+ manifest,
613
+ scoped: true,
614
+ scopeOwner: ownerId,
615
+ scopeSite: pointer,
616
+ };
617
+ nodes.set(scopedNode.id, scopedNode);
618
+ scopeLocal.set(name, scopedNode);
619
+ }
620
+ }
621
+ if (scopeLocal.size === 0) return;
622
+
623
+ const resolveScoped = (name: string): ResourceGraphNode | undefined =>
624
+ scopeLocal.get(name) ?? byName.get(name);
625
+
626
+ // The owner's own step edges were emitted before this scope was seen
627
+ // (step collection precedes the visit), so their names resolved
628
+ // module-level. Re-resolve them now that the scope exists: scope-local
629
+ // names WIN — the order `ScopeContext` and `!ref` already agree on — so
630
+ // a step's `invoke: !ref X` with X declared in `with:` reaches the
631
+ // scoped node, never a same-named module-level shadow.
632
+ for (const [key, edge] of stepEdgesByPath) {
633
+ if (!key.startsWith(`${ownerId}\0`)) continue;
634
+ const local = scopeLocal.get(edge.toName);
635
+ if (!local) continue;
636
+ edge.to = local.id;
637
+ edge.scoped = true;
638
+ }
639
+
640
+ for (const scopedNode of scopeLocal.values()) {
641
+ // The scoped resource's own ref slots, from its kind's field map.
642
+ const fieldMap = fieldMapFor(scopedNode.manifest);
643
+ if (fieldMap) {
644
+ const definition = definitionFor(scopedNode.manifest);
645
+ const rootSchema = definition?.schema as Record<string, any> | undefined;
646
+ for (const [fieldPath, entry] of fieldMap) {
647
+ if (!isRefEntry(entry)) continue;
648
+ for (const { value, path } of resolveFieldEntries(scopedNode.manifest, fieldPath)) {
649
+ const targetName = refTargetName(value);
650
+ if (targetName === undefined) continue;
651
+ const schemaDefault = rootSchema
652
+ ? schemaDefaultOf(enclosingSchemaOf(rootSchema, fieldPath))
653
+ : NO_DEFAULT;
654
+ const { use, unresolved, unresolvedReason } = resolveUseAtSite(
655
+ entry,
656
+ scopedNode.manifest,
657
+ path,
658
+ schemaDefault,
659
+ );
660
+ const edge: CallGraphEdge = {
661
+ from: scopedNode.id,
662
+ toName: targetName,
663
+ slot: fieldPath,
664
+ path,
665
+ use,
666
+ };
667
+ const target = resolveScoped(targetName);
668
+ if (target) edge.to = target.id;
669
+ if (unresolved) edge.unresolved = unresolved;
670
+ if (unresolvedReason) edge.unresolvedReason = unresolvedReason;
671
+ if (entry.inputs !== undefined) edge.inputs = entry.inputs;
672
+ edges.push(edge);
673
+ }
674
+ }
675
+ }
676
+ // Its step arrays too, resolved scope-local first.
677
+ collectStepsFor(scopedNode, resolveScoped);
678
+ }
679
+ },
680
+ onRef: (event) => {
681
+ const sourceName = event.source.metadata?.name as string | undefined;
682
+ if (!sourceName || !event.source.kind) return;
683
+ const sourceId = resourceId(event.source.kind, sourceName);
684
+ if (!nodes.has(sourceId)) return;
685
+
686
+ // A site inside a step was already emitted by the step walk, which
687
+ // reads the step item schema directly. When the FIELD MAP also reaches
688
+ // it — `Telo.Application`'s inline `targets[].invoke`, unlike
689
+ // `Run.Sequence`'s `$ref`-hidden `steps[].invoke` — the site is a
690
+ // Phase-5 injection site, and the existing step edge is stamped so the
691
+ // init-order projection keeps it.
692
+ if (ownerStepOf(stepsByOwner.get(sourceId) ?? [], event.concretePath)) {
693
+ if (!event.nested) {
694
+ const stepEdge = stepEdgesByPath.get(`${sourceId}\0${event.concretePath}`);
695
+ if (stepEdge) stepEdge.injected = true;
696
+ }
697
+ return;
698
+ }
699
+
700
+ const targetName = refTargetName(event.value);
701
+ if (targetName === undefined) return;
702
+
703
+ const definition = definitionFor(event.source);
704
+ const rootSchema = definition?.schema as Record<string, any> | undefined;
705
+ const schemaDefault =
706
+ !event.nested && rootSchema
707
+ ? schemaDefaultOf(enclosingSchemaOf(rootSchema, event.fieldPath))
708
+ : NO_DEFAULT;
709
+ const { use, unresolved, unresolvedReason } = resolveUseAtSite(
710
+ event.entry,
711
+ event.source,
712
+ event.concretePath,
713
+ schemaDefault,
714
+ );
715
+ const edge: CallGraphEdge = {
716
+ from: sourceId,
717
+ toName: targetName,
718
+ slot: event.fieldPath,
719
+ path: event.concretePath,
720
+ use,
721
+ };
722
+ const target = scopedNames.has(targetName)
723
+ ? (scopeLocal.get(targetName) ?? byName.get(targetName))
724
+ : byName.get(targetName);
725
+ if (target) edge.to = target.id;
726
+ if (unresolved) edge.unresolved = unresolved;
727
+ if (unresolvedReason) edge.unresolvedReason = unresolvedReason;
728
+ if (event.entry.inputs !== undefined) edge.inputs = event.entry.inputs;
729
+ if (event.nested) edge.nested = true;
730
+ else edge.injected = true;
731
+ if (scopedNames.has(targetName)) edge.scoped = true;
732
+ edges.push(edge);
733
+ },
734
+ },
735
+ {
736
+ aliases: options.aliases,
737
+ aliasesByModule: options.aliasesByModule,
738
+ skipKinds: SYSTEM_KINDS,
739
+ expand: true,
740
+ discoverNestedRefs: true,
741
+ },
742
+ );
743
+
744
+ const fromIndex = new Map<string, CallGraphEdge[]>();
745
+ const toIndex = new Map<string, CallGraphEdge[]>();
746
+ const push = (index: Map<string, CallGraphEdge[]>, key: string, edge: CallGraphEdge): void => {
747
+ const bucket = index.get(key);
748
+ if (bucket) bucket.push(edge);
749
+ else index.set(key, [edge]);
750
+ };
751
+ for (const edge of edges) {
752
+ push(fromIndex, edge.from, edge);
753
+ if (edge.to) push(toIndex, edge.to, edge);
754
+ }
755
+
756
+ return {
757
+ nodes,
758
+ edges,
759
+ edgesFrom: (id) => fromIndex.get(id) ?? [],
760
+ edgesTo: (id) => toIndex.get(id) ?? [],
761
+ resource: (kind, name) => nodes.get(resourceId(kind, name)) as ResourceGraphNode | undefined,
762
+ resourceByName: (name) => byName.get(name),
763
+ steps: (ownerId) => stepsByOwner.get(ownerId) ?? [],
764
+ controlEdges: () => edges.filter(reachesTarget),
765
+ };
766
+ }
767
+
768
+ /** The target resource NAME a ref site's value carries. Both written forms reach
769
+ * here: an unresolved `!ref <name>` sentinel and the `{kind, name}` object
770
+ * `resolveRefSentinels` rewrites it into. */
771
+ function refTargetName(value: unknown): string | undefined {
772
+ if (isRefSentinel(value)) return value.source;
773
+ if (!value || typeof value !== "object") return undefined;
774
+ const name = (value as Record<string, unknown>).name;
775
+ return typeof name === "string" ? name : undefined;
776
+ }
777
+
778
+ export interface ProjectToPairsOptions {
779
+ /** Keep only edges whose use satisfies this. Omit to keep every edge. */
780
+ keepUse?: (use: RefUse[]) => boolean;
781
+ /** Also include edges that are NOT injection sites (step slots behind a
782
+ * `$ref`, value-tree-discovered refs). Default false — see below. */
783
+ includeNonInjected?: boolean;
784
+ }
785
+
786
+ /**
787
+ * Unique `(from, to)` pairs, dropping slot identity and `use`. The projection
788
+ * the init-order consumer needs — the only consumer for which the distinction
789
+ * between two parallel edges genuinely does not matter.
790
+ *
791
+ * **Only injection sites order boot, and that is a property of the SITE, never
792
+ * of the node kind.** A site the reference field map reaches is a Phase-5
793
+ * injection site: the kernel puts the live instance into the field before
794
+ * `init()`, so the target must be constructed first — and that is as true for
795
+ * `Telo.Application`'s inline `targets[].invoke` (a step-declared slot the
796
+ * field map reaches) as for a resource-level `connection:`. A step slot behind
797
+ * a local `$ref` and a value-tree-discovered ref resolve at dispatch instead,
798
+ * so their targets need only exist by the time the step runs. An earlier
799
+ * revision keyed this on node kind and silently dropped boot targets' inline
800
+ * invoke edges from init order — the regression this comment exists to prevent.
801
+ *
802
+ * Scoped nodes take no part at all: a `with:`-scoped resource is created when
803
+ * the scope opens, and an edge into a scope is the owner's runtime business.
804
+ */
805
+ export function projectToPairs(
806
+ graph: CallGraph,
807
+ options: ProjectToPairsOptions = {},
808
+ ): Map<string, Set<string>> {
809
+ const out = new Map<string, Set<string>>();
810
+ for (const [id, node] of graph.nodes) {
811
+ if (node.type === "resource" && !node.scoped) out.set(id, new Set());
812
+ }
813
+ for (const edge of graph.edges) {
814
+ if (!edge.to) continue;
815
+ if (!edge.injected && !options.includeNonInjected) continue;
816
+ if (options.keepUse && !options.keepUse(edge.use)) continue;
817
+ const from = graph.nodes.get(edge.from);
818
+ const to = graph.nodes.get(edge.to);
819
+ if (to?.type === "resource" && to.scoped) continue;
820
+ let ownerId: string;
821
+ if (from?.type === "step") ownerId = from.owner;
822
+ else if (from?.type === "resource" && from.scoped) continue;
823
+ else ownerId = edge.from;
824
+ out.get(ownerId)?.add(edge.to);
825
+ }
826
+ return out;
827
+ }