@intentius/chant-lexicon-terraform 0.64.0 → 0.66.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.
@@ -0,0 +1,622 @@
1
+ /**
2
+ * A behaviour engine's request from a terraform estate, declared or live (#2360).
3
+ *
4
+ * The epic (#2355) wants two predictions shown as a delta: what the file
5
+ * declares, and what the account holds. For a choudoufu root the second is
6
+ * readable — `live-ls -json` lists every resource the account holds under the
7
+ * estate's marker, `live-plan -json` says which declared instance each one is
8
+ * bound to and which declarations nothing answers for — and this module turns
9
+ * either reading into the request `PredictBehaviourOptions` describes:
10
+ * `entityNames`, `entities`, `edges`, `edgeCoverage`, `traffic`.
11
+ *
12
+ * One producer for both sides, on purpose. A delta is only a statement about
13
+ * the estate if both sides were assembled the same way, and the third review
14
+ * comment on #2360 is about exactly the asymmetry this avoids: the declared
15
+ * path had no containment producer, so a "one zone lost" verdict differed
16
+ * between the sides for a reason that was not drift. Here the same reference
17
+ * catalog (`./reference-catalog.ts`) runs over the same reconstruction
18
+ * (`reconstructEdges`, `packages/core/src/graph-refs.ts`) on both sides, and
19
+ * `edgeCoverage.containmentEdges` is populated the same way on each.
20
+ *
21
+ * ## What the live side is, and is not
22
+ *
23
+ * The two documents carry identities, types, addresses and markers. Neither
24
+ * carries a resource's attributes: a subnet's `vpc_id` as the account holds it
25
+ * is in no section of either. So a live node's arguments are the declaration's
26
+ * arguments — the body choudoufu applied — and its **references** are the
27
+ * declaration's references resolved against what is bound: a reference whose
28
+ * target the plan bound resolves to that target's live identity, and one whose
29
+ * target is absent from the account stays unresolved and is reported as
30
+ * `dangling`, naming the node, the argument and the address it points at. A
31
+ * resource the listing holds that nothing declares has no body at all; it is a
32
+ * node with an identity and a type, and its kind is counted into
33
+ * `unresolvedKinds` unless the catalog knows that kind references nothing.
34
+ *
35
+ * The drift this side sees is therefore **membership** drift: a resource added
36
+ * out of band under the estate's marker, a declared resource never applied, a
37
+ * resource at a declared identity that another estate holds. An attribute
38
+ * changed live — an instance resized by hand — is not in either document and
39
+ * is not claimed here; the size an engine is sent is the declared one on both
40
+ * sides. `describe-resources.ts`'s module doc has the same boundary for the
41
+ * observation read, for the same reason.
42
+ *
43
+ * One consequence of that worth stating outright, because it is the one a
44
+ * reader will hit first: a block with `count` or `for_each` is **one** node on
45
+ * both sides, whatever the account holds. The request's unit is the entity the
46
+ * caller asked about, and the caller asked about `aws_eip.pool`, not about two
47
+ * slots — inventing `aws_eip.pool[0]` and `[1]` as entities would put names in
48
+ * the report that are in no `entityNames` list and in no build. Every instance
49
+ * address the plan named rides on `props.live.instances` instead, so an engine
50
+ * that prices per instance has the count and one that does not is not silently
51
+ * handed a multiplier. An estate whose drift is a changed `count` is therefore
52
+ * a delta this path does not yet show.
53
+ *
54
+ * ## `edgeCoverage`, honestly
55
+ *
56
+ * `partial` whenever `dangling` or `unresolvedKinds` is non-empty, `complete`
57
+ * only when both are empty, and `dangling` carries `reconstructEdges`'s
58
+ * `DanglingRef` records through unflattened — `from` is the field that says
59
+ * which node's argument leaves the estate. `containmentEdges` comes from the
60
+ * reconstruction's `containmentEdges`, the traversable shape, and never from
61
+ * the `containment` pairs beside it. All three of those are #2360's review
62
+ * comments, applied.
63
+ */
64
+
65
+ import type {
66
+ BehaviourEdgeCoverage,
67
+ PredictBehaviourOptions,
68
+ UnpredictedEntity,
69
+ } from "@intentius/chant/behaviour";
70
+ import type { EntityReference, IRNode } from "@intentius/chant/graph-ir";
71
+ import { reconstructEdges, type ReconstructedEdges } from "@intentius/chant/graph-refs";
72
+ import {
73
+ classifyLiveInstance,
74
+ declaredOf,
75
+ entityKeyFor,
76
+ instancesOf,
77
+ type LiveLsItem,
78
+ type LiveLsListing,
79
+ type LivePlanIndex,
80
+ type TerraformDeclared,
81
+ } from "../describe-resources";
82
+ import { RESOURCE_TYPE } from "../hcl/parse";
83
+ import { isUnresolvedKind, TERRAFORM_REFERENCE_CATALOG } from "./reference-catalog";
84
+
85
+ /**
86
+ * One entity as this builder needs it: the contract's `{ entityType, props }`
87
+ * plus the `references` the terraform build stamps beside `props`
88
+ * (`../hcl/edges.ts`, chant #2265). A caller holding the build's entities has
89
+ * them; a caller that copied only the two contract fields does not, and a
90
+ * block whose body holds a `${…}` reference with none resolved is counted as
91
+ * an unresolved kind rather than read as referencing nothing.
92
+ */
93
+ export interface TerraformBehaviourEntity {
94
+ entityType: string;
95
+ props: Record<string, unknown>;
96
+ references?: readonly EntityReference[];
97
+ }
98
+
99
+ /** One live root's two documents, already parsed. */
100
+ export interface TerraformLiveRead {
101
+ root: string;
102
+ listing: LiveLsListing;
103
+ plan: LivePlanIndex;
104
+ }
105
+
106
+ /** How a live node stands in the account. */
107
+ export type LiveResourceStatus = "bound" | "adoptable" | "foreign" | "orphan";
108
+
109
+ /**
110
+ * What a live node's `props.live` carries: the facts the two documents state
111
+ * about it, kept apart from the declaration under `props` so a reader can
112
+ * tell which side said what.
113
+ */
114
+ export interface LiveResourceFacts {
115
+ status: LiveResourceStatus;
116
+ ownership: "owned" | "unknown" | "foreign";
117
+ /** The import identity the plan bound (`vpc-…`, a bucket name), on a single-instance block. */
118
+ identity?: string;
119
+ /** The ARN or other stable identity the listing carries. */
120
+ arn?: string;
121
+ /** The region the ARN names, where it names one. */
122
+ region?: string;
123
+ /** Every instance address the plan named for the block, where there is more than the block itself. */
124
+ instances?: string[];
125
+ /** The estate holding a foreign resource, when its marker names one. */
126
+ heldBy?: string;
127
+ /** The marker tags the listing read off an undeclared resource. */
128
+ tags?: Record<string, string>;
129
+ }
130
+
131
+ export interface TerraformBehaviourRequestOptions
132
+ extends Pick<PredictBehaviourOptions, "environment" | "buildOutput" | "traffic" | "region" | "stack" | "owned"> {
133
+ entityNames: readonly string[];
134
+ entities: ReadonlyMap<string, TerraformBehaviourEntity>;
135
+ /**
136
+ * Live reads, one per live root. A root with a read here is built from the
137
+ * account; every other root is built from its declaration. Omit for the
138
+ * declared side.
139
+ */
140
+ live?: readonly TerraformLiveRead[];
141
+ }
142
+
143
+ export interface TerraformBehaviourRequest {
144
+ /** The request, ready for `screenBehaviourRequest` and an engine-fronting `predictBehaviour`. */
145
+ request: PredictBehaviourOptions;
146
+ /** Which side each name in the request came from. */
147
+ sources: Record<string, "live" | "declared">;
148
+ /**
149
+ * Declared resources of a live root the account does not hold: the plan
150
+ * reported them `ABSENT`, or named no instance for them. Not in the request,
151
+ * because a prediction of the account cannot price what is not in it, and
152
+ * not `unpredicted`, because the contract has no reason for "not there".
153
+ * The declared side names them; the delta shows them as declared-only.
154
+ */
155
+ absent: string[];
156
+ /**
157
+ * Declared names of a live root with no live verdict to build from — an
158
+ * instance the plan could not read, or one withheld by `owned` — each with
159
+ * the reason. Not in the request; a caller merges them into the report.
160
+ */
161
+ unpredicted: Record<string, UnpredictedEntity>;
162
+ }
163
+
164
+ /** The node `reconstructEdges` sees: the provider type as its kind, the substituted body as its attrs. */
165
+ type GraphNode = IRNode;
166
+
167
+ /** Code-unit order, so the request's bytes do not depend on the machine's locale. */
168
+ const byCodeUnit = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0);
169
+
170
+ /** The block a document instance address belongs to: `aws_eip.pool[0]` is `aws_eip.pool`. */
171
+ export function blockAddressOf(instance: string): string {
172
+ return instance.replace(/\[[^\]]*\]$/, "");
173
+ }
174
+
175
+ /** The region an ARN names, or undefined for a global one (`arn:aws:s3:::bucket`). */
176
+ export function regionOfArn(arn: string): string | undefined {
177
+ const parts = arn.split(":");
178
+ return parts[0] === "arn" && parts.length >= 6 && parts[3] ? parts[3] : undefined;
179
+ }
180
+
181
+ function asString(value: unknown): string | undefined {
182
+ return typeof value === "string" && value.length > 0 ? value : undefined;
183
+ }
184
+
185
+ function bodyOf(props: Record<string, unknown>): Record<string, unknown> {
186
+ const body = props.body;
187
+ return typeof body === "object" && body !== null && !Array.isArray(body) ? (body as Record<string, unknown>) : {};
188
+ }
189
+
190
+ function referencesOf(entity: TerraformBehaviourEntity | undefined): readonly EntityReference[] {
191
+ return Array.isArray(entity?.references) ? entity.references : [];
192
+ }
193
+
194
+ /** True when a body holds any `${…}` string at all, at any depth. */
195
+ function holdsReference(value: unknown, depth = 0): boolean {
196
+ if (depth > 12) return false;
197
+ if (typeof value === "string") return value.includes("${");
198
+ if (Array.isArray(value)) return value.some((v) => holdsReference(v, depth + 1));
199
+ if (typeof value === "object" && value !== null) {
200
+ return Object.values(value as Record<string, unknown>).some((v) => holdsReference(v, depth + 1));
201
+ }
202
+ return false;
203
+ }
204
+
205
+ /** `${addr}` followed by an attribute, an index, or the closing brace — the address itself, not a longer one. */
206
+ function mentions(text: string, address: string): boolean {
207
+ const escaped = address.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
208
+ return new RegExp(`\\$\\{${escaped}(?:[.\\[}\\s)\\],]|$)`).test(text);
209
+ }
210
+
211
+ /** One `${…}` and nothing else: the value the account holds *is* the target's identity. */
212
+ const WHOLE_INTERPOLATION = /^\$\{[^{}]*\}$/;
213
+
214
+ /**
215
+ * The body with every resolvable reference replaced by the identifier it
216
+ * stands for, so `reconstructEdges` can match it against the node that owns
217
+ * that identifier.
218
+ *
219
+ * A leaf string is replaced only when the reference **is** the whole value:
220
+ * the string is one `${…}` and nothing else, exactly one of the block's
221
+ * references under the same top-level argument is the one it names, and that
222
+ * reference resolves. Then the value in the account really is the target's
223
+ * identity, and matching it against the node holding that identity is a
224
+ * statement about the estate.
225
+ *
226
+ * Everything else is left alone, and the two cases it leaves alone are
227
+ * different. `"${a.id}-${b.id}"` and `"${a.id}-suffix"` are composed values —
228
+ * a name derived from an identity is not that identity, and substituting the
229
+ * bare identity into one would hand the resolver a value the account does not
230
+ * hold and get back an edge nothing points along. A string naming a reference
231
+ * that does not resolve keeps its `${…}`, which is what makes a reference to a
232
+ * target the account is missing come back from the resolver as `dangling` with
233
+ * the address in `value` rather than vanishing.
234
+ */
235
+ export function substituteReferences(
236
+ body: Record<string, unknown>,
237
+ references: readonly EntityReference[],
238
+ addressOf: (to: string) => string | undefined,
239
+ resolve: (to: string) => string | undefined,
240
+ ): Record<string, unknown> {
241
+ const leaf = (value: string, via: string): string => {
242
+ if (!WHOLE_INTERPOLATION.test(value)) return value;
243
+ const matched = references.filter((r) => {
244
+ if ((r.viaAttr ?? "") !== via) return false;
245
+ const address = addressOf(r.to);
246
+ return address !== undefined && mentions(value, address);
247
+ });
248
+ if (matched.length !== 1) return value;
249
+ return resolve(matched[0].to) ?? value;
250
+ };
251
+ const walk = (value: unknown, via: string, depth: number): unknown => {
252
+ if (depth > 12) return value;
253
+ if (typeof value === "string") return leaf(value, via);
254
+ if (Array.isArray(value)) return value.map((v) => walk(v, via, depth + 1));
255
+ if (typeof value === "object" && value !== null) {
256
+ const out: Record<string, unknown> = {};
257
+ for (const [key, inner] of Object.entries(value as Record<string, unknown>)) {
258
+ out[key] = walk(inner, depth === 0 ? key : via, depth + 1);
259
+ }
260
+ return out;
261
+ }
262
+ return value;
263
+ };
264
+ return walk(body, "", 0) as Record<string, unknown>;
265
+ }
266
+
267
+ /**
268
+ * The provider type a resource block declares — `aws_subnet` — or undefined
269
+ * for any other block.
270
+ *
271
+ * Read off the address, because the block carries no `type` field:
272
+ * `../hcl/parse.ts` composes a resource's address as `${type}.${name}` and
273
+ * keeps it unqualified even inside a descended child module, where the calling
274
+ * chain lives on `props.callers` and the `module.<name>` segments live in the
275
+ * entity's key. An address with no dot is a live row's identity standing in
276
+ * for an address it had none of, and is not a type.
277
+ *
278
+ * This is the kind a node is given for `reconstructEdges`, and it is the same
279
+ * derivation augur's `terraformResourceType` runs to look a coverage row up,
280
+ * so the graph and the coverage table agree on what a block is. A live row
281
+ * states its own type instead, on `props.resourceType`, the name
282
+ * `observeAmbient` already uses for it.
283
+ */
284
+ function providerTypeOf(entity: TerraformBehaviourEntity): string | undefined {
285
+ if (entity.entityType !== RESOURCE_TYPE) return undefined;
286
+ const stated = asString(entity.props.resourceType);
287
+ if (stated !== undefined) return stated;
288
+ const address = asString(entity.props.address);
289
+ if (address === undefined) return undefined;
290
+ const dot = address.indexOf(".");
291
+ return dot > 0 ? address.slice(0, dot) : undefined;
292
+ }
293
+
294
+ /** One root's worth of the request, on either side. */
295
+ interface RootPart {
296
+ nodes: GraphNode[];
297
+ entities: Map<string, TerraformBehaviourEntity>;
298
+ sources: Record<string, "live" | "declared">;
299
+ absent: string[];
300
+ unpredicted: Record<string, UnpredictedEntity>;
301
+ /** Kinds nothing was looked for on: no rule, or no body to look in. */
302
+ unresolvedKinds: Set<string>;
303
+ }
304
+
305
+ function emptyPart(): RootPart {
306
+ return { nodes: [], entities: new Map(), sources: {}, absent: [], unpredicted: {}, unresolvedKinds: new Set() };
307
+ }
308
+
309
+ /** The verdict `describe-resources.ts` gives a block, aggregated over its instances in the same precedence. */
310
+ interface LiveVerdict {
311
+ kind: "present" | "absent" | "unobserved";
312
+ status?: LiveResourceStatus;
313
+ ownership?: LiveResourceFacts["ownership"];
314
+ identity?: string;
315
+ heldBy?: string;
316
+ instances: string[];
317
+ reason?: UnpredictedEntity["reason"];
318
+ detail?: string;
319
+ }
320
+
321
+ function liveVerdictOf(address: string, plan: LivePlanIndex): LiveVerdict {
322
+ const instances = instancesOf(address, plan);
323
+ if (instances.length === 0) return { kind: "absent", instances };
324
+ const verdicts = instances.map((a) => classifyLiveInstance(a, plan));
325
+
326
+ const unobserved = verdicts.find((v) => v.kind === "unobserved");
327
+ if (unobserved && unobserved.kind === "unobserved") {
328
+ return {
329
+ kind: "unobserved",
330
+ instances,
331
+ // `no-credentials` is an observation reason and not a prediction one;
332
+ // the credential wording stays in the detail.
333
+ reason: unobserved.reason === "no-credentials" ? "read-failed" : unobserved.reason,
334
+ detail: unobserved.detail,
335
+ };
336
+ }
337
+ const foreign = verdicts.find((v) => v.kind === "foreign");
338
+ if (foreign && foreign.kind === "foreign") {
339
+ return {
340
+ kind: "present",
341
+ status: "foreign",
342
+ ownership: "foreign",
343
+ instances,
344
+ ...(foreign.row.identity ? { identity: foreign.row.identity } : {}),
345
+ ...(foreign.row.heldBy ? { heldBy: foreign.row.heldBy } : {}),
346
+ };
347
+ }
348
+ const adoptable = verdicts.find((v) => v.kind === "adoptable");
349
+ if (adoptable && adoptable.kind === "adoptable") {
350
+ return {
351
+ kind: "present",
352
+ status: "adoptable",
353
+ ownership: "unknown",
354
+ instances,
355
+ ...(adoptable.row.identity ? { identity: adoptable.row.identity } : {}),
356
+ };
357
+ }
358
+ const owned = verdicts.filter((v) => v.kind === "owned");
359
+ if (owned.length === 0) return { kind: "absent", instances };
360
+ const single = owned.length === 1 && instances.length === 1 ? owned[0] : undefined;
361
+ return {
362
+ kind: "present",
363
+ status: "bound",
364
+ ownership: "owned",
365
+ instances,
366
+ ...(single && single.kind === "owned" && single.row.identity ? { identity: single.row.identity } : {}),
367
+ };
368
+ }
369
+
370
+ /**
371
+ * A live root: the declared resources the account holds, at their live
372
+ * identities, plus everything the listing holds that nothing declares.
373
+ */
374
+ function liveRootPart(
375
+ root: string,
376
+ declared: TerraformDeclared[],
377
+ entities: ReadonlyMap<string, TerraformBehaviourEntity>,
378
+ read: TerraformLiveRead,
379
+ owned: boolean | undefined,
380
+ ): RootPart {
381
+ const part = emptyPart();
382
+ const { listing, plan } = read;
383
+ const estate = plan.estate || listing.estate;
384
+
385
+ // The listing, by block address: a `count` block has one item per instance.
386
+ const listed = new Map<string, LiveLsItem[]>();
387
+ for (const item of listing.items) {
388
+ if (!item.address) continue;
389
+ const block = blockAddressOf(item.address);
390
+ (listed.get(block) ?? listed.set(block, []).get(block)!).push(item);
391
+ }
392
+
393
+ // Live identity per entity key, for reference substitution below. Filled as
394
+ // declared blocks are placed; an orphan can be the target of nothing.
395
+ const identity = new Map<string, string | undefined>();
396
+ const placed: Array<{ name: string; entity: TerraformBehaviourEntity; declared: TerraformDeclared; facts: LiveResourceFacts }> = [];
397
+
398
+ for (const d of declared) {
399
+ const entity = entities.get(d.name);
400
+ if (!entity) continue;
401
+ if (entity.entityType !== RESOURCE_TYPE) {
402
+ // A variable, an output, a data block: not a thing the account holds,
403
+ // so the account has nothing to say about it. It rides through as
404
+ // declared, and augur declines it by name on both sides alike.
405
+ part.sources[d.name] = "live";
406
+ part.entities.set(d.name, entity);
407
+ continue;
408
+ }
409
+ const verdict = liveVerdictOf(d.address, plan);
410
+ if (verdict.kind === "absent") {
411
+ part.absent.push(d.name);
412
+ continue;
413
+ }
414
+ if (verdict.kind === "unobserved") {
415
+ part.unpredicted[d.name] = { type: RESOURCE_TYPE, reason: verdict.reason!, detail: verdict.detail! };
416
+ continue;
417
+ }
418
+ if (owned && verdict.ownership !== "owned") {
419
+ part.unpredicted[d.name] = {
420
+ type: RESOURCE_TYPE,
421
+ reason: "filtered",
422
+ detail: `this address read \`${verdict.ownership}\` on the root's live markers and --owned was requested`,
423
+ };
424
+ continue;
425
+ }
426
+ const items = listed.get(d.address) ?? [];
427
+ const single = items.length === 1 && verdict.instances.length === 1 ? items[0] : undefined;
428
+ const facts: LiveResourceFacts = {
429
+ status: verdict.status!,
430
+ ownership: verdict.ownership!,
431
+ ...(verdict.identity ? { identity: verdict.identity } : {}),
432
+ ...(single ? { arn: single.id } : {}),
433
+ ...(single && regionOfArn(single.id) ? { region: regionOfArn(single.id) } : {}),
434
+ ...(verdict.instances.length > 1 || verdict.instances[0] !== d.address ? { instances: verdict.instances } : {}),
435
+ ...(verdict.heldBy ? { heldBy: verdict.heldBy } : {}),
436
+ };
437
+ identity.set(d.name, verdict.identity);
438
+ part.sources[d.name] = "live";
439
+ placed.push({ name: d.name, entity, declared: d, facts });
440
+ }
441
+
442
+ const addressOf = (to: string): string | undefined => asString(entities.get(to)?.props.address);
443
+ // A bound target resolves to its live identity; a target that is live but
444
+ // carries none (a marker read the tagging index had not settled, choudoufu
445
+ // #1014) resolves to its node id; a target not in the account resolves to
446
+ // nothing, and the reference stays in the body for the resolver to report.
447
+ const resolve = (to: string): string | undefined =>
448
+ identity.has(to) ? (identity.get(to) ?? to) : undefined;
449
+
450
+ for (const { name, entity, declared: d, facts } of placed) {
451
+ const kind = providerTypeOf(entity) ?? "";
452
+ const references = referencesOf(entity);
453
+ const body = bodyOf(entity.props);
454
+ if (references.length === 0 && holdsReference(body)) part.unresolvedKinds.add(kind);
455
+ else if (isUnresolvedKind(kind)) part.unresolvedKinds.add(kind);
456
+ part.nodes.push({
457
+ id: name,
458
+ kind,
459
+ lexicon: "terraform",
460
+ attrs: substituteReferences(body, references, addressOf, resolve),
461
+ ...(facts.identity ? { physicalId: facts.identity } : {}),
462
+ });
463
+ part.entities.set(name, {
464
+ entityType: entity.entityType,
465
+ props: { ...entity.props, address: d.address, live: facts },
466
+ ...(entity.references ? { references: entity.references } : {}),
467
+ });
468
+ }
469
+
470
+ // What the account holds that nothing declares: the owned orphans. No body,
471
+ // so nothing to look for references in.
472
+ for (const item of listing.items) {
473
+ if (item.declared) continue;
474
+ const address = item.address ? blockAddressOf(item.address) : item.id;
475
+ const name = entityKeyFor(root, address);
476
+ if (part.entities.has(name) || entities.has(name)) continue;
477
+ const facts: LiveResourceFacts = {
478
+ status: "orphan",
479
+ ownership: "owned",
480
+ arn: item.id,
481
+ ...(regionOfArn(item.id) ? { region: regionOfArn(item.id) } : {}),
482
+ ...(item.address && item.address !== address ? { instances: [item.address] } : {}),
483
+ tags: item.tags,
484
+ };
485
+ if (isUnresolvedKind(item.type)) part.unresolvedKinds.add(item.type);
486
+ part.sources[name] = "live";
487
+ part.nodes.push({ id: name, kind: item.type, lexicon: "terraform", attrs: {}, physicalId: item.id });
488
+ part.entities.set(name, {
489
+ entityType: RESOURCE_TYPE,
490
+ props: {
491
+ address,
492
+ root,
493
+ estate,
494
+ // `resourceType`, the name `observeAmbient` gives the same fact
495
+ // (`../describe-resources.ts`). An undeclared resource has no
496
+ // terraform address for a type to be read out of, so the listing's
497
+ // own statement of it is the only one there is.
498
+ resourceType: item.type,
499
+ mode: "live",
500
+ live: facts,
501
+ },
502
+ });
503
+ }
504
+
505
+ return part;
506
+ }
507
+
508
+ /** A declared root: every entity as declared, references resolved to the entity keys they name. */
509
+ function declaredRootPart(
510
+ declared: TerraformDeclared[],
511
+ entities: ReadonlyMap<string, TerraformBehaviourEntity>,
512
+ ): RootPart {
513
+ const part = emptyPart();
514
+ const addressOf = (to: string): string | undefined => asString(entities.get(to)?.props.address);
515
+ const resolve = (to: string): string | undefined => (entities.has(to) ? to : undefined);
516
+ for (const d of declared) {
517
+ const entity = entities.get(d.name);
518
+ if (!entity) continue;
519
+ part.sources[d.name] = "declared";
520
+ part.entities.set(d.name, entity);
521
+ const kind = providerTypeOf(entity);
522
+ if (kind === undefined) continue;
523
+ const references = referencesOf(entity);
524
+ const body = bodyOf(entity.props);
525
+ if (references.length === 0 && holdsReference(body)) part.unresolvedKinds.add(kind);
526
+ else if (isUnresolvedKind(kind)) part.unresolvedKinds.add(kind);
527
+ part.nodes.push({
528
+ id: d.name,
529
+ kind,
530
+ lexicon: "terraform",
531
+ attrs: substituteReferences(body, references, addressOf, resolve),
532
+ });
533
+ }
534
+ return part;
535
+ }
536
+
537
+ /**
538
+ * The coverage claim, from the reconstruction and the kinds nothing was
539
+ * looked for on. `partial` names what is missing; `complete` is claimed only
540
+ * when nothing is.
541
+ */
542
+ export function edgeCoverageOf(
543
+ reconstructed: Pick<ReconstructedEdges, "dangling" | "containmentEdges">,
544
+ unresolvedKinds: readonly string[],
545
+ ): BehaviourEdgeCoverage {
546
+ const dangling = reconstructed.dangling;
547
+ const kinds = [...unresolvedKinds].sort(byCodeUnit);
548
+ return {
549
+ verdict: dangling.length > 0 || kinds.length > 0 ? "partial" : "complete",
550
+ ...(dangling.length > 0 ? { dangling } : {}),
551
+ ...(kinds.length > 0 ? { unresolvedKinds: kinds } : {}),
552
+ containmentEdges: reconstructed.containmentEdges,
553
+ };
554
+ }
555
+
556
+ /**
557
+ * Build the request. Pure: the documents are already parsed and nothing here
558
+ * reads a clock, the environment or the filesystem, so the same declaration
559
+ * and the same two documents give the same request forever.
560
+ */
561
+ export function terraformBehaviourRequest(options: TerraformBehaviourRequestOptions): TerraformBehaviourRequest {
562
+ const live = new Map((options.live ?? []).map((read) => [read.root, read]));
563
+ const byRoot = new Map<string, TerraformDeclared[]>();
564
+ const rootless: string[] = [];
565
+ for (const name of options.entityNames) {
566
+ const entity = options.entities.get(name);
567
+ const d = declaredOf(name, entity);
568
+ if (!d.root) {
569
+ rootless.push(name);
570
+ continue;
571
+ }
572
+ (byRoot.get(d.root) ?? byRoot.set(d.root, []).get(d.root)!).push(d);
573
+ }
574
+
575
+ const parts: RootPart[] = [];
576
+ for (const [root, declared] of byRoot) {
577
+ const read = live.get(root);
578
+ parts.push(read ? liveRootPart(root, declared, options.entities, read, options.owned) : declaredRootPart(declared, options.entities));
579
+ }
580
+
581
+ const entities = new Map<string, TerraformBehaviourEntity>();
582
+ const sources: Record<string, "live" | "declared"> = {};
583
+ const absent: string[] = [];
584
+ const unpredicted: Record<string, UnpredictedEntity> = {};
585
+ const unresolved = new Set<string>();
586
+ const nodes: GraphNode[] = [];
587
+ for (const part of parts) {
588
+ for (const [name, entity] of part.entities) entities.set(name, entity);
589
+ Object.assign(sources, part.sources);
590
+ absent.push(...part.absent);
591
+ Object.assign(unpredicted, part.unpredicted);
592
+ for (const kind of part.unresolvedKinds) unresolved.add(kind);
593
+ nodes.push(...part.nodes);
594
+ }
595
+ // A name carrying no root came from somewhere other than `buildRoots()`. It
596
+ // is passed through as it came, so the engine-fronting lexicon reports it
597
+ // rather than this builder dropping it.
598
+ for (const name of rootless) {
599
+ const entity = options.entities.get(name);
600
+ if (entity) entities.set(name, entity);
601
+ sources[name] = "declared";
602
+ }
603
+
604
+ nodes.sort((a, b) => byCodeUnit(a.id, b.id));
605
+ const reconstructed = reconstructEdges(nodes, TERRAFORM_REFERENCE_CATALOG);
606
+
607
+ const entityNames = [...new Set([...entities.keys(), ...rootless])].sort(byCodeUnit);
608
+ const request: PredictBehaviourOptions = {
609
+ environment: options.environment,
610
+ buildOutput: options.buildOutput,
611
+ entityNames,
612
+ entities: entities as Map<string, { entityType: string; props: Record<string, unknown> }>,
613
+ ...(options.stack !== undefined ? { stack: options.stack } : {}),
614
+ ...(options.region !== undefined ? { region: options.region } : {}),
615
+ ...(options.owned !== undefined ? { owned: options.owned } : {}),
616
+ traffic: options.traffic,
617
+ edges: reconstructed.edges,
618
+ edgeCoverage: edgeCoverageOf(reconstructed, [...unresolved]),
619
+ };
620
+
621
+ return { request, sources, absent: absent.sort(byCodeUnit), unpredicted };
622
+ }
@@ -486,8 +486,12 @@ function omissionVerdict(row: LivePlanOmissionRow): UnobservedReason | "absent"
486
486
  return mapped;
487
487
  }
488
488
 
489
- /** Every instance address in the document belonging to the declared block `address`. */
490
- function instancesOf(address: string, index: LivePlanIndex): string[] {
489
+ /**
490
+ * Every instance address in the document belonging to the declared block
491
+ * `address`. Exported for the behaviour request builder (`./behaviour/`,
492
+ * #2360), which places the same blocks against the same document.
493
+ */
494
+ export function instancesOf(address: string, index: LivePlanIndex): string[] {
491
495
  return index.addresses.filter((a) => a === address || a.startsWith(`${address}[`));
492
496
  }
493
497
 
@@ -499,7 +503,7 @@ function liveModuleMembers(address: string, index: LivePlanIndex): string[] {
499
503
  }
500
504
 
501
505
  /** One instance's verdict, before a block aggregates its instances. */
502
- type InstanceVerdict =
506
+ export type InstanceVerdict =
503
507
  | { kind: "owned"; row: LivePlanBoundRow }
504
508
  | { kind: "adoptable"; row: LivePlanAdoptableRow }
505
509
  | { kind: "foreign"; row: LivePlanUnownedRow }
@@ -516,7 +520,7 @@ type InstanceVerdict =
516
520
  * its own would read as unsupported-kind and hide a real, actionable verdict
517
521
  * (choudoufu #962, chant #2241).
518
522
  */
519
- function classifyLiveInstance(address: string, index: LivePlanIndex): InstanceVerdict {
523
+ export function classifyLiveInstance(address: string, index: LivePlanIndex): InstanceVerdict {
520
524
  const unowned = index.unowned.get(address);
521
525
  if (unowned) {
522
526
  return unowned.adoptEstate || unowned.adoptAddress
@@ -1015,7 +1019,7 @@ const REAL_DEPS: TerraformReadDeps = {
1015
1019
  };
1016
1020
 
1017
1021
  /** A declared entity, plus the root, address and mode `buildRoots()` recorded on it. */
1018
- interface TerraformDeclared extends DeclaredEntity {
1022
+ export interface TerraformDeclared extends DeclaredEntity {
1019
1023
  root: string;
1020
1024
  address: string;
1021
1025
  /** `"live"` when the root runs under choudoufu with a declared estate (#2103). */
@@ -1053,7 +1057,12 @@ export function qualifiedAddress(address: string, callers: readonly string[]): s
1053
1057
  return callers.length === 0 ? address : `${callers.join(".")}.${address}`;
1054
1058
  }
1055
1059
 
1056
- function declaredOf(name: string, entity: { entityType: string; props: Record<string, unknown> } | undefined): TerraformDeclared {
1060
+ /**
1061
+ * The root, qualified address and mode of one declared entity, from what
1062
+ * `buildRoots()` recorded on its props. Exported for the behaviour request
1063
+ * builder (#2360), which groups entities by root the same way.
1064
+ */
1065
+ export function declaredOf(name: string, entity: { entityType: string; props: Record<string, unknown> } | undefined): TerraformDeclared {
1057
1066
  const props = entity?.props ?? {};
1058
1067
  const fallback = fromEntityName(name);
1059
1068
  const callers = Array.isArray(props.callers)