@telorun/analyzer 0.67.0 → 0.69.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 +12 -0
- package/dist/analysis-registry.d.ts.map +1 -1
- package/dist/analysis-registry.js +26 -0
- package/dist/analyzer.d.ts.map +1 -1
- package/dist/analyzer.js +23 -0
- package/dist/call-graph.d.ts +80 -1
- package/dist/call-graph.d.ts.map +1 -1
- package/dist/call-graph.js +145 -12
- package/dist/extends-resolution.d.ts +20 -0
- package/dist/extends-resolution.d.ts.map +1 -1
- package/dist/extends-resolution.js +29 -0
- package/dist/import-resolution-diagnostics.d.ts.map +1 -1
- package/dist/import-resolution-diagnostics.js +22 -8
- package/dist/index.d.ts +8 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -6
- package/dist/invocation-contract.d.ts +20 -0
- package/dist/invocation-contract.d.ts.map +1 -1
- package/dist/invocation-contract.js +74 -0
- package/dist/loaded-types.d.ts +4 -4
- package/dist/loaded-types.d.ts.map +1 -1
- package/dist/manifest-analysis.d.ts +19 -0
- package/dist/manifest-analysis.d.ts.map +1 -1
- package/dist/manifest-analysis.js +27 -0
- package/dist/manifest-loader.d.ts +3 -4
- package/dist/manifest-loader.d.ts.map +1 -1
- package/dist/manifest-loader.js +4 -5
- package/dist/manifest-schemas.d.ts +2 -0
- package/dist/manifest-schemas.d.ts.map +1 -1
- package/dist/manifest-schemas.js +4 -0
- package/dist/module-graph.d.ts +500 -0
- package/dist/module-graph.d.ts.map +1 -0
- package/dist/module-graph.js +1411 -0
- package/dist/reconcile-module-versions.d.ts.map +1 -1
- package/dist/reconcile-module-versions.js +10 -11
- package/dist/release/release-plan.d.ts +1 -1
- package/dist/resolve-zone-containment.d.ts +9 -1
- package/dist/resolve-zone-containment.d.ts.map +1 -1
- package/dist/resolve-zone-containment.js +34 -6
- package/dist/resolve-zone-requirements.d.ts.map +1 -1
- package/dist/resolve-zone-requirements.js +4 -2
- package/dist/schema-keywords.d.ts +16 -1
- package/dist/schema-keywords.d.ts.map +1 -1
- package/dist/schema-keywords.js +20 -0
- package/dist/sources/default-sources.d.ts +6 -6
- package/dist/sources/default-sources.d.ts.map +1 -1
- package/dist/sources/default-sources.js +7 -8
- package/dist/sources/integrity.d.ts +3 -2
- package/dist/sources/integrity.d.ts.map +1 -1
- package/dist/sources/integrity.js +26 -3
- package/dist/sources/versioned-ref.d.ts +17 -12
- package/dist/sources/versioned-ref.d.ts.map +1 -1
- package/dist/sources/versioned-ref.js +22 -24
- package/dist/telo-version.d.ts +1 -1
- package/dist/telo-version.js +1 -1
- package/dist/validate-extends.d.ts.map +1 -1
- package/dist/validate-extends.js +42 -1
- package/dist/validate-sensitive-slots.d.ts +28 -0
- package/dist/validate-sensitive-slots.d.ts.map +1 -0
- package/dist/validate-sensitive-slots.js +75 -0
- package/dist/value-type-keyword.d.ts +1 -1
- package/dist/value-type-keyword.d.ts.map +1 -1
- package/dist/value-type-keyword.js +1 -0
- package/package.json +1 -1
- package/src/analysis-registry.ts +37 -0
- package/src/analyzer.ts +26 -0
- package/src/call-graph.ts +207 -14
- package/src/extends-resolution.ts +33 -0
- package/src/import-resolution-diagnostics.ts +24 -7
- package/src/index.ts +42 -5
- package/src/invocation-contract.ts +71 -0
- package/src/loaded-types.ts +4 -4
- package/src/manifest-analysis.ts +39 -0
- package/src/manifest-loader.ts +4 -5
- package/src/manifest-schemas.ts +4 -0
- package/src/module-graph.ts +2005 -0
- package/src/reconcile-module-versions.ts +10 -11
- package/src/release/release-plan.ts +1 -1
- package/src/resolve-zone-containment.ts +49 -9
- package/src/resolve-zone-requirements.ts +7 -2
- package/src/schema-keywords.ts +33 -1
- package/src/sources/default-sources.ts +7 -8
- package/src/sources/integrity.ts +28 -3
- package/src/sources/versioned-ref.ts +26 -28
- package/src/telo-version.ts +1 -1
- package/src/validate-extends.ts +51 -1
- package/src/validate-sensitive-slots.ts +109 -0
- package/src/value-type-keyword.ts +1 -0
- package/dist/sources/module-ref.d.ts +0 -21
- package/dist/sources/module-ref.d.ts.map +0 -1
- package/dist/sources/module-ref.js +0 -36
- package/dist/sources/registry-source.d.ts +0 -14
- package/dist/sources/registry-source.d.ts.map +0 -1
- package/dist/sources/registry-source.js +0 -45
- package/src/sources/module-ref.ts +0 -49
- package/src/sources/registry-source.ts +0 -52
|
@@ -0,0 +1,2005 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The module graph — what a module IS, as boxes, rows and classed edges.
|
|
3
|
+
*
|
|
4
|
+
* One fold over the call graph, the reference field map and each kind's own
|
|
5
|
+
* schema, producing the three primitives an editor draws:
|
|
6
|
+
*
|
|
7
|
+
* - **Box** — a declaration and what it owns. Every resource is a node whatever
|
|
8
|
+
* declaration form it arrived in (named, inline, `with:`-scoped, imported,
|
|
9
|
+
* injected), plus the module root, which is not a resource but owns `targets`.
|
|
10
|
+
* - **Row** — one ORDERED entry inside a box: a step, an entry-list item (a
|
|
11
|
+
* route, a mount), a boot target. Order is manifest data, so it is carried
|
|
12
|
+
* rather than re-derived; a row is where reordering is expressible at all.
|
|
13
|
+
* - **Edge** — a reference leaving a PORT, classed by what the slot's `use`
|
|
14
|
+
* says happens at it.
|
|
15
|
+
*
|
|
16
|
+
* **Ports are declared, not discovered.** A port exists because the kind's
|
|
17
|
+
* schema declares a ref slot, so an EMPTY slot is a port with an empty
|
|
18
|
+
* occupancy — the fact that `notFoundHandler` is unset is as much a property of
|
|
19
|
+
* the application as the fact that `mounts` has two entries, and it is the only
|
|
20
|
+
* thing an editor can offer to fill.
|
|
21
|
+
*
|
|
22
|
+
* **Three edge classes, not six uses.** What a reader must distinguish is
|
|
23
|
+
* whether control transfers, not which of four ways it does:
|
|
24
|
+
* `call` / `detached` / `trigger.inbound` / `trigger.consumer` are **flow**,
|
|
25
|
+
* `dependency` is **holds**, `schema` is **shape** — a type annotation rather
|
|
26
|
+
* than a runtime relation. The six-value `use` stays on the edge for consumers
|
|
27
|
+
* that need the distinction; the class is what a view draws.
|
|
28
|
+
*
|
|
29
|
+
* **Identity is anchored on names, never on indices.** A row addressed by array
|
|
30
|
+
* index shifts when a sibling is inserted above it, so selection and sticky
|
|
31
|
+
* expansion would detach precisely while the user is editing — the primary use
|
|
32
|
+
* case. Where the grammar offers a name (a step's `name:`, a resource's
|
|
33
|
+
* `metadata.name`) the name is the identity; where it does not (an unnamed
|
|
34
|
+
* route, an unnamed step) the identity is the nearest named ancestor plus a
|
|
35
|
+
* content-derived key. The same reason the migration driver refuses indexed
|
|
36
|
+
* matches into a resized array: a stale key resolves to nothing, a stale index
|
|
37
|
+
* silently names a different element.
|
|
38
|
+
*
|
|
39
|
+
* **What is deliberately NOT here: view policy.** Bands, labels, layout and
|
|
40
|
+
* expansion are the editor's, so nothing in this file reads a schema `title` or
|
|
41
|
+
* decides where a node is drawn. What it emits is the fact each of those
|
|
42
|
+
* decisions is taken from — capability, ownership, edge class, `boot` — so two
|
|
43
|
+
* hosts drawing the same module cannot disagree about what it contains.
|
|
44
|
+
*
|
|
45
|
+
* Browser-safe: no Node built-ins.
|
|
46
|
+
*/
|
|
47
|
+
import type { ResourceDefinition, ResourceManifest } from "@telorun/sdk";
|
|
48
|
+
import {
|
|
49
|
+
buildCelEnvironment,
|
|
50
|
+
extractAccessChains,
|
|
51
|
+
isRefSentinel,
|
|
52
|
+
walkCelExpressions,
|
|
53
|
+
} from "@telorun/templating";
|
|
54
|
+
import {
|
|
55
|
+
nodeIdFor,
|
|
56
|
+
resolveScopedName,
|
|
57
|
+
resourceId,
|
|
58
|
+
type CallGraph,
|
|
59
|
+
type CallGraphEdge,
|
|
60
|
+
type ResourceGraphNode,
|
|
61
|
+
type StepGraphNode,
|
|
62
|
+
} from "./call-graph.js";
|
|
63
|
+
import { propertySchemas, resolveLocalRef } from "./manifest-navigation.js";
|
|
64
|
+
import { isStepSlot } from "./step-slot.js";
|
|
65
|
+
import { isInlineResource, resolveFieldEntries } from "./reference-field-map.js";
|
|
66
|
+
import { findZoneProviders } from "./resolve-zone-containment.js";
|
|
67
|
+
import { possibleUses, readRefSlot, type RefUse } from "./ref-slot.js";
|
|
68
|
+
|
|
69
|
+
/** How a node's declaration reached the module, which is what decides where a
|
|
70
|
+
* view may draw it — an inline child exists nowhere but its parent's YAML, so
|
|
71
|
+
* it is never a peer of the resource holding it. */
|
|
72
|
+
export type NodeOwnership = "root" | "named" | "inline" | "scoped" | "imported" | "injected";
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* What happens at a site, reduced to what a view draws.
|
|
76
|
+
*
|
|
77
|
+
* The first three classify a REFERENCE by its `use`; `data` is not a reference
|
|
78
|
+
* at all — it is one resource reading another's published state in CEL
|
|
79
|
+
* (`resources.<name>.status.<field>`), which is a real dependency the reference
|
|
80
|
+
* graph does not carry and which no slot declares.
|
|
81
|
+
*/
|
|
82
|
+
export type EdgeClass = "flow" | "holds" | "shape" | "data";
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* What a line of a body is.
|
|
86
|
+
*
|
|
87
|
+
* The first three are ORDERED entries of an array — a step, an entry-list item,
|
|
88
|
+
* a boot target — and reordering one is the point of drawing them. The last two
|
|
89
|
+
* are not positions at all: a slot may hold a DECLARATION rather than a name
|
|
90
|
+
* (`invoke: { kind: …, …config }`), and that declaration exists nowhere but the
|
|
91
|
+
* site, so it has no array to sit in and no sibling to be moved past. It is a
|
|
92
|
+
* line of the body because it is a thing the author wrote and has to be able to
|
|
93
|
+
* reach; see {@link isOrderedRow}.
|
|
94
|
+
*/
|
|
95
|
+
export type RowKind = "step" | "entry" | "target" | "inline" | "reference";
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Rows that are ordered entries of their array.
|
|
99
|
+
*
|
|
100
|
+
* A consumer offering reorder, removal or an index must ask — a declaration
|
|
101
|
+
* written at a dispatch site carries an `index` of 0 and an `array` it shares
|
|
102
|
+
* with its host, both of which are borrowed so it groups into the right branch,
|
|
103
|
+
* and neither of which means what it means on a step.
|
|
104
|
+
*/
|
|
105
|
+
export function isOrderedRow(row: GraphRow): boolean {
|
|
106
|
+
return row.kind === "step" || row.kind === "entry" || row.kind === "target";
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** One occupancy of a port: the concrete site, and the name it holds. */
|
|
110
|
+
export interface PortSlot {
|
|
111
|
+
/** Concrete path of this site (`mounts[1].mount`), the write target. */
|
|
112
|
+
path: string;
|
|
113
|
+
/** Referenced resource name, absent for an empty slot. */
|
|
114
|
+
target?: string;
|
|
115
|
+
/** Node id the name resolves to, absent when it resolves to nothing. */
|
|
116
|
+
targetNode?: string;
|
|
117
|
+
/** The site holds an inline declaration rather than a reference; `targetNode`
|
|
118
|
+
* is the extracted child. */
|
|
119
|
+
inline?: boolean;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** A reference slot the kind declares, filled or not. */
|
|
123
|
+
export interface GraphPort {
|
|
124
|
+
/** Field-map path with `[]` / `{}` markers — the port's identity on its node. */
|
|
125
|
+
slot: string;
|
|
126
|
+
/** Accepted `x-telo-ref` constraints, canonicalized. */
|
|
127
|
+
refs: string[];
|
|
128
|
+
/** Capabilities a target may satisfy — what validates a wire. */
|
|
129
|
+
capabilities: string[];
|
|
130
|
+
/** The slot traverses at least one array, so it takes many targets. */
|
|
131
|
+
array: boolean;
|
|
132
|
+
class: EdgeClass;
|
|
133
|
+
slots: PortSlot[];
|
|
134
|
+
/** Concrete path a new array item would be written at. Array ports only. */
|
|
135
|
+
addPath?: string;
|
|
136
|
+
/** This slot's occupancy is drawn as ROWS rather than as port slots — the
|
|
137
|
+
* slot sits inside a step body or an entry list, where order is semantic and
|
|
138
|
+
* the row is the thing a reader manipulates. */
|
|
139
|
+
rowOwned?: boolean;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** One ordered entry inside a box. */
|
|
143
|
+
export interface GraphRow {
|
|
144
|
+
/** Name-anchored identity, stable across insertion of a sibling. */
|
|
145
|
+
id: string;
|
|
146
|
+
kind: RowKind;
|
|
147
|
+
/** What the manifest calls this line: a step's `name:`, or — for a
|
|
148
|
+
* `reference` row — the field holding it. Absent where the grammar offers
|
|
149
|
+
* neither (an unnamed route, an `inline` row, which is named by its kind). */
|
|
150
|
+
name?: string;
|
|
151
|
+
/** Concrete path of the row (`steps[0].do[1]`, `routes[2]`) — the write
|
|
152
|
+
* target, and what a position index is keyed by. */
|
|
153
|
+
path: string;
|
|
154
|
+
/** Concrete path of the array holding it, the reorder domain. */
|
|
155
|
+
array: string;
|
|
156
|
+
/** Lexical index within that array. */
|
|
157
|
+
index: number;
|
|
158
|
+
/** Nesting depth: 0 at the body's top level, +1 inside each branch. */
|
|
159
|
+
depth: number;
|
|
160
|
+
/** Row id of the enclosing row, when this one nests inside a branch. */
|
|
161
|
+
parent?: string;
|
|
162
|
+
/** Referenced name this row dispatches to, when it dispatches. */
|
|
163
|
+
target?: string;
|
|
164
|
+
/** Node id of that target, when it resolves. */
|
|
165
|
+
targetNode?: string;
|
|
166
|
+
/** JSON Pointer to this call's argument map, when the slot declares one. */
|
|
167
|
+
inputs?: string;
|
|
168
|
+
/** The values a matcher-role field holds (`{path: "/orders", method: "POST"}`)
|
|
169
|
+
* — what identifies an entry to a reader, and what its content key is
|
|
170
|
+
* derived from. Entry rows only. */
|
|
171
|
+
match?: Record<string, unknown>;
|
|
172
|
+
/**
|
|
173
|
+
* What this row IS, in the grammar's own words — `invoke`, `if/then/else`,
|
|
174
|
+
* `while/do`, `switch/cases/default`, `try/catch/finally`, `throw`, `value`.
|
|
175
|
+
*
|
|
176
|
+
* Without it every statement in a body reads alike: a loop and a dispatch are
|
|
177
|
+
* both a name and an arrow, so a reader has to open the source to find out
|
|
178
|
+
* which is which. Read off the branch the step matches, so a kind declaring a
|
|
179
|
+
* body of its own is described in the words its author chose.
|
|
180
|
+
*/
|
|
181
|
+
variant?: string;
|
|
182
|
+
/** The branch's title as its author wrote it — a label to render, never to
|
|
183
|
+
* parse. See `StepGraphNode.variantLabel`. */
|
|
184
|
+
variantLabel?: string;
|
|
185
|
+
/**
|
|
186
|
+
* The expression deciding whether or how this row runs, as written — an
|
|
187
|
+
* `if:`, a `while:`, a `switch:`, or a dispatch's `when:` guard.
|
|
188
|
+
*
|
|
189
|
+
* A step drawn without it says it is conditional and not on what, which for a
|
|
190
|
+
* loop is the whole behaviour.
|
|
191
|
+
*/
|
|
192
|
+
predicate?: string;
|
|
193
|
+
/** This row declares an error branch — a field the kind annotated
|
|
194
|
+
* `x-telo-error-context`, which is where a raised code is discharged. Found
|
|
195
|
+
* by the annotation, so a third-party composer's `catches:` is seen too. */
|
|
196
|
+
catches?: boolean;
|
|
197
|
+
/**
|
|
198
|
+
* Where this row's call is WRITTEN, and what may fill it.
|
|
199
|
+
*
|
|
200
|
+
* A row is the dispatch site a reader manipulates — a step's `invoke:`, a
|
|
201
|
+
* route's `handler:`, a boot target — and until now the site was recoverable
|
|
202
|
+
* only from an edge, so a row dispatching to nothing yet had no address at
|
|
203
|
+
* all. That is exactly the row an editor has something to offer at: it is
|
|
204
|
+
* where a reference is written, and where a new resource would be wired in.
|
|
205
|
+
*
|
|
206
|
+
* Absent for a row that dispatches nothing by grammar (a pure `value:` step)
|
|
207
|
+
* and for a declaration row, which IS the thing dispatched to.
|
|
208
|
+
*/
|
|
209
|
+
dispatch?: {
|
|
210
|
+
path: string;
|
|
211
|
+
refs: string[];
|
|
212
|
+
/** The slot holds a DECLARATION written at the site rather than a reference
|
|
213
|
+
* to one elsewhere. A consumer offers different things at the two: nothing
|
|
214
|
+
* can be wired into an occupied declaration without destroying it, and a
|
|
215
|
+
* declaration is the one thing that can be given a name of its own. */
|
|
216
|
+
inline?: boolean;
|
|
217
|
+
/**
|
|
218
|
+
* The row's OTHER sites — the same call, written a different way, under a
|
|
219
|
+
* different constraint.
|
|
220
|
+
*
|
|
221
|
+
* A boot target is the case that forced it: the entry takes a bare
|
|
222
|
+
* `!ref` to a `Telo.Runnable | Telo.Service`, and it takes an invoke step
|
|
223
|
+
* whose `invoke:` takes any `Telo.Executable`. Reporting only the first made
|
|
224
|
+
* every `Telo.Invocable` in the application unbootable from the editor —
|
|
225
|
+
* legal in the manifest, and offered nowhere, since a slot with nothing to
|
|
226
|
+
* put in it reads as an empty module rather than as a missing site.
|
|
227
|
+
*
|
|
228
|
+
* Primary first, and a site is listed once per CONSTRAINT: a boot target's
|
|
229
|
+
* `ref:` accepts exactly what its bare form does, so the two are one site
|
|
230
|
+
* and the bare spelling wins. Which spelling to write is a choice a reader
|
|
231
|
+
* should not have to make, and the constraint is the only thing that
|
|
232
|
+
* changes what may be written at all.
|
|
233
|
+
*/
|
|
234
|
+
alternatives?: { path: string; refs: string[] }[];
|
|
235
|
+
};
|
|
236
|
+
/** `inline` rows: the kind the declaration written at this site names. A
|
|
237
|
+
* declaration is not a reference — there is nothing elsewhere to point at,
|
|
238
|
+
* which is the whole reason the row has to carry its own identity. */
|
|
239
|
+
declares?: string;
|
|
240
|
+
/** That kind resolved to no definition. */
|
|
241
|
+
unknownKind?: boolean;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** A resource, the module root, or a declaration owned by either. */
|
|
245
|
+
export interface GraphNode {
|
|
246
|
+
/** `<kind>\0<name>` for a module-level resource — the call graph's own id, so
|
|
247
|
+
* a consumer holding one can address the other. */
|
|
248
|
+
id: string;
|
|
249
|
+
kind: string;
|
|
250
|
+
name: string;
|
|
251
|
+
/** Declared capability, absent when the kind does not resolve — an unresolved
|
|
252
|
+
* import, a kind with no definition. A view must render it as unknown rather
|
|
253
|
+
* than guessing a placement it would have to take back. */
|
|
254
|
+
capability?: string;
|
|
255
|
+
/**
|
|
256
|
+
* `<module>.<Kind>` — what `kind` NAMES, resolved.
|
|
257
|
+
*
|
|
258
|
+
* `kind` is the string the author wrote, and it is written in the DECLARING
|
|
259
|
+
* module's alias scope: a library declares its own instances as
|
|
260
|
+
* `kind: Self.WriteLine`, and `Self` means that library. Carried into a
|
|
261
|
+
* flattened application the spelling survives and resolves to nothing there,
|
|
262
|
+
* so every consumer joining on a kind — does this slot accept it, which
|
|
263
|
+
* instances does this kind have, what schema does the form use — silently
|
|
264
|
+
* missed a whole imported library.
|
|
265
|
+
*
|
|
266
|
+
* Absent when the kind is already canonical, and when it resolves to nothing
|
|
267
|
+
* (which `unknownKind` says).
|
|
268
|
+
*/
|
|
269
|
+
canonicalKind?: string;
|
|
270
|
+
/** The kind resolved to no definition at all. */
|
|
271
|
+
unknownKind?: boolean;
|
|
272
|
+
ownership: NodeOwnership;
|
|
273
|
+
/** Node that owns this declaration — set for `inline` and `scoped`. */
|
|
274
|
+
owner?: string;
|
|
275
|
+
/** Site on the owner that declares it (`/with`, `mounts[0].mount`). */
|
|
276
|
+
ownerSite?: string;
|
|
277
|
+
/** Module that declared it, when stamped. */
|
|
278
|
+
module?: string;
|
|
279
|
+
/** True when the declaring module is not the entry module — an instance
|
|
280
|
+
* reached across an import boundary. */
|
|
281
|
+
external?: boolean;
|
|
282
|
+
/** Import alias the entry module reaches it under, when it is external and
|
|
283
|
+
* one alias points at its module. What a boundary box is labelled by, and
|
|
284
|
+
* what a reference to it is written with. */
|
|
285
|
+
alias?: string;
|
|
286
|
+
ports: GraphPort[];
|
|
287
|
+
rows: GraphRow[];
|
|
288
|
+
/**
|
|
289
|
+
* The ordered arrays this kind can hold rows in, whether or not any exist.
|
|
290
|
+
*
|
|
291
|
+
* Declared rather than observed, for the same reason a port is: a server with
|
|
292
|
+
* no mounts still HAS mounts, and a canvas that lists only what is there
|
|
293
|
+
* offers no way to add the first one. Each is a field name plus what its rows
|
|
294
|
+
* would be.
|
|
295
|
+
*/
|
|
296
|
+
rowArrays: { field: string; kind: RowKind }[];
|
|
297
|
+
/** What invoking this can raise, resolved along its own call graph — the
|
|
298
|
+
* error contract a caller has to render or let escape. `unbounded` means the
|
|
299
|
+
* union could not be closed statically, so a catch-all is required. */
|
|
300
|
+
throws?: { codes: string[]; unbounded: boolean };
|
|
301
|
+
/** The module root, which owns `targets` and the module's own config. */
|
|
302
|
+
root?: boolean;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** A reference site, classed. */
|
|
306
|
+
export interface GraphEdge {
|
|
307
|
+
/** Stable within the graph: source, slot and site. */
|
|
308
|
+
id: string;
|
|
309
|
+
/** Node id of the declaring resource — never a step, since a step is a ROW of
|
|
310
|
+
* its owner here rather than a node. `row` says which row declared it. */
|
|
311
|
+
from: string;
|
|
312
|
+
/** Node id of the target, absent when the name resolves to nothing. */
|
|
313
|
+
to?: string;
|
|
314
|
+
/** The referenced name as written, always present — a `!ref` to a name that
|
|
315
|
+
* does not exist is a real edge some other pass reports, and dropping it
|
|
316
|
+
* would make the graph disagree with the manifest about what was written. */
|
|
317
|
+
toName: string;
|
|
318
|
+
class: EdgeClass;
|
|
319
|
+
/** The declared uses at this site, unreduced. */
|
|
320
|
+
use: RefUse[];
|
|
321
|
+
/** Field-map path of the slot — part of the edge's identity, so two slots
|
|
322
|
+
* naming one target are two edges. */
|
|
323
|
+
slot: string;
|
|
324
|
+
/** Concrete path of the site. */
|
|
325
|
+
path: string;
|
|
326
|
+
/** Row this edge leaves from, when a row declares it. */
|
|
327
|
+
row?: string;
|
|
328
|
+
/** JSON Pointer to this call's argument map, when declared. */
|
|
329
|
+
inputs?: string;
|
|
330
|
+
/** The edge is a boot target of the module root: ordered, and the reason the
|
|
331
|
+
* target runs at all. */
|
|
332
|
+
boot?: boolean;
|
|
333
|
+
/** The target is declared inside the source's own scope. */
|
|
334
|
+
scoped?: boolean;
|
|
335
|
+
/** Data edges only: the access chain as written (`resources.db.status.port`),
|
|
336
|
+
* so a reader is told WHAT is read rather than only that something is. */
|
|
337
|
+
read?: string;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* A genuine containment: a set of nodes something else encloses.
|
|
342
|
+
*
|
|
343
|
+
* Reference reachability is NOT containment — that mistake is what drew a mount
|
|
344
|
+
* as a child of its server while the slot said `dependency`. The three that ARE:
|
|
345
|
+
* an **inline** declaration and a **scope**'s resources exist nowhere but their
|
|
346
|
+
* owner's YAML, and a **zone** is a region of execution every dispatch inside it
|
|
347
|
+
* runs within.
|
|
348
|
+
*/
|
|
349
|
+
export interface GraphRegion {
|
|
350
|
+
id: string;
|
|
351
|
+
kind: "inline" | "scope" | "zone";
|
|
352
|
+
/** Node whose declaration encloses the members. */
|
|
353
|
+
owner: string;
|
|
354
|
+
/** Site on the owner (`/with`, `invoke`, `steps`). */
|
|
355
|
+
site: string;
|
|
356
|
+
/** Node ids inside. */
|
|
357
|
+
members: string[];
|
|
358
|
+
/** Zone regions only: what the region GUARANTEES about its contents, as the
|
|
359
|
+
* declaring kind wrote it — the attribute name mapped to the author's
|
|
360
|
+
* reason, which a consumer quotes rather than paraphrases. */
|
|
361
|
+
attributes?: Readonly<Record<string, string>>;
|
|
362
|
+
/** Zone regions only: dispatches inside the region the zone does NOT extend
|
|
363
|
+
* through — a detached call, an inbound trigger. Recorded because the site is
|
|
364
|
+
* inside the region even though its target is not. */
|
|
365
|
+
boundaries?: { from: string; toName: string; escaping: string[] }[];
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* A kind declaration — the second plane.
|
|
370
|
+
*
|
|
371
|
+
* Kept apart from the instance nodes rather than mixed in: a `Telo.Definition`
|
|
372
|
+
* is a TYPE, and drawing it among the instances would put things that exist at
|
|
373
|
+
* runtime and things that do not on one surface with nothing separating them.
|
|
374
|
+
* A module that declares only kinds has this plane and no other, which is why
|
|
375
|
+
* it is a first-class list rather than a flag on a node.
|
|
376
|
+
*/
|
|
377
|
+
export interface GraphKind {
|
|
378
|
+
/** Canonical `<module>.<Name>` — what a `kind:` resolves to. */
|
|
379
|
+
id: string;
|
|
380
|
+
name: string;
|
|
381
|
+
module?: string;
|
|
382
|
+
/** Non-instantiable: the contract has no default implementation. */
|
|
383
|
+
abstract: boolean;
|
|
384
|
+
capability?: string;
|
|
385
|
+
/** Canonical id of the kind this one specializes, when it resolves. */
|
|
386
|
+
extendsId?: string;
|
|
387
|
+
/** The `extends` target as WRITTEN, kept when it resolves to nothing — an
|
|
388
|
+
* unresolved parent is a fact about the manifest, not a reason to draw the
|
|
389
|
+
* kind as having none. */
|
|
390
|
+
extendsName?: string;
|
|
391
|
+
/** Ids of the instance nodes declared of this kind — the join between the
|
|
392
|
+
* two planes. */
|
|
393
|
+
instances: string[];
|
|
394
|
+
/** Declares a body of its own (`resources:` / `invoke:` / `run:` / `provide:`)
|
|
395
|
+
* rather than naming a controller — the kind's interior. */
|
|
396
|
+
template: boolean;
|
|
397
|
+
/** Declared by the entry module rather than reached through an import. */
|
|
398
|
+
own: boolean;
|
|
399
|
+
/** Listed in the entry module's `exports.kinds`, so an importer may construct
|
|
400
|
+
* one. Undefined for a kind this module did not declare, whose gate is its
|
|
401
|
+
* own library's to state. */
|
|
402
|
+
exported?: boolean;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
export interface ModuleGraph {
|
|
406
|
+
/** The module root, when one was supplied. */
|
|
407
|
+
root?: GraphNode;
|
|
408
|
+
nodes: GraphNode[];
|
|
409
|
+
edges: GraphEdge[];
|
|
410
|
+
regions: GraphRegion[];
|
|
411
|
+
/** The kind plane — every kind declaration in scope. */
|
|
412
|
+
kinds: GraphKind[];
|
|
413
|
+
nodeById(id: string): GraphNode | undefined;
|
|
414
|
+
edgesFrom(id: string): GraphEdge[];
|
|
415
|
+
edgesTo(id: string): GraphEdge[];
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* A hold whose target is ambient infrastructure — the collapse candidate.
|
|
420
|
+
*
|
|
421
|
+
* Stated here rather than in the view because it is the rule the plan fixes,
|
|
422
|
+
* and two hosts applying it differently would disagree about which edges exist:
|
|
423
|
+
* a hold into a shared connection or store is fan-in that swamps a layout and
|
|
424
|
+
* carries no structure, while a hold BETWEEN working resources is the
|
|
425
|
+
* application's spine — a server holding its mounts — and demoting the second
|
|
426
|
+
* with the first is exactly the mistake this replaces.
|
|
427
|
+
*/
|
|
428
|
+
/**
|
|
429
|
+
* Is anything reaching this declaration at all?
|
|
430
|
+
*
|
|
431
|
+
* "Declared, referenced by nothing, in no `targets`" — the resource a reader
|
|
432
|
+
* cannot otherwise tell apart from a wired one, since a manifest states no
|
|
433
|
+
* difference between the two. Every incoming edge counts, not only flow: a
|
|
434
|
+
* connection is HELD rather than called, and reading it as unwired would mark
|
|
435
|
+
* every provider in the module. An owned declaration is reached by its owner,
|
|
436
|
+
* and the root is what reaches everything else.
|
|
437
|
+
*
|
|
438
|
+
* A declaration this module did not write is NEVER unwired, however little it
|
|
439
|
+
* is used here. An imported library exports what it exports, and the flatten
|
|
440
|
+
* forwards all of it; "nothing references this" would be a true sentence about
|
|
441
|
+
* an unused export and a useless one, since the reader cannot act on it — the
|
|
442
|
+
* declaration is not theirs to remove.
|
|
443
|
+
*/
|
|
444
|
+
export function isUnwired(node: GraphNode, graph: ModuleGraph): boolean {
|
|
445
|
+
if (node.root || node.external) return false;
|
|
446
|
+
if (node.ownership === "inline" || node.ownership === "scoped") return false;
|
|
447
|
+
return graph.edgesTo(node.id).length === 0;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
export function isAmbientHold(edge: GraphEdge, graph: ModuleGraph): boolean {
|
|
451
|
+
if (edge.class !== "holds" || !edge.to) return false;
|
|
452
|
+
const target = graph.nodeById(edge.to);
|
|
453
|
+
return isAmbientCapability(target?.capability);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Capabilities whose resources are AMBIENT: held and read, never run, and never
|
|
458
|
+
* drawn as the target of a line.
|
|
459
|
+
*
|
|
460
|
+
* Exported because the view partitions on the same fact — which boxes go off
|
|
461
|
+
* the canvas into the drawer — and two spellings of it is how a host ends up
|
|
462
|
+
* collapsing a hold the other still draws an edge for.
|
|
463
|
+
*/
|
|
464
|
+
export const AMBIENT_CAPABILITIES: ReadonlySet<string> = new Set([
|
|
465
|
+
"Telo.Provider",
|
|
466
|
+
"Telo.Type",
|
|
467
|
+
]);
|
|
468
|
+
|
|
469
|
+
/** Is this an ambient declaration — held and read rather than run? */
|
|
470
|
+
export function isAmbientCapability(capability: string | undefined): boolean {
|
|
471
|
+
return !!capability && AMBIENT_CAPABILITIES.has(capability);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/** Uses that transfer control, so the site is drawn as flow. */
|
|
475
|
+
const FLOW_USES: ReadonlySet<string> = new Set([
|
|
476
|
+
"call",
|
|
477
|
+
"detached",
|
|
478
|
+
"trigger.inbound",
|
|
479
|
+
"trigger.consumer",
|
|
480
|
+
]);
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* The class a site is drawn as.
|
|
484
|
+
*
|
|
485
|
+
* A slot declaring NO use reads as flow, the same conservative direction the
|
|
486
|
+
* call graph takes: the cost of a false "control reaches here" is an edge drawn
|
|
487
|
+
* more prominently than it deserved, while the cost of a false "it never does"
|
|
488
|
+
* is a call the picture denies exists.
|
|
489
|
+
*/
|
|
490
|
+
export function edgeClassOf(use: readonly RefUse[]): EdgeClass {
|
|
491
|
+
if (use.length === 0) return "flow";
|
|
492
|
+
if (use.some((u) => FLOW_USES.has(u))) return "flow";
|
|
493
|
+
if (use.includes("dependency" as RefUse)) return "holds";
|
|
494
|
+
return "shape";
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/** What the projection needs from a registry, as a structural contract — so it
|
|
498
|
+
* folds over stubs in tests and over the real registry in a host, and so this
|
|
499
|
+
* module imports no registry class. */
|
|
500
|
+
export interface ModuleGraphDeps {
|
|
501
|
+
/** Every reference slot a resource's kind declares, filled or not. */
|
|
502
|
+
refFields(resource: ResourceManifest): {
|
|
503
|
+
path: string;
|
|
504
|
+
isArray: boolean;
|
|
505
|
+
refs: string[];
|
|
506
|
+
capabilities: string[];
|
|
507
|
+
}[];
|
|
508
|
+
/** The resource's definition, resolved in its declaring module's scope. */
|
|
509
|
+
definition(kind: string, module?: string): ResourceDefinition | undefined;
|
|
510
|
+
/** What invoking this resource can raise. Optional: a host without the
|
|
511
|
+
* resolver gets nodes with no error contract rather than a wrong one. */
|
|
512
|
+
throwsOf?(manifest: ResourceManifest): { codes: string[]; unbounded: boolean } | undefined;
|
|
513
|
+
/** Import aliases pointing at a module, so a reference written across the
|
|
514
|
+
* boundary as `!ref <Alias>.<name>` resolves to the instance it names. The
|
|
515
|
+
* call graph resolves bare names only — correct for a name declared here,
|
|
516
|
+
* and the reason every cross-module reference otherwise reads as dangling. */
|
|
517
|
+
aliasesForModule(module: string): string[];
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
export interface BuildModuleGraphOptions {
|
|
521
|
+
/** The module doc (`Telo.Application` / `Telo.Library`), which is not a
|
|
522
|
+
* resource but owns `targets` and is the boot root. */
|
|
523
|
+
root?: ResourceManifest;
|
|
524
|
+
/** Module name of the entry module, so an instance declared elsewhere is
|
|
525
|
+
* marked external rather than being told apart by a heuristic. */
|
|
526
|
+
entryModule?: string;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* Documents that declare a TYPE or a module rather than an instance.
|
|
531
|
+
*
|
|
532
|
+
* The flattened analysis carries every imported library's definitions beside
|
|
533
|
+
* its instances, so without this the instance plane fills with the abstracts a
|
|
534
|
+
* dependency happens to declare (`Sql.Connection`, `Codec.Encoder`) — boxes for
|
|
535
|
+
* things that never exist at runtime, drawn among the things that do. They are
|
|
536
|
+
* the kind plane's, which is a separate surface. The module docs are here too:
|
|
537
|
+
* the entry module's is minted as the ROOT, and an imported library's is not an
|
|
538
|
+
* instance at all.
|
|
539
|
+
*/
|
|
540
|
+
const DECLARATION_KINDS: ReadonlySet<string> = new Set([
|
|
541
|
+
"Telo.Definition",
|
|
542
|
+
"Telo.Abstract",
|
|
543
|
+
"Telo.Import",
|
|
544
|
+
"Telo.Application",
|
|
545
|
+
"Telo.Library",
|
|
546
|
+
]);
|
|
547
|
+
|
|
548
|
+
const moduleOf = (manifest: ResourceManifest): string | undefined =>
|
|
549
|
+
(manifest.metadata as { module?: string } | undefined)?.module;
|
|
550
|
+
|
|
551
|
+
/** The canonical id of a resolved kind definition — where it was declared plus
|
|
552
|
+
* what it is called there, which is the one spelling every module agrees on. */
|
|
553
|
+
const canonicalKindOf = (definition: ResourceDefinition | undefined): string | undefined => {
|
|
554
|
+
const metadata = definition?.metadata as { module?: string; name?: string } | undefined;
|
|
555
|
+
if (!metadata?.name) return undefined;
|
|
556
|
+
return metadata.module ? `${metadata.module}.${metadata.name}` : metadata.name;
|
|
557
|
+
};
|
|
558
|
+
|
|
559
|
+
const originOf = (
|
|
560
|
+
manifest: ResourceManifest,
|
|
561
|
+
): { parentKind: string; parentName: string; pathFromParent: string } | undefined =>
|
|
562
|
+
(
|
|
563
|
+
manifest.metadata as
|
|
564
|
+
| { xTeloOrigin?: { parentKind: string; parentName: string; pathFromParent: string } }
|
|
565
|
+
| undefined
|
|
566
|
+
)?.xTeloOrigin;
|
|
567
|
+
|
|
568
|
+
const isForwardedExport = (manifest: ResourceManifest): boolean =>
|
|
569
|
+
(manifest.metadata as { forwardedExport?: boolean } | undefined)?.forwardedExport === true;
|
|
570
|
+
|
|
571
|
+
const isInjected = (manifest: ResourceManifest): boolean =>
|
|
572
|
+
(manifest.metadata as Record<string, unknown> | undefined)?.["xTeloInjected"] === true;
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* A short, stable key over a value's shape.
|
|
576
|
+
*
|
|
577
|
+
* FNV-1a over canonical JSON: what is wanted is that the same written entry
|
|
578
|
+
* keeps the same identity when a sibling is inserted above it, which a hash of
|
|
579
|
+
* the entry's own content gives and an index cannot. Collisions are resolved by
|
|
580
|
+
* declaration order at the call site, so two byte-identical rows stay
|
|
581
|
+
* distinguishable without either one's identity depending on the other's
|
|
582
|
+
* position.
|
|
583
|
+
*/
|
|
584
|
+
export function contentKey(value: unknown): string {
|
|
585
|
+
const json = canonicalJson(value);
|
|
586
|
+
let hash = 0x811c9dc5;
|
|
587
|
+
for (let i = 0; i < json.length; i++) {
|
|
588
|
+
hash ^= json.charCodeAt(i);
|
|
589
|
+
hash = Math.imul(hash, 0x01000193) >>> 0;
|
|
590
|
+
}
|
|
591
|
+
return hash.toString(36);
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function canonicalJson(value: unknown): string {
|
|
595
|
+
if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "null";
|
|
596
|
+
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
|
|
597
|
+
const entries = Object.entries(value as Record<string, unknown>)
|
|
598
|
+
.filter(([, v]) => v !== undefined)
|
|
599
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
600
|
+
return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${canonicalJson(v)}`).join(",")}}`;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/** Mints ids that are unique without being positional: the name where one
|
|
604
|
+
* exists, a content key where none does, and a `~n` suffix only when two
|
|
605
|
+
* siblings are genuinely indistinguishable. */
|
|
606
|
+
class IdMinter {
|
|
607
|
+
private readonly used = new Set<string>();
|
|
608
|
+
|
|
609
|
+
mint(base: string): string {
|
|
610
|
+
if (!this.used.has(base)) {
|
|
611
|
+
this.used.add(base);
|
|
612
|
+
return base;
|
|
613
|
+
}
|
|
614
|
+
for (let n = 2; ; n++) {
|
|
615
|
+
const candidate = `${base}~${n}`;
|
|
616
|
+
if (!this.used.has(candidate)) {
|
|
617
|
+
this.used.add(candidate);
|
|
618
|
+
return candidate;
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/** The schema node at a field-map path, following `[]` into `items` and `{}`
|
|
625
|
+
* into `additionalProperties`, resolving local `$ref`s along the way. */
|
|
626
|
+
function schemaAt(
|
|
627
|
+
rootSchema: Record<string, any> | undefined,
|
|
628
|
+
slotPath: string,
|
|
629
|
+
): Record<string, any> | undefined {
|
|
630
|
+
if (!rootSchema) return undefined;
|
|
631
|
+
let current: Record<string, any> | undefined = rootSchema;
|
|
632
|
+
for (const segment of slotPath.split(".")) {
|
|
633
|
+
if (!current) return undefined;
|
|
634
|
+
// A map's key step is its OWN segment (`columns.{}.type`), where an array's
|
|
635
|
+
// rides the field it belongs to (`mounts[].mount`). Reading only the suffix
|
|
636
|
+
// form walked into nothing at the first map, so every slot under one was
|
|
637
|
+
// left with no schema — and a slot with no schema declares no `use`, which
|
|
638
|
+
// classed a column's typed reference as a control transfer.
|
|
639
|
+
if (segment === "{}") {
|
|
640
|
+
current = resolveLocalRef(
|
|
641
|
+
current.additionalProperties as Record<string, any> | undefined,
|
|
642
|
+
rootSchema,
|
|
643
|
+
);
|
|
644
|
+
continue;
|
|
645
|
+
}
|
|
646
|
+
const bare = segment.replace(/(\[\]|\{\})+$/g, "");
|
|
647
|
+
let next: Record<string, any> | undefined = propertySchemas(current).find(
|
|
648
|
+
([k]) => k === bare,
|
|
649
|
+
)?.[1];
|
|
650
|
+
for (const marker of segment.slice(bare.length).match(/\[\]|\{\}/g) ?? []) {
|
|
651
|
+
next = resolveLocalRef(
|
|
652
|
+
marker === "[]"
|
|
653
|
+
? (next?.items as Record<string, any> | undefined)
|
|
654
|
+
: (next?.additionalProperties as Record<string, any> | undefined),
|
|
655
|
+
rootSchema,
|
|
656
|
+
);
|
|
657
|
+
if (!next || typeof next !== "object") return undefined;
|
|
658
|
+
}
|
|
659
|
+
current = resolveLocalRef(next, rootSchema);
|
|
660
|
+
}
|
|
661
|
+
return current;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/** Every array field of a kind carrying `x-telo-topology-role: entries`, with
|
|
665
|
+
* the roles declared inside its items — `matcher` fields identify an entry to
|
|
666
|
+
* a reader, `handler` fields say what it dispatches to. No kind is named: a
|
|
667
|
+
* third-party router declaring the same three tokens renders identically. */
|
|
668
|
+
interface EntryListSpec {
|
|
669
|
+
field: string;
|
|
670
|
+
matchers: string[];
|
|
671
|
+
handlers: string[];
|
|
672
|
+
/** Sub-fields annotated `x-telo-error-context` — where an entry discharges a
|
|
673
|
+
* raised code. */
|
|
674
|
+
errorBranches: string[];
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/**
|
|
678
|
+
* Does this field hold the branch that DISCHARGES a raised error?
|
|
679
|
+
*
|
|
680
|
+
* Two annotations mark one, from two layers, and both are read: the shared CEL
|
|
681
|
+
* one (`x-telo-error-context`, which types the `error` variable inside a
|
|
682
|
+
* `catch:`) and the dispatch outcome vocabulary (`x-telo-outcome-list: catches`,
|
|
683
|
+
* which an HTTP-style router uses to render a code as a response). Neither is a
|
|
684
|
+
* field NAME, so a third-party composer spelling its branch differently is seen
|
|
685
|
+
* as long as it annotates it; recognizing only one would silently mark every
|
|
686
|
+
* route in the standard library as handling nothing.
|
|
687
|
+
*/
|
|
688
|
+
function isErrorBranch(schema: Record<string, any> | undefined): boolean {
|
|
689
|
+
return (
|
|
690
|
+
schema?.["x-telo-error-context"] !== undefined ||
|
|
691
|
+
schema?.["x-telo-outcome-list"] === "catches"
|
|
692
|
+
);
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
function entryListsOf(rootSchema: Record<string, any> | undefined): EntryListSpec[] {
|
|
696
|
+
if (!rootSchema) return [];
|
|
697
|
+
const out: EntryListSpec[] = [];
|
|
698
|
+
for (const [key, propSchema] of propertySchemas(rootSchema)) {
|
|
699
|
+
if (propSchema?.["x-telo-topology-role"] !== "entries") continue;
|
|
700
|
+
const items = resolveLocalRef(propSchema.items as Record<string, any>, rootSchema);
|
|
701
|
+
const matchers: string[] = [];
|
|
702
|
+
const handlers: string[] = [];
|
|
703
|
+
const errorBranches: string[] = [];
|
|
704
|
+
for (const [subKey, subSchema] of propertySchemas(items ?? {})) {
|
|
705
|
+
const role = subSchema?.["x-telo-topology-role"];
|
|
706
|
+
if (role === "matcher") matchers.push(subKey);
|
|
707
|
+
else if (role === "handler") handlers.push(subKey);
|
|
708
|
+
if (isErrorBranch(subSchema)) errorBranches.push(subKey);
|
|
709
|
+
}
|
|
710
|
+
out.push({ field: key, matchers, handlers, errorBranches });
|
|
711
|
+
}
|
|
712
|
+
return out;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
/** A value written AT a ref slot that declares a resource rather than naming
|
|
716
|
+
* one — `{kind, …config}` with no `name`. */
|
|
717
|
+
function isInlineDeclaration(value: unknown): boolean {
|
|
718
|
+
if (isRefSentinel(value) || !value || typeof value !== "object" || Array.isArray(value)) {
|
|
719
|
+
return false;
|
|
720
|
+
}
|
|
721
|
+
// The shape test itself is the field map's — one reader for "is this a
|
|
722
|
+
// declaration rather than a reference", since the extraction pass keys on the
|
|
723
|
+
// same answer and a second opinion here would decide differently the day the
|
|
724
|
+
// form gains a key.
|
|
725
|
+
return isInlineResource(value as Record<string, unknown>);
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/** The referenced name a ref value carries, across both written forms: an
|
|
729
|
+
* unresolved `!ref <name>` sentinel and the `{kind, name}` object
|
|
730
|
+
* `resolveRefSentinels` rewrites it into. */
|
|
731
|
+
function refName(value: unknown): string | undefined {
|
|
732
|
+
if (isRefSentinel(value)) return value.source;
|
|
733
|
+
if (!value || typeof value !== "object") return undefined;
|
|
734
|
+
const name = (value as Record<string, unknown>).name;
|
|
735
|
+
return typeof name === "string" ? name : undefined;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
/**
|
|
739
|
+
* Fold a manifest set into the module graph.
|
|
740
|
+
*
|
|
741
|
+
* The call graph supplies what calls what — including the scoped nodes and step
|
|
742
|
+
* bodies it already discovers — and this pass adds what a picture needs and a
|
|
743
|
+
* call graph has no reason to carry: declared-but-empty ports, ownership,
|
|
744
|
+
* ordered rows, region membership, and the reduction of six uses to three
|
|
745
|
+
* classes.
|
|
746
|
+
*/
|
|
747
|
+
export function buildModuleGraph(
|
|
748
|
+
resources: ResourceManifest[],
|
|
749
|
+
callGraph: CallGraph,
|
|
750
|
+
deps: ModuleGraphDeps,
|
|
751
|
+
options: BuildModuleGraphOptions = {},
|
|
752
|
+
): ModuleGraph {
|
|
753
|
+
const nodes: GraphNode[] = [];
|
|
754
|
+
const edges: GraphEdge[] = [];
|
|
755
|
+
const regions: GraphRegion[] = [];
|
|
756
|
+
const byId = new Map<string, GraphNode>();
|
|
757
|
+
const rowIdByPath = new Map<string, string>();
|
|
758
|
+
/** References found inside inline declarations, resolved once every node
|
|
759
|
+
* exists — a declaration may name a resource declared later in the file. */
|
|
760
|
+
const inlineEdgeSeeds = new Map<string, InlineEdgeSeed[]>();
|
|
761
|
+
|
|
762
|
+
const add = (node: GraphNode): GraphNode => {
|
|
763
|
+
nodes.push(node);
|
|
764
|
+
byId.set(node.id, node);
|
|
765
|
+
return node;
|
|
766
|
+
};
|
|
767
|
+
|
|
768
|
+
// --- the module root -------------------------------------------------------
|
|
769
|
+
// Not a resource: it owns `targets` and the module's own configuration, and
|
|
770
|
+
// the call graph deliberately skips it. Minting it here is what makes a boot
|
|
771
|
+
// target an ordinary edge rather than a fact a consumer has to fetch from
|
|
772
|
+
// somewhere else.
|
|
773
|
+
let root: GraphNode | undefined;
|
|
774
|
+
if (options.root) {
|
|
775
|
+
const name = (options.root.metadata?.name as string | undefined) ?? "";
|
|
776
|
+
const module = moduleOf(options.root);
|
|
777
|
+
root = add({
|
|
778
|
+
id: resourceId(options.root.kind as string, name),
|
|
779
|
+
kind: options.root.kind as string,
|
|
780
|
+
name,
|
|
781
|
+
ownership: "root",
|
|
782
|
+
...(module ? { module } : {}),
|
|
783
|
+
ports: [],
|
|
784
|
+
rows: [],
|
|
785
|
+
// Boot targets are an ordered list the root always has, empty or not.
|
|
786
|
+
rowArrays: [{ field: "targets", kind: "target" }],
|
|
787
|
+
root: true,
|
|
788
|
+
});
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
// --- resource nodes --------------------------------------------------------
|
|
792
|
+
// Minted from the MANIFEST LIST, not from the call graph's node map.
|
|
793
|
+
//
|
|
794
|
+
// The call graph keys a resource by `(kind, name)`, which is unique within one
|
|
795
|
+
// module and not across a flattened set: two libraries each exporting an
|
|
796
|
+
// `Http.Api` named `routes` collapse onto one node there, and a picture built
|
|
797
|
+
// from that map draws one box for two declarations. Minting here from the
|
|
798
|
+
// manifests keeps both, qualified by their declaring module. What is still the
|
|
799
|
+
// call graph's — steps and edges — is translated through `projectedId`, and
|
|
800
|
+
// the collapsed twin's own edges are missing from it; its ports and rows are
|
|
801
|
+
// read from its manifest here, so the box is drawn and wired correctly and
|
|
802
|
+
// only the edges the call graph lost are absent.
|
|
803
|
+
// One id scheme, the call graph's — a resource name is module-scoped, so the
|
|
804
|
+
// module is part of the identity wherever one is stamped. Stating it here a
|
|
805
|
+
// second way is how the two halves would disagree about which box an edge
|
|
806
|
+
// arrives at.
|
|
807
|
+
const idOf = (manifest: ResourceManifest): string => nodeIdFor(manifest);
|
|
808
|
+
|
|
809
|
+
const manifestById = new Map<string, ResourceManifest>();
|
|
810
|
+
for (const manifest of resources) {
|
|
811
|
+
const name = manifest.metadata?.name;
|
|
812
|
+
if (typeof name !== "string" || !manifest.kind || DECLARATION_KINDS.has(manifest.kind)) continue;
|
|
813
|
+
if (root && manifest === options.root) continue;
|
|
814
|
+
const id = idOf(manifest);
|
|
815
|
+
if (byId.has(id)) continue;
|
|
816
|
+
add(projectResource(id, manifest, deps, options));
|
|
817
|
+
manifestById.set(id, manifest);
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
// Call-graph node id → the node it designates here, so a step or an edge the
|
|
821
|
+
// call graph produced lands on the box this pass minted.
|
|
822
|
+
const projectedId = new Map<string, string>();
|
|
823
|
+
const scopedIdByKey = new Map<string, string>();
|
|
824
|
+
const scopedByOwner = new Map<string, Map<string, string[]>>();
|
|
825
|
+
for (const graphNode of callGraph.nodes.values()) {
|
|
826
|
+
if (graphNode.type !== "resource") continue;
|
|
827
|
+
if (DECLARATION_KINDS.has(graphNode.kind)) continue;
|
|
828
|
+
if (graphNode.scoped) {
|
|
829
|
+
// A `with:`-scoped resource is declared inside another's body, so it is in
|
|
830
|
+
// no manifest list of its own — the call graph is where it exists.
|
|
831
|
+
//
|
|
832
|
+
// One declaration, one box: the call graph keys a scoped node by the
|
|
833
|
+
// scope POINTER, and `x-telo-scope` lists every region a scoped name
|
|
834
|
+
// resolves in (`Run.Sequence` names both `/steps` and `/targets`), so one
|
|
835
|
+
// `with:` entry arrives once per pointer. The pointers say where the name
|
|
836
|
+
// is visible, not where the resource was declared.
|
|
837
|
+
const key = `${graphNode.scopeOwner ?? ""}#scope#${resourceId(graphNode.kind, graphNode.name)}`;
|
|
838
|
+
const already = scopedIdByKey.get(key);
|
|
839
|
+
if (already) {
|
|
840
|
+
projectedId.set(graphNode.id, already);
|
|
841
|
+
continue;
|
|
842
|
+
}
|
|
843
|
+
const node = add(projectResource(graphNode.id, graphNode.manifest, deps, options));
|
|
844
|
+
scopedIdByKey.set(key, node.id);
|
|
845
|
+
node.ownership = "scoped";
|
|
846
|
+
if (graphNode.scopeOwner) node.owner = graphNode.scopeOwner;
|
|
847
|
+
if (graphNode.scopeSite) node.ownerSite = graphNode.scopeSite;
|
|
848
|
+
manifestById.set(node.id, graphNode.manifest);
|
|
849
|
+
projectedId.set(graphNode.id, node.id);
|
|
850
|
+
const owner = graphNode.scopeOwner;
|
|
851
|
+
if (owner) {
|
|
852
|
+
const sites = scopedByOwner.get(owner) ?? new Map<string, string[]>();
|
|
853
|
+
const site = graphNode.scopeSite ?? "";
|
|
854
|
+
sites.set(site, [...(sites.get(site) ?? []), node.id]);
|
|
855
|
+
scopedByOwner.set(owner, sites);
|
|
856
|
+
}
|
|
857
|
+
continue;
|
|
858
|
+
}
|
|
859
|
+
projectedId.set(graphNode.id, idOf(graphNode.manifest));
|
|
860
|
+
}
|
|
861
|
+
// A scoped node's owner is a call-graph id; translate it now that every
|
|
862
|
+
// resource node has one.
|
|
863
|
+
for (const node of nodes) {
|
|
864
|
+
if (node.ownership === "scoped" && node.owner) {
|
|
865
|
+
node.owner = projectedId.get(node.owner) ?? node.owner;
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
for (const [owner, sites] of [...scopedByOwner]) {
|
|
869
|
+
const translated = projectedId.get(owner);
|
|
870
|
+
if (translated && translated !== owner) {
|
|
871
|
+
scopedByOwner.delete(owner);
|
|
872
|
+
scopedByOwner.set(translated, sites);
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
// Inline children: the extraction stamps the parent and the path it was
|
|
877
|
+
// written at, so ownership is read off the declaration rather than guessed
|
|
878
|
+
// from a name pattern. The stamp names the parent by `(kind, name)`, so it is
|
|
879
|
+
// translated through the same table a call-graph id is.
|
|
880
|
+
const inlineByOwner = new Map<string, Map<string, string[]>>();
|
|
881
|
+
for (const node of nodes) {
|
|
882
|
+
if (node.ownership !== "inline" || !node.owner) continue;
|
|
883
|
+
node.owner = projectedId.get(node.owner) ?? node.owner;
|
|
884
|
+
const sites = inlineByOwner.get(node.owner) ?? new Map<string, string[]>();
|
|
885
|
+
const site = node.ownerSite ?? "";
|
|
886
|
+
sites.set(site, [...(sites.get(site) ?? []), node.id]);
|
|
887
|
+
inlineByOwner.set(node.owner, sites);
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
for (const [owner, sites] of inlineByOwner) {
|
|
891
|
+
for (const [site, members] of sites) {
|
|
892
|
+
regions.push({ id: `${owner}#inline:${site}`, kind: "inline", owner, site, members });
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
for (const [owner, sites] of scopedByOwner) {
|
|
896
|
+
for (const [site, members] of sites) {
|
|
897
|
+
regions.push({ id: `${owner}#scope:${site}`, kind: "scope", owner, site, members });
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
// Execution zones: a region every dispatch inside runs within. Read from the
|
|
902
|
+
// containment walk rather than re-derived, so what the editor draws and what
|
|
903
|
+
// `telo check` enforces are the same region — including one declaring no
|
|
904
|
+
// attributes, which is still a zone.
|
|
905
|
+
for (const zone of findZoneProviders(callGraph, (kind, module) => deps.definition(kind, module))) {
|
|
906
|
+
const owner = projectedId.get(zone.provider.id) ?? zone.provider.id;
|
|
907
|
+
if (!byId.has(owner)) continue;
|
|
908
|
+
const members: string[] = [];
|
|
909
|
+
for (const [id, contained] of zone.contents) {
|
|
910
|
+
// A step is a ROW of its owner here, so a zone reaching one is a zone
|
|
911
|
+
// reaching the resource whose body declares it.
|
|
912
|
+
const memberId =
|
|
913
|
+
contained.node.type === "step"
|
|
914
|
+
? (projectedId.get(contained.node.owner) ?? contained.node.owner)
|
|
915
|
+
: (projectedId.get(id) ?? id);
|
|
916
|
+
if (memberId !== owner && byId.has(memberId) && !members.includes(memberId)) {
|
|
917
|
+
members.push(memberId);
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
const boundaries = zone.boundaries.map((b) => ({
|
|
921
|
+
from: projectedId.get(b.from.id) ?? b.from.id,
|
|
922
|
+
toName: b.edge.toName,
|
|
923
|
+
escaping: b.escaping,
|
|
924
|
+
}));
|
|
925
|
+
regions.push({
|
|
926
|
+
id: `${owner}#zone:${zone.slot}`,
|
|
927
|
+
kind: "zone",
|
|
928
|
+
owner,
|
|
929
|
+
site: zone.slot,
|
|
930
|
+
members,
|
|
931
|
+
attributes: zone.attributes,
|
|
932
|
+
...(boundaries.length > 0 ? { boundaries } : {}),
|
|
933
|
+
});
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
// --- rows ------------------------------------------------------------------
|
|
937
|
+
// Steps come from the call graph, which owns the analyzer's only step-array
|
|
938
|
+
// recursion; entry lists and boot targets are read here, since neither is a
|
|
939
|
+
// step body and neither has a node of its own.
|
|
940
|
+
const callGraphIdByNode = invertProjectedIds(projectedId);
|
|
941
|
+
const callGraphIdOf = (nodeId: string): string => callGraphIdByNode.get(nodeId) ?? nodeId;
|
|
942
|
+
|
|
943
|
+
for (const node of nodes) {
|
|
944
|
+
const manifest = node.root ? options.root : manifestById.get(node.id);
|
|
945
|
+
if (!manifest) continue;
|
|
946
|
+
const definition = deps.definition(node.kind, node.module);
|
|
947
|
+
const schema = definition?.schema as Record<string, any> | undefined;
|
|
948
|
+
// The root's rows are its BOOT LIST and nothing else. `targets` carries the
|
|
949
|
+
// step grammar, so the call graph mints a step node for every entry that is
|
|
950
|
+
// not a bare `!ref` — and `targetRows` already renders every shape an entry
|
|
951
|
+
// takes, so collecting both listed an inline target twice.
|
|
952
|
+
node.rows = node.root
|
|
953
|
+
? targetRows(node, manifest, rowIdByPath)
|
|
954
|
+
: [
|
|
955
|
+
...stepRows(node, callGraph, callGraphIdOf(node.id), rowIdByPath),
|
|
956
|
+
...entryRows(node, manifest, schema, rowIdByPath),
|
|
957
|
+
];
|
|
958
|
+
if (!node.root) node.rowArrays = declaredRowArrays(schema);
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
// --- ports and edges -------------------------------------------------------
|
|
962
|
+
for (const node of nodes) {
|
|
963
|
+
const manifest = node.root ? options.root : manifestById.get(node.id);
|
|
964
|
+
if (!manifest) continue;
|
|
965
|
+
const definition = deps.definition(node.kind, node.module);
|
|
966
|
+
const schema = definition?.schema as Record<string, any> | undefined;
|
|
967
|
+
// A slot whose occupancy is DRAWN AS ROWS is not also a port: a route, a
|
|
968
|
+
// boot target and a step are manipulated as the ordered thing they are, and
|
|
969
|
+
// a second rendering of the same occupancy beside it is two controls for
|
|
970
|
+
// one fact. Read off the DECLARED arrays, not the rows: an empty `mounts`
|
|
971
|
+
// would otherwise be row-owned only once it had a mount in it, so a fresh
|
|
972
|
+
// server showed both a port and an add control for the same list.
|
|
973
|
+
const rowArrays = new Set(node.rowArrays.map((a) => a.field));
|
|
974
|
+
node.ports = buildPorts(manifest, deps, schema, rowArrays);
|
|
975
|
+
const throws = deps.throwsOf?.(manifest);
|
|
976
|
+
if (throws && (throws.codes.length > 0 || throws.unbounded)) node.throws = throws;
|
|
977
|
+
// A declaration written at a dispatch site hangs under the row that
|
|
978
|
+
// declares it — see `inlineRows`. After the ports, because a route's
|
|
979
|
+
// `handler:` is a port slot and its occupancy is what names the site; and
|
|
980
|
+
// woven rather than appended, so the body stays pre-order, which every
|
|
981
|
+
// consumer of `parent` relies on.
|
|
982
|
+
node.rows = weaveInlineRows(
|
|
983
|
+
node,
|
|
984
|
+
manifest,
|
|
985
|
+
callGraph,
|
|
986
|
+
callGraphIdOf(node.id),
|
|
987
|
+
deps,
|
|
988
|
+
rowIdByPath,
|
|
989
|
+
inlineEdgeSeeds,
|
|
990
|
+
);
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
// Alias-qualified names, so a reference across an import boundary resolves.
|
|
994
|
+
// The call graph matches bare names — right for a name declared here, and the
|
|
995
|
+
// reason `!ref Console.writeLine` reached this pass as a dangling edge.
|
|
996
|
+
// Every row is known by now, so the per-owner index the longest-prefix walk
|
|
997
|
+
// needs is built once rather than re-scanned per edge.
|
|
998
|
+
const rowsByOwner = rowsByOwnerOf(rowIdByPath);
|
|
999
|
+
|
|
1000
|
+
const byQualifiedName = new Map<string, string>();
|
|
1001
|
+
for (const node of nodes) {
|
|
1002
|
+
if (!node.module) continue;
|
|
1003
|
+
for (const alias of deps.aliasesForModule(node.module)) {
|
|
1004
|
+
const key = `${alias}.${node.name}`;
|
|
1005
|
+
if (!byQualifiedName.has(key)) byQualifiedName.set(key, node.id);
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
// Edges come from the call graph — one per site, already resolved — re-keyed
|
|
1010
|
+
// onto the boxes a view draws: a step's edge is attributed to the resource
|
|
1011
|
+
// whose body declares it, with the row that declared it named, because a step
|
|
1012
|
+
// is a row here rather than a node of its own.
|
|
1013
|
+
for (const edge of callGraph.edges) {
|
|
1014
|
+
const projected = projectEdge(
|
|
1015
|
+
edge,
|
|
1016
|
+
callGraph,
|
|
1017
|
+
byId,
|
|
1018
|
+
rowIdByPath,
|
|
1019
|
+
rowsByOwner,
|
|
1020
|
+
byQualifiedName,
|
|
1021
|
+
projectedId,
|
|
1022
|
+
);
|
|
1023
|
+
if (!projected) continue;
|
|
1024
|
+
// A reference leaving the module root IS a boot target — the root has no
|
|
1025
|
+
// other slots — so the flag is stamped here rather than by a second pass
|
|
1026
|
+
// over `targets`, which emitted a duplicate edge for every one of them.
|
|
1027
|
+
if (root && projected.from === root.id) projected.boot = true;
|
|
1028
|
+
edges.push(projected);
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
// Data edges: one resource reading another's published state in CEL. Parsed,
|
|
1032
|
+
// never scanned — `extractAccessChains` reads `resources.db.status.port` as a
|
|
1033
|
+
// chain and a name inside a string literal as nothing, which a token scan
|
|
1034
|
+
// cannot tell apart.
|
|
1035
|
+
// A bare name resolves in the module that WROTE it — the call graph's own
|
|
1036
|
+
// rule, shared rather than restated. Keeping a first-wins index here would
|
|
1037
|
+
// have put every inline declaration's reference and every CEL state read back
|
|
1038
|
+
// on whichever module happened to come first in the flattened list, which is
|
|
1039
|
+
// the collision module-scoped identity exists to prevent.
|
|
1040
|
+
const nodesByName = new Map<string, GraphNode[]>();
|
|
1041
|
+
for (const node of nodes) {
|
|
1042
|
+
if (node.root) continue;
|
|
1043
|
+
nodesByName.set(node.name, [...(nodesByName.get(node.name) ?? []), node]);
|
|
1044
|
+
}
|
|
1045
|
+
const resolveName = (name: string, fromModule: string | undefined): string | undefined =>
|
|
1046
|
+
resolveScopedName(nodesByName.get(name), (node) => node.module, fromModule)?.id;
|
|
1047
|
+
|
|
1048
|
+
// References written INSIDE a declaration. Resolved here rather than where
|
|
1049
|
+
// they were found, because a declaration may name a resource declared later
|
|
1050
|
+
// in the file — and against the same name index every other edge uses, so a
|
|
1051
|
+
// hold reached through an inline declaration counts exactly as one written at
|
|
1052
|
+
// a named resource's own slot.
|
|
1053
|
+
for (const [from, list] of inlineEdgeSeeds) {
|
|
1054
|
+
const fromModule = byId.get(from)?.module;
|
|
1055
|
+
for (const seed of list) {
|
|
1056
|
+
const to = resolveName(seed.toName, fromModule) ?? byQualifiedName.get(seed.toName);
|
|
1057
|
+
const edge: GraphEdge = {
|
|
1058
|
+
id: `${from}\0${seed.path}`,
|
|
1059
|
+
from,
|
|
1060
|
+
toName: seed.toName,
|
|
1061
|
+
class: edgeClassOf(seed.uses),
|
|
1062
|
+
use: seed.uses,
|
|
1063
|
+
slot: seed.slot,
|
|
1064
|
+
path: seed.path,
|
|
1065
|
+
row: seed.rowId,
|
|
1066
|
+
};
|
|
1067
|
+
if (to) edge.to = to;
|
|
1068
|
+
edges.push(edge);
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
for (const node of nodes) {
|
|
1073
|
+
const manifest = node.root ? options.root : manifestById.get(node.id);
|
|
1074
|
+
if (!manifest) continue;
|
|
1075
|
+
edges.push(...dataEdges(node, manifest, node.module, resolveName, rowIdByPath, rowsByOwner));
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
// Where each row's call is WRITTEN, and what may fill it — see
|
|
1079
|
+
// `GraphRow.dispatch`. Three shapes, because the grammar has three: a step's
|
|
1080
|
+
// slot is declared on its item schema and reachable only through the step
|
|
1081
|
+
// walk; an entry's is a row-owned port of the array it sits in; a boot target
|
|
1082
|
+
// IS its own slot. All three are stated even when nothing fills them, since
|
|
1083
|
+
// an empty site is exactly the one an editor has something to offer at.
|
|
1084
|
+
for (const node of nodes) {
|
|
1085
|
+
const stepSlots = new Map<string, GraphRow["dispatch"]>();
|
|
1086
|
+
const stepSites = new Map<string, { path: string; refs: string[] }[]>();
|
|
1087
|
+
for (const step of callGraph.steps(callGraphIdOf(node.id))) {
|
|
1088
|
+
const first = step.refSlots?.[0];
|
|
1089
|
+
if (first) {
|
|
1090
|
+
stepSlots.set(step.path, {
|
|
1091
|
+
path: first.path,
|
|
1092
|
+
refs: first.kinds,
|
|
1093
|
+
...(first.inline ? { inline: true } : {}),
|
|
1094
|
+
});
|
|
1095
|
+
}
|
|
1096
|
+
if (step.refSlots?.length) {
|
|
1097
|
+
stepSites.set(
|
|
1098
|
+
step.path,
|
|
1099
|
+
step.refSlots.map((slot) => ({ path: slot.path, refs: slot.kinds })),
|
|
1100
|
+
);
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
// Whether a port-derived site holds a declaration, by its concrete path.
|
|
1104
|
+
const inlineAt = new Set(
|
|
1105
|
+
node.ports.flatMap((port) => port.slots.filter((s) => s.inline).map((s) => s.path)),
|
|
1106
|
+
);
|
|
1107
|
+
const rowOwnedPorts = node.ports.filter((port) => port.rowOwned);
|
|
1108
|
+
for (const row of node.rows) {
|
|
1109
|
+
if (row.kind === "step") {
|
|
1110
|
+
const slot = stepSlots.get(row.path);
|
|
1111
|
+
if (slot) {
|
|
1112
|
+
row.dispatch = withAlternatives(slot, stepSites.get(row.path) ?? []);
|
|
1113
|
+
}
|
|
1114
|
+
continue;
|
|
1115
|
+
}
|
|
1116
|
+
if (row.kind === "target") {
|
|
1117
|
+
const port = node.ports.find((p) => p.slot === `${row.array}[]`);
|
|
1118
|
+
if (port) {
|
|
1119
|
+
row.dispatch = withAlternatives(
|
|
1120
|
+
{
|
|
1121
|
+
path: row.path,
|
|
1122
|
+
refs: port.refs,
|
|
1123
|
+
...(inlineAt.has(row.path) ? { inline: true } : {}),
|
|
1124
|
+
},
|
|
1125
|
+
// A boot target IS a step, and the step walk is what sees the sites
|
|
1126
|
+
// the entry's own grammar declares — the bare reference the port
|
|
1127
|
+
// reports is one spelling of one of them.
|
|
1128
|
+
stepSites.get(row.path) ?? [],
|
|
1129
|
+
);
|
|
1130
|
+
}
|
|
1131
|
+
continue;
|
|
1132
|
+
}
|
|
1133
|
+
if (row.kind !== "entry") continue;
|
|
1134
|
+
// `routes[].handler` → the handler of THIS route, whether or not one is
|
|
1135
|
+
// written: the port's own slots list only the routes that have one.
|
|
1136
|
+
const port = rowOwnedPorts.find((p) => containerArrayOf(p.slot) === row.array);
|
|
1137
|
+
if (port) {
|
|
1138
|
+
const path = `${row.path}.${port.slot.slice(port.slot.indexOf("[].") + 3)}`;
|
|
1139
|
+
row.dispatch = {
|
|
1140
|
+
path,
|
|
1141
|
+
refs: port.refs,
|
|
1142
|
+
...(inlineAt.has(path) ? { inline: true } : {}),
|
|
1143
|
+
};
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
// Back-fill what a row learns from the edge it declares: where its target
|
|
1149
|
+
// resolved, and where this call's arguments are written. Both are the edge's
|
|
1150
|
+
// to know — a row is read off the manifest, while `inputs` is a POINTER the
|
|
1151
|
+
// slot declares and the target is a name the graph resolved — so they are
|
|
1152
|
+
// stamped here rather than guessed twice.
|
|
1153
|
+
const rowById = new Map<string, GraphRow>();
|
|
1154
|
+
for (const node of nodes) for (const row of node.rows) rowById.set(row.id, row);
|
|
1155
|
+
for (const edge of edges) {
|
|
1156
|
+
const row = edge.row ? rowById.get(edge.row) : undefined;
|
|
1157
|
+
if (!row) continue;
|
|
1158
|
+
if (edge.to && row.targetNode === undefined) row.targetNode = edge.to;
|
|
1159
|
+
if (row.target === undefined) row.target = edge.toName;
|
|
1160
|
+
if (edge.inputs !== undefined && row.inputs === undefined) {
|
|
1161
|
+
// The pointer is relative to the object ENCLOSING the slot, which for a
|
|
1162
|
+
// step or an entry is the row itself.
|
|
1163
|
+
row.inputs = `${row.path}${edge.inputs.replace(/\//g, ".")}`;
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
const fromIndex = new Map<string, GraphEdge[]>();
|
|
1168
|
+
const toIndex = new Map<string, GraphEdge[]>();
|
|
1169
|
+
for (const edge of edges) {
|
|
1170
|
+
fromIndex.set(edge.from, [...(fromIndex.get(edge.from) ?? []), edge]);
|
|
1171
|
+
if (edge.to) toIndex.set(edge.to, [...(toIndex.get(edge.to) ?? []), edge]);
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
return {
|
|
1175
|
+
root,
|
|
1176
|
+
nodes,
|
|
1177
|
+
edges,
|
|
1178
|
+
regions,
|
|
1179
|
+
kinds: buildKindPlane(resources, nodes, deps, options),
|
|
1180
|
+
nodeById: (id) => byId.get(id),
|
|
1181
|
+
edgesFrom: (id) => fromIndex.get(id) ?? [],
|
|
1182
|
+
edgesTo: (id) => toIndex.get(id) ?? [],
|
|
1183
|
+
};
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
/**
|
|
1187
|
+
* The call-graph id a projected node came from — the reverse of `projectedId`.
|
|
1188
|
+
*
|
|
1189
|
+
* Built ONCE. Inverting the map per lookup was a linear scan inside three
|
|
1190
|
+
* per-node loops, so a module of n boxes paid O(n²) three times over on every
|
|
1191
|
+
* keystroke.
|
|
1192
|
+
*/
|
|
1193
|
+
function invertProjectedIds(projectedId: ReadonlyMap<string, string>): Map<string, string> {
|
|
1194
|
+
const out = new Map<string, string>();
|
|
1195
|
+
for (const [callGraphId, projected] of projectedId) {
|
|
1196
|
+
if (!out.has(projected)) out.set(projected, callGraphId);
|
|
1197
|
+
}
|
|
1198
|
+
return out;
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
function projectResource(
|
|
1202
|
+
id: string,
|
|
1203
|
+
manifest: ResourceManifest,
|
|
1204
|
+
deps: ModuleGraphDeps,
|
|
1205
|
+
options: BuildModuleGraphOptions,
|
|
1206
|
+
): GraphNode {
|
|
1207
|
+
const module = moduleOf(manifest);
|
|
1208
|
+
const kind = manifest.kind as string;
|
|
1209
|
+
const definition = deps.definition(kind, module);
|
|
1210
|
+
const origin = originOf(manifest);
|
|
1211
|
+
|
|
1212
|
+
const node: GraphNode = {
|
|
1213
|
+
id,
|
|
1214
|
+
kind,
|
|
1215
|
+
name: manifest.metadata?.name as string,
|
|
1216
|
+
ownership: "named",
|
|
1217
|
+
ports: [],
|
|
1218
|
+
rows: [],
|
|
1219
|
+
rowArrays: [],
|
|
1220
|
+
};
|
|
1221
|
+
if (definition?.capability) node.capability = definition.capability as string;
|
|
1222
|
+
const canonical = canonicalKindOf(definition);
|
|
1223
|
+
if (canonical && canonical !== kind) node.canonicalKind = canonical;
|
|
1224
|
+
if (!definition) node.unknownKind = true;
|
|
1225
|
+
if (module) node.module = module;
|
|
1226
|
+
|
|
1227
|
+
if (origin) {
|
|
1228
|
+
node.ownership = "inline";
|
|
1229
|
+
node.owner = resourceId(origin.parentKind, origin.parentName);
|
|
1230
|
+
node.ownerSite = origin.pathFromParent;
|
|
1231
|
+
} else if (isInjected(manifest)) {
|
|
1232
|
+
node.ownership = "injected";
|
|
1233
|
+
} else if (isForwardedExport(manifest)) {
|
|
1234
|
+
node.ownership = "imported";
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
// External is a fact about the DECLARING module, not about the ownership
|
|
1238
|
+
// class: a library's own named resource forwarded into an app is `imported`,
|
|
1239
|
+
// while a resource the app declares in an included partial is not — both are
|
|
1240
|
+
// decided by the module stamp rather than by how the reference reached here.
|
|
1241
|
+
if (options.entryModule && module && module !== options.entryModule) {
|
|
1242
|
+
node.external = true;
|
|
1243
|
+
// The alias the reference is WRITTEN with. Several may point at one module;
|
|
1244
|
+
// the first is taken, because a boundary box needs one label and every
|
|
1245
|
+
// alias designates the same module.
|
|
1246
|
+
const alias = module ? deps.aliasesForModule(module)[0] : undefined;
|
|
1247
|
+
if (alias) node.alias = alias;
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
return node;
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
/** Step rows, from the call graph's step nodes. Depth and parent come from the
|
|
1254
|
+
* nesting the call graph already recorded; identity is re-anchored on names. */
|
|
1255
|
+
function stepRows(
|
|
1256
|
+
node: GraphNode,
|
|
1257
|
+
callGraph: CallGraph,
|
|
1258
|
+
callGraphId: string,
|
|
1259
|
+
rowIdByPath: Map<string, string>,
|
|
1260
|
+
): GraphRow[] {
|
|
1261
|
+
const steps = callGraph.steps(callGraphId);
|
|
1262
|
+
if (steps.length === 0) return [];
|
|
1263
|
+
const minter = new IdMinter();
|
|
1264
|
+
const idByStepPath = new Map<string, string>();
|
|
1265
|
+
const rows: GraphRow[] = [];
|
|
1266
|
+
|
|
1267
|
+
for (const step of steps) {
|
|
1268
|
+
const parentId = step.parent ? idByStepPath.get(step.parent) : undefined;
|
|
1269
|
+
const anchor = parentId ? `${parentId}/` : `${node.id}#step:`;
|
|
1270
|
+
const key = step.name ?? `@${contentKey(step.step)}`;
|
|
1271
|
+
const id = minter.mint(`${anchor}${key}`);
|
|
1272
|
+
idByStepPath.set(step.id, id);
|
|
1273
|
+
rowIdByPath.set(`${node.id}\0${step.path}`, id);
|
|
1274
|
+
|
|
1275
|
+
const row: GraphRow = {
|
|
1276
|
+
id,
|
|
1277
|
+
kind: "step",
|
|
1278
|
+
path: step.path,
|
|
1279
|
+
array: step.array,
|
|
1280
|
+
index: step.index,
|
|
1281
|
+
depth: depthOf(step, callGraph, callGraphId),
|
|
1282
|
+
...(step.name !== undefined ? { name: step.name } : {}),
|
|
1283
|
+
...(step.variant !== undefined ? { variant: step.variant } : {}),
|
|
1284
|
+
...(step.variantLabel !== undefined ? { variantLabel: step.variantLabel } : {}),
|
|
1285
|
+
...(step.predicate !== undefined ? { predicate: step.predicate } : {}),
|
|
1286
|
+
...(parentId ? { parent: parentId } : {}),
|
|
1287
|
+
};
|
|
1288
|
+
rows.push(row);
|
|
1289
|
+
}
|
|
1290
|
+
return rows;
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
/**
|
|
1294
|
+
* One dispatch, with the other ways its grammar lets it be written.
|
|
1295
|
+
*
|
|
1296
|
+
* Deduplicated by CONSTRAINT, not by path: two spellings accepting the same
|
|
1297
|
+
* kinds are one site, and which of them to write is a choice a reader should
|
|
1298
|
+
* never be asked to make (the primary is the plainer form). A spelling that
|
|
1299
|
+
* accepts something the primary cannot is a site of its own, because it is the
|
|
1300
|
+
* only address a reference to such a target could be written at.
|
|
1301
|
+
*/
|
|
1302
|
+
function withAlternatives(
|
|
1303
|
+
primary: NonNullable<GraphRow["dispatch"]>,
|
|
1304
|
+
sites: readonly { path: string; refs: string[] }[],
|
|
1305
|
+
): NonNullable<GraphRow["dispatch"]> {
|
|
1306
|
+
const key = (refs: readonly string[]) => [...refs].sort().join("\u0000");
|
|
1307
|
+
const seen = new Set([key(primary.refs)]);
|
|
1308
|
+
const alternatives: { path: string; refs: string[] }[] = [];
|
|
1309
|
+
for (const site of sites) {
|
|
1310
|
+
if (site.path === primary.path || seen.has(key(site.refs))) continue;
|
|
1311
|
+
seen.add(key(site.refs));
|
|
1312
|
+
alternatives.push(site);
|
|
1313
|
+
}
|
|
1314
|
+
return alternatives.length > 0 ? { ...primary, alternatives } : primary;
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
/**
|
|
1318
|
+
* The rows and edges a DECLARATION written at a dispatch site contributes.
|
|
1319
|
+
*
|
|
1320
|
+
* `invoke: { kind: Sql.Command, connection: !ref chatDb }` is a resource the
|
|
1321
|
+
* manifest genuinely declares, and until now the graph could see none of it: no
|
|
1322
|
+
* node, no edge, and a step row identical to one that dispatches nothing. The
|
|
1323
|
+
* hold was invisible too, so a connection reached only from inside inline
|
|
1324
|
+
* declarations was reported as referenced by nothing.
|
|
1325
|
+
*
|
|
1326
|
+
* What is emitted is one row for the declaration — named by its kind, addressed
|
|
1327
|
+
* at the site, so it can be opened and edited where it was written — and one row
|
|
1328
|
+
* per reference it fills, each carrying a real edge. That is what puts the hold
|
|
1329
|
+
* back on the graph, and it is why these are rows rather than a label: a
|
|
1330
|
+
* reference needs somewhere for its line to leave from.
|
|
1331
|
+
*
|
|
1332
|
+
* Recursive, because a declaration may hold another; bounded by the manifest,
|
|
1333
|
+
* which cannot contain itself.
|
|
1334
|
+
*/
|
|
1335
|
+
function inlineRows(
|
|
1336
|
+
node: GraphNode,
|
|
1337
|
+
site: { path: string; value: Record<string, unknown> },
|
|
1338
|
+
host: { rowId: string; array: string; depth: number; slot: string },
|
|
1339
|
+
deps: ModuleGraphDeps,
|
|
1340
|
+
rowIdByPath: Map<string, string>,
|
|
1341
|
+
out: { rows: GraphRow[]; edges: InlineEdgeSeed[] },
|
|
1342
|
+
): void {
|
|
1343
|
+
const kind = site.value.kind as string;
|
|
1344
|
+
const declared = deps.definition(kind, node.module);
|
|
1345
|
+
const id = `${host.rowId}/${lastPathSegment(site.path)}`;
|
|
1346
|
+
const row: GraphRow = {
|
|
1347
|
+
id,
|
|
1348
|
+
kind: "inline",
|
|
1349
|
+
path: site.path,
|
|
1350
|
+
array: host.array,
|
|
1351
|
+
index: 0,
|
|
1352
|
+
depth: host.depth + 1,
|
|
1353
|
+
parent: host.rowId,
|
|
1354
|
+
declares: kind,
|
|
1355
|
+
...(declared ? {} : { unknownKind: true }),
|
|
1356
|
+
};
|
|
1357
|
+
out.rows.push(row);
|
|
1358
|
+
rowIdByPath.set(`${node.id}\0${site.path}`, id);
|
|
1359
|
+
|
|
1360
|
+
// The declaration's own reference slots, read through its kind's field map —
|
|
1361
|
+
// the same map a named resource's ports come from, so an inline declaration
|
|
1362
|
+
// and an extracted one describe themselves identically.
|
|
1363
|
+
const asManifest = { ...site.value, metadata: { name: id } } as unknown as ResourceManifest;
|
|
1364
|
+
const declaredSchema = declared?.schema as Record<string, any> | undefined;
|
|
1365
|
+
for (const field of deps.refFields(asManifest)) {
|
|
1366
|
+
for (const entry of resolveFieldEntries(asManifest, field.path)) {
|
|
1367
|
+
const path = `${site.path}.${entry.path}`;
|
|
1368
|
+
if (isInlineDeclaration(entry.value)) {
|
|
1369
|
+
inlineRows(
|
|
1370
|
+
node,
|
|
1371
|
+
{ path, value: entry.value as Record<string, unknown> },
|
|
1372
|
+
{ rowId: id, array: host.array, depth: row.depth, slot: `${host.slot}.${field.path}` },
|
|
1373
|
+
deps,
|
|
1374
|
+
rowIdByPath,
|
|
1375
|
+
out,
|
|
1376
|
+
);
|
|
1377
|
+
continue;
|
|
1378
|
+
}
|
|
1379
|
+
const target = refName(entry.value);
|
|
1380
|
+
if (target === undefined) continue;
|
|
1381
|
+
const refId = `${id}/${lastPathSegment(entry.path)}`;
|
|
1382
|
+
out.rows.push({
|
|
1383
|
+
id: refId,
|
|
1384
|
+
kind: "reference",
|
|
1385
|
+
name: lastPathSegment(field.path),
|
|
1386
|
+
path,
|
|
1387
|
+
array: host.array,
|
|
1388
|
+
index: 0,
|
|
1389
|
+
depth: row.depth + 1,
|
|
1390
|
+
parent: id,
|
|
1391
|
+
target,
|
|
1392
|
+
});
|
|
1393
|
+
rowIdByPath.set(`${node.id}\0${path}`, refId);
|
|
1394
|
+
out.edges.push({
|
|
1395
|
+
rowId: refId,
|
|
1396
|
+
toName: target,
|
|
1397
|
+
// The slot the OWNER declares, so the branch this edge leaves is the
|
|
1398
|
+
// host's own property — what decides whether it is drawn at all.
|
|
1399
|
+
slot: `${host.slot}.${field.path}`,
|
|
1400
|
+
path,
|
|
1401
|
+
// Read off the DECLARED kind's own schema, exactly as a port's is —
|
|
1402
|
+
// `use` is a property of the slot, and the slot belongs to the kind
|
|
1403
|
+
// written here rather than to the resource hosting it.
|
|
1404
|
+
uses: readUses(schemaAt(declaredSchema, field.path)),
|
|
1405
|
+
});
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
/**
|
|
1411
|
+
* The body with each declaration's rows woven in beneath the row that declares
|
|
1412
|
+
* it, keeping the whole list pre-order.
|
|
1413
|
+
*
|
|
1414
|
+
* Pre-order is not a nicety: every consumer of `parent` — the tree's visibility
|
|
1415
|
+
* walk, the geometry, the renderer — settles a parent's verdict before it asks
|
|
1416
|
+
* about a child, and appending these at the end would silently break all three.
|
|
1417
|
+
*
|
|
1418
|
+
* Sites come from two places and neither is optional. A STEP's dispatch slot is
|
|
1419
|
+
* recorded by the call graph, which is the only walk that reaches a step's item
|
|
1420
|
+
* schema; every other slot — a route's `handler:`, a `mounts[].mount` — is an
|
|
1421
|
+
* ordinary field-map entry and is found here.
|
|
1422
|
+
*/
|
|
1423
|
+
function weaveInlineRows(
|
|
1424
|
+
node: GraphNode,
|
|
1425
|
+
manifest: ResourceManifest,
|
|
1426
|
+
callGraph: CallGraph,
|
|
1427
|
+
callGraphId: string,
|
|
1428
|
+
deps: ModuleGraphDeps,
|
|
1429
|
+
rowIdByPath: Map<string, string>,
|
|
1430
|
+
seeds: Map<string, InlineEdgeSeed[]>,
|
|
1431
|
+
): GraphRow[] {
|
|
1432
|
+
/**
|
|
1433
|
+
* Sites keyed by their concrete path, because the two walks OVERLAP: a step's
|
|
1434
|
+
* `invoke:` is both a step dispatch slot and a row-owned port slot, so
|
|
1435
|
+
* collecting them into a list emitted every declaration under a step twice —
|
|
1436
|
+
* two rows sharing one id, and two copies of every edge inside it.
|
|
1437
|
+
*/
|
|
1438
|
+
// This node's rows as they stand before any declaration is woven in — which
|
|
1439
|
+
// is what a site can be hosted BY. Scoped to the node rather than scanning
|
|
1440
|
+
// every row in the module, and taken once because the weave only ADDS rows
|
|
1441
|
+
// below the ones a site could already have named.
|
|
1442
|
+
const ownRows = rowsByOwnerOf(rowIdByPath).get(node.id) ?? [];
|
|
1443
|
+
|
|
1444
|
+
const sites = new Map<string, { rowId: string; path: string; value: Record<string, unknown>; slot: string }>();
|
|
1445
|
+
const record = (rowId: string, path: string, value: unknown, slot: string): void => {
|
|
1446
|
+
if (!rowId || sites.has(path) || !isInlineDeclaration(value)) return;
|
|
1447
|
+
sites.set(path, { rowId, path, value: value as Record<string, unknown>, slot });
|
|
1448
|
+
};
|
|
1449
|
+
|
|
1450
|
+
for (const step of callGraph.steps(callGraphId)) {
|
|
1451
|
+
for (const site of (step.refSlots ?? []).filter((slot) => slot.inline)) {
|
|
1452
|
+
const rowId = rowIdByPath.get(`${node.id}\0${step.path}`);
|
|
1453
|
+
if (!rowId) continue;
|
|
1454
|
+
record(rowId, site.path, step.step[site.key], `${step.array}[].${site.key}`);
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
for (const port of node.ports) {
|
|
1459
|
+
if (!port.slots.some((slot) => slot.inline)) continue;
|
|
1460
|
+
for (const entry of resolveFieldEntries(manifest, port.slot)) {
|
|
1461
|
+
// A site on a slot no row owns — a plain `connection:` on a resource — is
|
|
1462
|
+
// left to the port, which already draws it. Only a ROW can host a subtree.
|
|
1463
|
+
const rowId =
|
|
1464
|
+
rowIdByPath.get(`${node.id}\0${entry.path}`) ??
|
|
1465
|
+
rowAt(new Map([[node.id, ownRows]]), node.id, entry.path);
|
|
1466
|
+
if (!rowId) continue;
|
|
1467
|
+
record(rowId, entry.path, entry.value, port.slot);
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
if (sites.size === 0) return node.rows;
|
|
1472
|
+
|
|
1473
|
+
const byRow = new Map<string, typeof sites extends Map<string, infer V> ? V[] : never>();
|
|
1474
|
+
for (const site of sites.values()) {
|
|
1475
|
+
byRow.set(site.rowId, [...(byRow.get(site.rowId) ?? []), site]);
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
|
|
1479
|
+
|
|
1480
|
+
const out: GraphRow[] = [];
|
|
1481
|
+
for (const row of node.rows) {
|
|
1482
|
+
out.push(row);
|
|
1483
|
+
for (const site of byRow.get(row.id) ?? []) {
|
|
1484
|
+
const collected = { rows: [] as GraphRow[], edges: [] as InlineEdgeSeed[] };
|
|
1485
|
+
inlineRows(
|
|
1486
|
+
node,
|
|
1487
|
+
{ path: site.path, value: site.value },
|
|
1488
|
+
{ rowId: row.id, array: row.array, depth: row.depth, slot: site.slot },
|
|
1489
|
+
deps,
|
|
1490
|
+
rowIdByPath,
|
|
1491
|
+
collected,
|
|
1492
|
+
);
|
|
1493
|
+
out.push(...collected.rows);
|
|
1494
|
+
if (collected.edges.length > 0) {
|
|
1495
|
+
seeds.set(node.id, [...(seeds.get(node.id) ?? []), ...collected.edges]);
|
|
1496
|
+
}
|
|
1497
|
+
}
|
|
1498
|
+
}
|
|
1499
|
+
return out;
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
/** A reference found inside a declaration, before its target is resolved. */
|
|
1503
|
+
interface InlineEdgeSeed {
|
|
1504
|
+
rowId: string;
|
|
1505
|
+
toName: string;
|
|
1506
|
+
slot: string;
|
|
1507
|
+
path: string;
|
|
1508
|
+
uses: RefUse[];
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
/** `steps[0].invoke.connection` → `connection`; `routes[1]` → `routes`. */
|
|
1512
|
+
function lastPathSegment(path: string): string {
|
|
1513
|
+
const last = path.split(".").pop() ?? path;
|
|
1514
|
+
return last.replace(/\[\d+\]$/, "").replace(/\[\]$|\{\}$/, "");
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
/** Nesting depth of a step: how many step parents stand above it. */
|
|
1518
|
+
function depthOf(step: StepGraphNode, callGraph: CallGraph, ownerId: string): number {
|
|
1519
|
+
let depth = 0;
|
|
1520
|
+
let current: StepGraphNode | undefined = step;
|
|
1521
|
+
const byId = new Map(callGraph.steps(ownerId).map((s) => [s.id, s] as const));
|
|
1522
|
+
while (current?.parent) {
|
|
1523
|
+
depth++;
|
|
1524
|
+
current = byId.get(current.parent);
|
|
1525
|
+
}
|
|
1526
|
+
return depth;
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
/** Entry rows: one per item of an `x-telo-topology-role: entries` array. The
|
|
1530
|
+
* matcher fields are what identifies an entry to a reader, so they are also
|
|
1531
|
+
* what its identity is derived from — a route keeps its identity when a route
|
|
1532
|
+
* is inserted above it, and loses it only when its own path or method change,
|
|
1533
|
+
* which is what makes it a different route. */
|
|
1534
|
+
function entryRows(
|
|
1535
|
+
node: GraphNode,
|
|
1536
|
+
manifest: ResourceManifest,
|
|
1537
|
+
schema: Record<string, any> | undefined,
|
|
1538
|
+
rowIdByPath: Map<string, string>,
|
|
1539
|
+
): GraphRow[] {
|
|
1540
|
+
const rows: GraphRow[] = [];
|
|
1541
|
+
for (const spec of entryListsOf(schema)) {
|
|
1542
|
+
const value = (manifest as Record<string, unknown>)[spec.field];
|
|
1543
|
+
if (!Array.isArray(value)) continue;
|
|
1544
|
+
const minter = new IdMinter();
|
|
1545
|
+
value.forEach((item, index) => {
|
|
1546
|
+
const entry = (item ?? {}) as Record<string, unknown>;
|
|
1547
|
+
// What IDENTIFIES an entry to a reader is the scalars its matcher holds —
|
|
1548
|
+
// a path and a method — not the whole matcher, which for an HTTP route
|
|
1549
|
+
// also carries the request schema. Taking the schema would put a row's
|
|
1550
|
+
// identity at the mercy of an edit to a property it does not show, so a
|
|
1551
|
+
// reader loses their selection by editing something else entirely.
|
|
1552
|
+
const match = scalarLeaves(
|
|
1553
|
+
Object.fromEntries(spec.matchers.filter((m) => entry[m] !== undefined).map((m) => [m, entry[m]])),
|
|
1554
|
+
);
|
|
1555
|
+
const key = Object.keys(match).length > 0 ? contentKey(match) : contentKey(entry);
|
|
1556
|
+
const path = `${spec.field}[${index}]`;
|
|
1557
|
+
const id = minter.mint(`${node.id}#entry:${spec.field}/${key}`);
|
|
1558
|
+
rowIdByPath.set(`${node.id}\0${path}`, id);
|
|
1559
|
+
|
|
1560
|
+
const handlerField = spec.handlers.find((h) => entry[h] !== undefined);
|
|
1561
|
+
const target = handlerField ? refName(entry[handlerField]) : undefined;
|
|
1562
|
+
const catches = spec.errorBranches.some((b) => {
|
|
1563
|
+
const value = entry[b];
|
|
1564
|
+
return Array.isArray(value) ? value.length > 0 : value !== undefined;
|
|
1565
|
+
});
|
|
1566
|
+
rows.push({
|
|
1567
|
+
id,
|
|
1568
|
+
kind: "entry",
|
|
1569
|
+
path,
|
|
1570
|
+
array: spec.field,
|
|
1571
|
+
index,
|
|
1572
|
+
depth: 0,
|
|
1573
|
+
...(Object.keys(match).length > 0 ? { match } : {}),
|
|
1574
|
+
...(target !== undefined ? { target } : {}),
|
|
1575
|
+
...(catches ? { catches: true } : {}),
|
|
1576
|
+
});
|
|
1577
|
+
});
|
|
1578
|
+
}
|
|
1579
|
+
return rows;
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
/**
|
|
1583
|
+
* The scalar leaves of a value, flattened to one map, to a bounded depth.
|
|
1584
|
+
*
|
|
1585
|
+
* A matcher is whatever the kind declared it to be: a flat `path` / `method`
|
|
1586
|
+
* pair on one router, a nested `request:` object on another. Reading the scalars
|
|
1587
|
+
* out of it works for both without naming either — and stopping at scalars is
|
|
1588
|
+
* what keeps a nested JSON Schema (which is an object all the way down) out of
|
|
1589
|
+
* something a reader is meant to recognize the row by.
|
|
1590
|
+
*/
|
|
1591
|
+
function scalarLeaves(value: unknown, depth = 0): Record<string, unknown> {
|
|
1592
|
+
const out: Record<string, unknown> = {};
|
|
1593
|
+
if (depth > 2 || !value || typeof value !== "object" || Array.isArray(value)) return out;
|
|
1594
|
+
for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
|
|
1595
|
+
if (child === null) continue;
|
|
1596
|
+
if (typeof child !== "object") out[key] = child;
|
|
1597
|
+
else Object.assign(out, scalarLeaves(child, depth + 1));
|
|
1598
|
+
}
|
|
1599
|
+
return out;
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1602
|
+
/**
|
|
1603
|
+
* The ordered arrays a kind declares — its entry lists and its step bodies.
|
|
1604
|
+
*
|
|
1605
|
+
* Both are found by annotation (`x-telo-topology-role: entries`, and the shared
|
|
1606
|
+
* step-body stamp), so a third-party composer's list is offered the same
|
|
1607
|
+
* affordances as `Http.Api`'s routes with no editor change.
|
|
1608
|
+
*/
|
|
1609
|
+
function declaredRowArrays(
|
|
1610
|
+
schema: Record<string, any> | undefined,
|
|
1611
|
+
): { field: string; kind: RowKind }[] {
|
|
1612
|
+
const out: { field: string; kind: RowKind }[] = [];
|
|
1613
|
+
for (const spec of entryListsOf(schema)) out.push({ field: spec.field, kind: "entry" });
|
|
1614
|
+
for (const [key, propSchema] of propertySchemas(schema ?? {})) {
|
|
1615
|
+
if (isStepSlot(propSchema)) out.push({ field: key, kind: "step" });
|
|
1616
|
+
}
|
|
1617
|
+
return out;
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1620
|
+
/** Boot rows: the root's `targets`, which is an ordered step list — a later
|
|
1621
|
+
* target reads an earlier one's result — so it is rows, not a set. */
|
|
1622
|
+
function targetRows(
|
|
1623
|
+
node: GraphNode,
|
|
1624
|
+
manifest: ResourceManifest,
|
|
1625
|
+
rowIdByPath: Map<string, string>,
|
|
1626
|
+
): GraphRow[] {
|
|
1627
|
+
const targets = (manifest as Record<string, unknown>).targets;
|
|
1628
|
+
if (!Array.isArray(targets)) return [];
|
|
1629
|
+
const minter = new IdMinter();
|
|
1630
|
+
return targets.map((entry, index) => {
|
|
1631
|
+
const record = (entry ?? {}) as Record<string, unknown>;
|
|
1632
|
+
const target =
|
|
1633
|
+
refName(entry) ?? refName(record.ref) ?? refName(record.invoke) ?? undefined;
|
|
1634
|
+
const name = typeof record.name === "string" ? record.name : undefined;
|
|
1635
|
+
const key = name ?? target ?? `@${contentKey(entry)}`;
|
|
1636
|
+
const path = `targets[${index}]`;
|
|
1637
|
+
const id = minter.mint(`${node.id}#target:${key}`);
|
|
1638
|
+
rowIdByPath.set(`${node.id}\0${path}`, id);
|
|
1639
|
+
return {
|
|
1640
|
+
id,
|
|
1641
|
+
kind: "target" as const,
|
|
1642
|
+
path,
|
|
1643
|
+
array: "targets",
|
|
1644
|
+
index,
|
|
1645
|
+
depth: 0,
|
|
1646
|
+
...(name !== undefined ? { name } : {}),
|
|
1647
|
+
...(target !== undefined ? { target } : {}),
|
|
1648
|
+
// A boot target's shapes carry no branch titles, so there is no variant
|
|
1649
|
+
// to report — but a GATED one is the same fact a step's `when:` is, and
|
|
1650
|
+
// a target drawn without its guard says it always runs.
|
|
1651
|
+
...(guardOf(record) !== undefined ? { predicate: guardOf(record)! } : {}),
|
|
1652
|
+
};
|
|
1653
|
+
});
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
/** The `when:` guard on a boot target, as written. */
|
|
1657
|
+
function guardOf(entry: Record<string, unknown>): string | undefined {
|
|
1658
|
+
const written = entry.when;
|
|
1659
|
+
if (typeof written === "string") return written;
|
|
1660
|
+
if (written && typeof written === "object") {
|
|
1661
|
+
const source = (written as { source?: unknown }).source;
|
|
1662
|
+
if (typeof source === "string") return source;
|
|
1663
|
+
}
|
|
1664
|
+
return undefined;
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1667
|
+
/** Every declared reference slot as a port, with its occupancy read off the
|
|
1668
|
+
* manifest — so an empty slot is a port with no filled sites rather than an
|
|
1669
|
+
* absence a view has to infer. */
|
|
1670
|
+
function buildPorts(
|
|
1671
|
+
manifest: ResourceManifest,
|
|
1672
|
+
deps: ModuleGraphDeps,
|
|
1673
|
+
schema: Record<string, any> | undefined,
|
|
1674
|
+
rowArrays: ReadonlySet<string>,
|
|
1675
|
+
): GraphPort[] {
|
|
1676
|
+
const fields = deps.refFields(manifest);
|
|
1677
|
+
|
|
1678
|
+
// The `anyOf` sub-shapes of one array-of-refs are ONE slot, not three. A boot
|
|
1679
|
+
// target may be written bare, as `{ref, when}` or as an inline invoke step, so
|
|
1680
|
+
// the field map lists `targets[]`, `targets[].ref` and `targets[].invoke` —
|
|
1681
|
+
// rendering each as its own port offers three sockets for one position and
|
|
1682
|
+
// says the module has slots it does not have.
|
|
1683
|
+
const arrayRefBases = new Set(
|
|
1684
|
+
fields.filter((f) => isArrayOfRefs(f.path)).map((f) => arrayBaseOf(f.path)),
|
|
1685
|
+
);
|
|
1686
|
+
|
|
1687
|
+
const ports: GraphPort[] = [];
|
|
1688
|
+
for (const field of fields) {
|
|
1689
|
+
if ([...arrayRefBases].some((base) => field.path.startsWith(`${base}[].`))) continue;
|
|
1690
|
+
const slotSchema = schemaAt(schema, field.path);
|
|
1691
|
+
const uses = readUses(slotSchema);
|
|
1692
|
+
const slots: PortSlot[] = [];
|
|
1693
|
+
for (const { value, path } of resolveFieldEntries(manifest, field.path)) {
|
|
1694
|
+
const target = refName(value);
|
|
1695
|
+
const slot: PortSlot = { path };
|
|
1696
|
+
if (target !== undefined) slot.target = target;
|
|
1697
|
+
// A slot holding a declaration rather than a reference is FILLED, and a
|
|
1698
|
+
// view that reads only `target` would draw it as an empty socket — the
|
|
1699
|
+
// one reading that is wrong in both directions, since it invites filling
|
|
1700
|
+
// a slot that is already occupied.
|
|
1701
|
+
else if (isInlineDeclaration(value)) slot.inline = true;
|
|
1702
|
+
slots.push(slot);
|
|
1703
|
+
}
|
|
1704
|
+
|
|
1705
|
+
// **An unwritten slot still has a write site.** Resolving the manifest for
|
|
1706
|
+
// `notFoundHandler.invoke` on a server that declares no `notFoundHandler`
|
|
1707
|
+
// yields nothing, so the port had no path at all — it rendered as an empty
|
|
1708
|
+
// socket that could not be filled, which is worse than not drawing it: it
|
|
1709
|
+
// offers an affordance and then refuses. The path IS the site for a slot in
|
|
1710
|
+
// no array, so it is synthesized here rather than left to every consumer to
|
|
1711
|
+
// reconstruct.
|
|
1712
|
+
if (slots.length === 0 && !field.path.includes("[]") && !field.path.includes("{}")) {
|
|
1713
|
+
slots.push({ path: field.path });
|
|
1714
|
+
}
|
|
1715
|
+
const port: GraphPort = {
|
|
1716
|
+
slot: field.path,
|
|
1717
|
+
refs: field.refs,
|
|
1718
|
+
capabilities: field.capabilities,
|
|
1719
|
+
array: field.isArray,
|
|
1720
|
+
class: edgeClassOf(uses),
|
|
1721
|
+
slots,
|
|
1722
|
+
};
|
|
1723
|
+
const append = appendPathFor(field.path, manifest);
|
|
1724
|
+
if (append) port.addPath = append;
|
|
1725
|
+
if (rowArrays.has(containerArrayOf(field.path))) port.rowOwned = true;
|
|
1726
|
+
ports.push(port);
|
|
1727
|
+
}
|
|
1728
|
+
return ports;
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1731
|
+
/** A top-level array of direct refs (`targets[]`): the trailing `[]` is the
|
|
1732
|
+
* path's only marker. */
|
|
1733
|
+
function isArrayOfRefs(path: string): boolean {
|
|
1734
|
+
return path.endsWith("[]") && !path.slice(0, -2).match(/\[\]|\{\}/);
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1737
|
+
/** `targets[]` → `targets`. */
|
|
1738
|
+
function arrayBaseOf(path: string): string {
|
|
1739
|
+
return path.slice(0, -2);
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1742
|
+
/**
|
|
1743
|
+
* Where a NEW occupancy of this slot would be written.
|
|
1744
|
+
*
|
|
1745
|
+
* Both array shapes reach here: a direct array of refs (`targets[]` →
|
|
1746
|
+
* `targets[2]`) and a ref inside an array of objects (`mounts[].mount` →
|
|
1747
|
+
* `mounts[2].mount`). The second was missing, so an `Http.Server` with no mounts
|
|
1748
|
+
* offered no way to add one — the port drew an empty rail and the drag had
|
|
1749
|
+
* nowhere to land. Undefined for a slot in no array, and for one nested past a
|
|
1750
|
+
* single array, where the index of the outer item is not determined by the slot
|
|
1751
|
+
* alone.
|
|
1752
|
+
*/
|
|
1753
|
+
function appendPathFor(path: string, manifest: ResourceManifest): string | undefined {
|
|
1754
|
+
const marker = path.indexOf("[]");
|
|
1755
|
+
if (marker === -1) return undefined;
|
|
1756
|
+
const array = path.slice(0, marker);
|
|
1757
|
+
const suffix = path.slice(marker + 2);
|
|
1758
|
+
if (array.includes("{}") || suffix.includes("[]") || suffix.includes("{}")) return undefined;
|
|
1759
|
+
const existing = (manifest as Record<string, unknown>)[array];
|
|
1760
|
+
return `${array}[${Array.isArray(existing) ? existing.length : 0}]${suffix}`;
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1763
|
+
/** The array a slot's occupancy sits in (`routes[].handler` → `routes`,
|
|
1764
|
+
* `targets[]` → `targets`), or the path itself when it is in no array. */
|
|
1765
|
+
function containerArrayOf(path: string): string {
|
|
1766
|
+
const marker = path.indexOf("[]");
|
|
1767
|
+
return marker === -1 ? path : path.slice(0, marker);
|
|
1768
|
+
}
|
|
1769
|
+
|
|
1770
|
+
/**
|
|
1771
|
+
* Declared uses at a slot, through the annotation's ONE reader.
|
|
1772
|
+
*
|
|
1773
|
+
* It used to hand-parse `slotSchema["x-telo-ref"]`, which sees nothing when the
|
|
1774
|
+
* annotation sits in a `oneOf` branch — the sanctioned shape for a slot that
|
|
1775
|
+
* unions a value with a reference. A column's `type:` is exactly that, so its
|
|
1776
|
+
* `use: schema` read as no declared use at all and the slot was classed (and
|
|
1777
|
+
* drawn) as a control transfer. `readRefSlot` unions the branches; `possibleUses`
|
|
1778
|
+
* folds a case map's arms in, which is what a PORT wants: the port describes the
|
|
1779
|
+
* slot, and which arm holds is decided per site by the call graph.
|
|
1780
|
+
*/
|
|
1781
|
+
function readUses(slotSchema: Record<string, any> | undefined): RefUse[] {
|
|
1782
|
+
const slot = readRefSlot(slotSchema);
|
|
1783
|
+
return slot ? possibleUses(slot) : [];
|
|
1784
|
+
}
|
|
1785
|
+
|
|
1786
|
+
/** Re-key one call-graph edge onto the boxes a view draws. A step's edge is
|
|
1787
|
+
* attributed to the resource whose body declares it — a step is a row here,
|
|
1788
|
+
* not a node — with the row named so the edge can dock onto it. */
|
|
1789
|
+
function projectEdge(
|
|
1790
|
+
edge: CallGraphEdge,
|
|
1791
|
+
callGraph: CallGraph,
|
|
1792
|
+
byId: ReadonlyMap<string, GraphNode>,
|
|
1793
|
+
rowIdByPath: ReadonlyMap<string, string>,
|
|
1794
|
+
rowsByOwner: ReadonlyMap<string, readonly { path: string; id: string }[]>,
|
|
1795
|
+
byQualifiedName: ReadonlyMap<string, string>,
|
|
1796
|
+
projectedId: ReadonlyMap<string, string>,
|
|
1797
|
+
): GraphEdge | undefined {
|
|
1798
|
+
const source = callGraph.nodes.get(edge.from);
|
|
1799
|
+
const rawFrom = source?.type === "step" ? source.owner : edge.from;
|
|
1800
|
+
const fromId = projectedId.get(rawFrom) ?? rawFrom;
|
|
1801
|
+
if (!byId.has(fromId)) return undefined;
|
|
1802
|
+
|
|
1803
|
+
const projected: GraphEdge = {
|
|
1804
|
+
id: `${fromId}\0${edge.slot}\0${edge.path}`,
|
|
1805
|
+
from: fromId,
|
|
1806
|
+
toName: edge.toName,
|
|
1807
|
+
class: edgeClassOf(edge.use),
|
|
1808
|
+
use: edge.use,
|
|
1809
|
+
slot: edge.slot,
|
|
1810
|
+
path: edge.path,
|
|
1811
|
+
};
|
|
1812
|
+
const to = edge.to ? (projectedId.get(edge.to) ?? edge.to) : undefined;
|
|
1813
|
+
if (to && byId.has(to)) projected.to = to;
|
|
1814
|
+
else {
|
|
1815
|
+
const qualified = byQualifiedName.get(edge.toName);
|
|
1816
|
+
if (qualified) projected.to = qualified;
|
|
1817
|
+
}
|
|
1818
|
+
if (edge.inputs !== undefined) projected.inputs = edge.inputs;
|
|
1819
|
+
if (edge.scoped) projected.scoped = true;
|
|
1820
|
+
|
|
1821
|
+
// The row an edge leaves from: the step that declares it, or the entry whose
|
|
1822
|
+
// handler slot holds it. Longest-prefix on the concrete path, so a site
|
|
1823
|
+
// nested inside a branch docks onto the row that actually declares it.
|
|
1824
|
+
const row = rowIdByPath.get(`${fromId}\0${edge.path}`) ?? rowAt(rowsByOwner, fromId, edge.path);
|
|
1825
|
+
if (row) projected.row = row;
|
|
1826
|
+
return projected;
|
|
1827
|
+
}
|
|
1828
|
+
|
|
1829
|
+
/** The row whose path is the longest prefix of a site's path. */
|
|
1830
|
+
function rowAt(
|
|
1831
|
+
rowsByOwner: ReadonlyMap<string, readonly { path: string; id: string }[]>,
|
|
1832
|
+
ownerId: string,
|
|
1833
|
+
path: string,
|
|
1834
|
+
): string | undefined {
|
|
1835
|
+
let best: string | undefined;
|
|
1836
|
+
let bestLength = -1;
|
|
1837
|
+
for (const row of rowsByOwner.get(ownerId) ?? []) {
|
|
1838
|
+
const inside = path.startsWith(`${row.path}.`) || path.startsWith(`${row.path}[`);
|
|
1839
|
+
if (inside && row.path.length > bestLength) {
|
|
1840
|
+
best = row.id;
|
|
1841
|
+
bestLength = row.path.length;
|
|
1842
|
+
}
|
|
1843
|
+
}
|
|
1844
|
+
return best;
|
|
1845
|
+
}
|
|
1846
|
+
|
|
1847
|
+
/**
|
|
1848
|
+
* The rows each box owns, by owner — so finding the row a nested site sits in
|
|
1849
|
+
* is a scan of one box's rows rather than of every row in the module.
|
|
1850
|
+
*
|
|
1851
|
+
* The flat `<owner>\0<path>` map is the right shape for an exact hit and the
|
|
1852
|
+
* wrong one for the longest-prefix walk, which is the COMMON case: a step's ref
|
|
1853
|
+
* is nested inside the step, so the exact lookup misses and the fallback ran
|
|
1854
|
+
* over every row of every box, for every edge and every CEL chain.
|
|
1855
|
+
*/
|
|
1856
|
+
function rowsByOwnerOf(
|
|
1857
|
+
rowIdByPath: ReadonlyMap<string, string>,
|
|
1858
|
+
): Map<string, { path: string; id: string }[]> {
|
|
1859
|
+
const out = new Map<string, { path: string; id: string }[]>();
|
|
1860
|
+
for (const [key, id] of rowIdByPath) {
|
|
1861
|
+
// The LAST separator: a node id contains NULs of its own (`kind\0name`, and
|
|
1862
|
+
// `module\0kind\0name` across a boundary) while a concrete path contains
|
|
1863
|
+
// none, so splitting at the first one takes the kind for the owner and
|
|
1864
|
+
// leaves the name glued to the path — an index that matches nothing.
|
|
1865
|
+
const marker = key.lastIndexOf("\0");
|
|
1866
|
+
if (marker === -1) continue;
|
|
1867
|
+
const owner = key.slice(0, marker);
|
|
1868
|
+
out.set(owner, [...(out.get(owner) ?? []), { path: key.slice(marker + 1), id }]);
|
|
1869
|
+
}
|
|
1870
|
+
return out;
|
|
1871
|
+
}
|
|
1872
|
+
|
|
1873
|
+
/** Kinds a definition body declares — the marks of a template rather than a
|
|
1874
|
+
* controller-backed kind. */
|
|
1875
|
+
const TEMPLATE_FIELDS = ["resources", "invoke", "run", "provide"] as const;
|
|
1876
|
+
|
|
1877
|
+
/**
|
|
1878
|
+
* The kind plane: every `Telo.Definition` / `Telo.Abstract` in scope, with its
|
|
1879
|
+
* lineage and the instances that were declared of it.
|
|
1880
|
+
*
|
|
1881
|
+
* Built from the same manifest list the instance plane skips them from, so a
|
|
1882
|
+
* kind-only library — one whose whole content is declarations — has a plane to
|
|
1883
|
+
* render rather than an empty canvas.
|
|
1884
|
+
*/
|
|
1885
|
+
function buildKindPlane(
|
|
1886
|
+
resources: ResourceManifest[],
|
|
1887
|
+
nodes: readonly GraphNode[],
|
|
1888
|
+
deps: ModuleGraphDeps,
|
|
1889
|
+
options: BuildModuleGraphOptions,
|
|
1890
|
+
): GraphKind[] {
|
|
1891
|
+
const exportedKinds = new Set(
|
|
1892
|
+
(((options.root as Record<string, any> | undefined)?.exports?.kinds ?? []) as unknown[]).filter(
|
|
1893
|
+
(k): k is string => typeof k === "string",
|
|
1894
|
+
),
|
|
1895
|
+
);
|
|
1896
|
+
|
|
1897
|
+
// Keyed on the CANONICAL kind, because that is what a kind's own id is: an
|
|
1898
|
+
// instance a library declared as `kind: Self.WriteLine` belongs to
|
|
1899
|
+
// `console.WriteLine`, and keying on the written spelling gave every such kind
|
|
1900
|
+
// an empty instance list.
|
|
1901
|
+
const instancesByKind = new Map<string, string[]>();
|
|
1902
|
+
for (const node of nodes) {
|
|
1903
|
+
if (node.root) continue;
|
|
1904
|
+
const key = node.canonicalKind ?? node.kind;
|
|
1905
|
+
instancesByKind.set(key, [...(instancesByKind.get(key) ?? []), node.id]);
|
|
1906
|
+
}
|
|
1907
|
+
|
|
1908
|
+
const out: GraphKind[] = [];
|
|
1909
|
+
for (const manifest of resources) {
|
|
1910
|
+
const docKind = manifest.kind as string;
|
|
1911
|
+
if (docKind !== "Telo.Definition" && docKind !== "Telo.Abstract") continue;
|
|
1912
|
+
const name = manifest.metadata?.name as string | undefined;
|
|
1913
|
+
if (!name) continue;
|
|
1914
|
+
const module = moduleOf(manifest);
|
|
1915
|
+
const id = module ? `${module}.${name}` : name;
|
|
1916
|
+
const record = manifest as unknown as Record<string, unknown>;
|
|
1917
|
+
const extendsName = typeof record.extends === "string" ? record.extends : undefined;
|
|
1918
|
+
const parent = extendsName ? deps.definition(extendsName, module) : undefined;
|
|
1919
|
+
const parentModule = parent ? moduleOf(parent as unknown as ResourceManifest) : undefined;
|
|
1920
|
+
const parentName = parent?.metadata?.name as string | undefined;
|
|
1921
|
+
|
|
1922
|
+
const kind: GraphKind = {
|
|
1923
|
+
id,
|
|
1924
|
+
name,
|
|
1925
|
+
abstract: docKind === "Telo.Abstract",
|
|
1926
|
+
instances: instancesByKind.get(id) ?? [],
|
|
1927
|
+
template: TEMPLATE_FIELDS.some((f) => record[f] !== undefined),
|
|
1928
|
+
own: !!options.entryModule && module === options.entryModule,
|
|
1929
|
+
};
|
|
1930
|
+
if (module) kind.module = module;
|
|
1931
|
+
if (typeof record.capability === "string") kind.capability = record.capability;
|
|
1932
|
+
if (extendsName) kind.extendsName = extendsName;
|
|
1933
|
+
if (parent && parentName) kind.extendsId = parentModule ? `${parentModule}.${parentName}` : parentName;
|
|
1934
|
+
// Only the entry module's gate is in hand — an imported library's
|
|
1935
|
+
// `exports.kinds` is stamped on the import, not on the definition, so a
|
|
1936
|
+
// claim about it here would be a guess.
|
|
1937
|
+
if (kind.own) kind.exported = exportedKinds.has(name);
|
|
1938
|
+
out.push(kind);
|
|
1939
|
+
}
|
|
1940
|
+
return out;
|
|
1941
|
+
}
|
|
1942
|
+
|
|
1943
|
+
/** The CEL environment used to PARSE a chain out of an expression. One per
|
|
1944
|
+
* process: building it is the expensive half, and nothing here evaluates. */
|
|
1945
|
+
let parseEnv: ReturnType<typeof buildCelEnvironment> | undefined;
|
|
1946
|
+
|
|
1947
|
+
/** Access chains an expression reads, or none when it does not parse — a syntax
|
|
1948
|
+
* error is the engine pass's to report, not this one's. */
|
|
1949
|
+
function accessChains(source: string): string[][] {
|
|
1950
|
+
try {
|
|
1951
|
+
parseEnv ??= buildCelEnvironment();
|
|
1952
|
+
return extractAccessChains(parseEnv.parse(source).ast);
|
|
1953
|
+
} catch {
|
|
1954
|
+
return [];
|
|
1955
|
+
}
|
|
1956
|
+
}
|
|
1957
|
+
|
|
1958
|
+
/**
|
|
1959
|
+
* Every `resources.<name>…` read in one resource's CEL, as an edge.
|
|
1960
|
+
*
|
|
1961
|
+
* This is the dependency a manifest states without a slot: a config provider
|
|
1962
|
+
* read by five resources has five edges the reference graph cannot show, and an
|
|
1963
|
+
* observed-state read is the same shape one level in. Deduplicated per
|
|
1964
|
+
* (target, chain), since the same read at two sites is one fact about the pair.
|
|
1965
|
+
*/
|
|
1966
|
+
function dataEdges(
|
|
1967
|
+
node: GraphNode,
|
|
1968
|
+
manifest: ResourceManifest,
|
|
1969
|
+
fromModule: string | undefined,
|
|
1970
|
+
resolveName: (name: string, fromModule: string | undefined) => string | undefined,
|
|
1971
|
+
rowIdByPath: ReadonlyMap<string, string>,
|
|
1972
|
+
rowsByOwner: ReadonlyMap<string, readonly { path: string; id: string }[]>,
|
|
1973
|
+
): GraphEdge[] {
|
|
1974
|
+
const out: GraphEdge[] = [];
|
|
1975
|
+
const seen = new Set<string>();
|
|
1976
|
+
walkCelExpressions(manifest, "", (source, path) => {
|
|
1977
|
+
for (const chain of accessChains(source)) {
|
|
1978
|
+
if (chain[0] !== "resources" || chain.length < 2) continue;
|
|
1979
|
+
const targetName = chain[1]!;
|
|
1980
|
+
// `resources.<name>` is a bare name written in THIS module's scope, so it
|
|
1981
|
+
// resolves the way every other bare name does.
|
|
1982
|
+
const to = resolveName(targetName, fromModule);
|
|
1983
|
+
if (!to || to === node.id) continue;
|
|
1984
|
+
const read = chain.join(".");
|
|
1985
|
+
const key = `${to}\0${read}`;
|
|
1986
|
+
if (seen.has(key)) continue;
|
|
1987
|
+
seen.add(key);
|
|
1988
|
+
const edge: GraphEdge = {
|
|
1989
|
+
id: `${node.id}\0data\0${path}\0${read}`,
|
|
1990
|
+
from: node.id,
|
|
1991
|
+
to,
|
|
1992
|
+
toName: targetName,
|
|
1993
|
+
class: "data",
|
|
1994
|
+
use: [],
|
|
1995
|
+
slot: "cel",
|
|
1996
|
+
path,
|
|
1997
|
+
read,
|
|
1998
|
+
};
|
|
1999
|
+
const row = rowIdByPath.get(`${node.id}\0${path}`) ?? rowAt(rowsByOwner, node.id, path);
|
|
2000
|
+
if (row) edge.row = row;
|
|
2001
|
+
out.push(edge);
|
|
2002
|
+
}
|
|
2003
|
+
});
|
|
2004
|
+
return out;
|
|
2005
|
+
}
|