@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.
- package/dist/analysis-registry.d.ts +8 -0
- package/dist/analysis-registry.d.ts.map +1 -1
- package/dist/analysis-registry.js +21 -3
- package/dist/analyzer.d.ts +3 -2
- package/dist/analyzer.d.ts.map +1 -1
- package/dist/analyzer.js +193 -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 +15 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -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-module-metadata.d.ts +38 -0
- package/dist/validate-module-metadata.d.ts.map +1 -0
- package/dist/validate-module-metadata.js +256 -0
- 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 +20 -2
- package/src/analyzer.ts +211 -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 +51 -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-module-metadata.ts +335 -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,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
|
+
}
|