@intentius/chant-lexicon-terraform 0.60.0 → 0.62.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/dist/composites/terraform-adopt-op.d.ts +18 -2
  2. package/dist/composites/terraform-adopt-op.d.ts.map +1 -1
  3. package/dist/describe-resources.d.ts +59 -0
  4. package/dist/describe-resources.d.ts.map +1 -1
  5. package/dist/hcl/edges.d.ts +124 -0
  6. package/dist/hcl/edges.d.ts.map +1 -0
  7. package/dist/hcl/parse.d.ts +42 -0
  8. package/dist/hcl/parse.d.ts.map +1 -1
  9. package/dist/hcl/roots.d.ts +6 -0
  10. package/dist/hcl/roots.d.ts.map +1 -1
  11. package/dist/index.d.ts +1 -1
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/integrity.json +2 -2
  14. package/dist/manifest.json +1 -1
  15. package/dist/op/activities/terraform.d.ts +44 -8
  16. package/dist/op/activities/terraform.d.ts.map +1 -1
  17. package/dist/op/adoption.d.ts +110 -35
  18. package/dist/op/adoption.d.ts.map +1 -1
  19. package/package.json +2 -2
  20. package/src/__fixtures__/ACCEPTANCE.md +135 -60
  21. package/src/__fixtures__/graph-roots/README.md +21 -0
  22. package/src/__fixtures__/graph-roots/app/main.tf +41 -0
  23. package/src/__fixtures__/graph-roots/app/modules/cdn/main.tf +7 -0
  24. package/src/__fixtures__/graph-roots/network/main.tf +16 -0
  25. package/src/__fixtures__/live-estate/README.md +65 -19
  26. package/src/__fixtures__/live-estate/adoptable.tf +13 -0
  27. package/src/__fixtures__/live-ls.json +5 -5
  28. package/src/__fixtures__/live-plan.json +67 -35
  29. package/src/composites/terraform-adopt-op.acceptance.test.ts +101 -50
  30. package/src/composites/terraform-adopt-op.test.ts +33 -11
  31. package/src/composites/terraform-adopt-op.ts +20 -2
  32. package/src/composites/terraform-apply-op.acceptance.test.ts +4 -2
  33. package/src/describe-resources.live.test.ts +76 -4
  34. package/src/describe-resources.test.ts +35 -0
  35. package/src/describe-resources.ts +110 -3
  36. package/src/hcl/edges.test.ts +207 -0
  37. package/src/hcl/edges.ts +316 -0
  38. package/src/hcl/parse.ts +53 -0
  39. package/src/hcl/roots.ts +14 -0
  40. package/src/index.ts +1 -0
  41. package/src/op/activities/choudoufu.test.ts +3 -3
  42. package/src/op/activities/terraform.ts +70 -21
  43. package/src/op/adoption.test.ts +129 -11
  44. package/src/op/adoption.ts +181 -49
@@ -13,6 +13,34 @@
13
13
  * and the marker on the resource is the ownership answer. The two halves are
14
14
  * two adapters over the same `observeEntities` harness, picked per root.
15
15
  *
16
+ * ## Which of the two answered is on the wire (#2267)
17
+ *
18
+ * They are different claims. `terraform show -json` says what the last apply
19
+ * recorded; `choudoufu live-plan -json` says what the account holds now. A
20
+ * renderer that paints an observation as drift and cannot tell them apart
21
+ * paints a state read green, which reads as "the account matches" when all it
22
+ * says is "the apply finished". For an estate that has decided state is never
23
+ * the system of record, a state-backed observation is not a weaker answer, it
24
+ * is a different one.
25
+ *
26
+ * So the returned `ObservationResult` carries `sources`, keyed by chant entity
27
+ * name, with one of exactly two values:
28
+ *
29
+ * - `"state"` — this entity's root was read with `terraform show -json`.
30
+ * - `"live"` — this entity's root was read with `choudoufu live-plan -json`.
31
+ *
32
+ * Three guarantees a consumer may rely on. Every entity `describeResources`
33
+ * was asked about that belongs to a root has an entry, whatever verdict it
34
+ * came back with: present, absent and not-observed alike, because which read
35
+ * was attempted is a fact independent of what it found. The value equals the
36
+ * declared entity's own `props.mode` (`./hcl/parse.ts`), since one fact
37
+ * decides both, so `props.mode` is also a valid join key for a consumer
38
+ * holding the declared graph and no observation. And a project mixing stock
39
+ * and live roots gets both values in one document, which is why this is a map
40
+ * and not a field on the result as a whole.
41
+ *
42
+ * An entity with no root gets no entry, for the same reason it gets no read.
43
+ *
16
44
  * ## The state file is the ownership answer, on a stock root
17
45
  *
18
46
  * Every other lexicon in chant stamps a tag or a label at synthesis and reads
@@ -54,6 +82,7 @@
54
82
  * | `bound[]`, any other source | present, `owned` by derivation, record, or cache; noted as such, and no marker is surfaced because none was read |
55
83
  * | `unowned[]` with `adopt_*` | present, `unknown`; an adoptable match, carrying the exact two tag values #2105 would write |
56
84
  * | `unowned[]` without | present, `foreign`; a live resource is in the way at a declared identity and the plan will not touch it |
85
+ * | `adoptable[]` | present, `unknown`; the same adoptable verdict, for a live resource the sweep content-matched to a declaration that carries no identity of its own (choudoufu #962, chant #2241) |
57
86
  * | `omissions[]` | not-observed, `ABSENT` excepted (see below) |
58
87
  *
59
88
  * The ownership channel this declares is `./live-ownership.ts`'s
@@ -282,6 +311,27 @@ export interface LivePlanUnownedRow {
282
311
  adoptAddress?: string;
283
312
  }
284
313
 
314
+ /**
315
+ * One `adoptable[]` entry: a live resource the estate-wide sweep matched to a
316
+ * declared instance by content rather than by reading a declared identity
317
+ * (choudoufu #962). The declaration names no identity at all, so this is the
318
+ * only row the document has for it and the paired omission says
319
+ * `NEEDS_DISCOVERY`.
320
+ *
321
+ * Same fields as {@link LivePlanUnownedRow} minus `heldBy`, which cannot
322
+ * apply: a row here carries no marker for any estate, or the sweep would have
323
+ * called it foreign instead of unclaimed.
324
+ */
325
+ export interface LivePlanAdoptableRow {
326
+ addr: string;
327
+ type?: string;
328
+ identity?: string;
329
+ adoptEstate?: string;
330
+ adoptAddress?: string;
331
+ /** `matched[]`: the arguments the declaration and the live resource agreed on exactly. */
332
+ matched?: Array<{ attribute: string; value: string }>;
333
+ }
334
+
285
335
  /** A `live-plan -json` document, indexed by declared instance address. */
286
336
  export interface LivePlanIndex {
287
337
  /** `estate`: the estate every section was computed against. */
@@ -289,6 +339,13 @@ export interface LivePlanIndex {
289
339
  bound: Map<string, LivePlanBoundRow>;
290
340
  omissions: Map<string, LivePlanOmissionRow>;
291
341
  unowned: Map<string, LivePlanUnownedRow>;
342
+ adoptable: Map<string, LivePlanAdoptableRow>;
343
+ /**
344
+ * `swept[]`: the resource types the estate-wide sweep listed in full. Empty
345
+ * on every observation read, which asks for no sweep, so an empty
346
+ * `adoptable` beside it says nothing was asked rather than nothing found.
347
+ */
348
+ swept: string[];
292
349
  /** Every address any section named, so a block can find its own instances. */
293
350
  addresses: string[];
294
351
  /** `diagnostics[]` summaries, for the observation's run-level notes. */
@@ -307,6 +364,8 @@ export function indexLivePlan(document: unknown): LivePlanIndex {
307
364
  bound: new Map(),
308
365
  omissions: new Map(),
309
366
  unowned: new Map(),
367
+ adoptable: new Map(),
368
+ swept: asArray(doc.swept).filter((t): t is string => typeof t === "string"),
310
369
  addresses: [],
311
370
  diagnostics: [],
312
371
  };
@@ -348,6 +407,24 @@ export function indexLivePlan(document: unknown): LivePlanIndex {
348
407
  });
349
408
  }
350
409
 
410
+ for (const entry of asArray(doc.adoptable)) {
411
+ const row = asRecord(entry);
412
+ const addr = asString(row.addr);
413
+ if (!addr) continue;
414
+ const matched = asArray(row.matched)
415
+ .map((m) => asRecord(m))
416
+ .filter((m) => asString(m.attribute))
417
+ .map((m) => ({ attribute: asString(m.attribute)!, value: asString(m.value) ?? "" }));
418
+ index.adoptable.set(addr, {
419
+ addr,
420
+ ...(asString(row.type) ? { type: asString(row.type) } : {}),
421
+ ...(asString(row.identity) ? { identity: asString(row.identity) } : {}),
422
+ ...(asString(row.adopt_tofu_estate) ? { adoptEstate: asString(row.adopt_tofu_estate) } : {}),
423
+ ...(asString(row.adopt_tofu_address) ? { adoptAddress: asString(row.adopt_tofu_address) } : {}),
424
+ ...(matched.length > 0 ? { matched } : {}),
425
+ });
426
+ }
427
+
351
428
  for (const entry of asArray(doc.diagnostics)) {
352
429
  const row = asRecord(entry);
353
430
  const summary = asString(row.summary);
@@ -355,7 +432,12 @@ export function indexLivePlan(document: unknown): LivePlanIndex {
355
432
  }
356
433
 
357
434
  index.addresses = [
358
- ...new Set([...index.bound.keys(), ...index.omissions.keys(), ...index.unowned.keys()]),
435
+ ...new Set([
436
+ ...index.bound.keys(),
437
+ ...index.omissions.keys(),
438
+ ...index.unowned.keys(),
439
+ ...index.adoptable.keys(),
440
+ ]),
359
441
  ].sort();
360
442
  return index;
361
443
  }
@@ -419,7 +501,7 @@ function liveModuleMembers(address: string, index: LivePlanIndex): string[] {
419
501
  /** One instance's verdict, before a block aggregates its instances. */
420
502
  type InstanceVerdict =
421
503
  | { kind: "owned"; row: LivePlanBoundRow }
422
- | { kind: "adoptable"; row: LivePlanUnownedRow }
504
+ | { kind: "adoptable"; row: LivePlanAdoptableRow }
423
505
  | { kind: "foreign"; row: LivePlanUnownedRow }
424
506
  | { kind: "absent" }
425
507
  | { kind: "unobserved"; reason: UnobservedReason; detail: string };
@@ -428,6 +510,11 @@ type InstanceVerdict =
428
510
  * Classify one instance address against the document, `unowned[]` first: a
429
511
  * declared instance that also carries a `UNOWNED` omission is answered by the
430
512
  * section that has the verdict, not by the one that has the apology.
513
+ * `adoptable[]` sits under `bound[]` for the same reason, one rung further
514
+ * down: a content match is the answer for an instance nothing else could
515
+ * answer for, and its paired omission is always `NEEDS_DISCOVERY`, which on
516
+ * its own would read as unsupported-kind and hide a real, actionable verdict
517
+ * (choudoufu #962, chant #2241).
431
518
  */
432
519
  function classifyLiveInstance(address: string, index: LivePlanIndex): InstanceVerdict {
433
520
  const unowned = index.unowned.get(address);
@@ -440,6 +527,11 @@ function classifyLiveInstance(address: string, index: LivePlanIndex): InstanceVe
440
527
  const bound = index.bound.get(address);
441
528
  if (bound) return { kind: "owned", row: bound };
442
529
 
530
+ const matched = index.adoptable.get(address);
531
+ if (matched && (matched.adoptEstate || matched.adoptAddress)) {
532
+ return { kind: "adoptable", row: matched };
533
+ }
534
+
443
535
  const omission = index.omissions.get(address);
444
536
  if (omission) {
445
537
  const verdict = omissionVerdict(omission);
@@ -516,6 +608,13 @@ function readLiveResource(
516
608
  ...(adoptable.row.type ? { resourceType: adoptable.row.type } : {}),
517
609
  ...(adoptable.row.adoptEstate ? { adoptTofuEstate: adoptable.row.adoptEstate } : {}),
518
610
  ...(adoptable.row.adoptAddress ? { adoptTofuAddress: adoptable.row.adoptAddress } : {}),
611
+ // Present only on a content match: the arguments the sweep compared,
612
+ // which is the whole evidence for a match nobody read an identity
613
+ // for, and the thing an operator checks before letting a tag write
614
+ // claim the resource.
615
+ ...(adoptable.row.matched?.length
616
+ ? { matchedOn: adoptable.row.matched.map((m) => `${m.attribute}=${m.value}`) }
617
+ : {}),
519
618
  },
520
619
  },
521
620
  queried,
@@ -1091,12 +1190,20 @@ export async function describeResources(
1091
1190
 
1092
1191
  const parts = [];
1093
1192
  const notes: string[] = [];
1193
+ // Which of the two reads answered, per entity (#2267). Filled for every
1194
+ // entity of the root before the read runs, so the answer is on the wire
1195
+ // whatever the verdict came back as: present, absent, or not-observed. A
1196
+ // renderer joins observations to nodes, and painting `terraform show -json`
1197
+ // over state the same green as a live read of the account is the drift lie
1198
+ // an overlay exists to prevent.
1199
+ const sources: Record<string, string> = {};
1094
1200
  for (const [root, declared] of byRoot) {
1095
1201
  // One fact decides the whole read, and the parse already recorded it on
1096
1202
  // every entity of the root (#2103). A root is live when its binary is
1097
1203
  // choudoufu AND it declares an estate, so a single entity carrying
1098
1204
  // `mode: "live"` settles it for the root.
1099
1205
  const live = declared.some((entity) => entity.mode === "live");
1206
+ for (const entity of declared) sources[entity.name] = live ? "live" : "state";
1100
1207
  if (live) {
1101
1208
  notes.push(
1102
1209
  `terraform.roots.${root} is a live root: ownership came from choudoufu's tofu-estate/tofu-address markers via \`live-plan -json\`, not from a state file`,
@@ -1136,5 +1243,5 @@ export async function describeResources(
1136
1243
  }
1137
1244
  }
1138
1245
 
1139
- return observation(resources, unobserved, merged.queried, merged.notes);
1246
+ return observation(resources, unobserved, merged.queried, merged.notes, undefined, sources);
1140
1247
  }
@@ -0,0 +1,207 @@
1
+ /**
2
+ * Reference resolution and the graph IR it feeds (chant #2265, #2266).
3
+ *
4
+ * Everything here goes through `renderTerraformRoots` and then core's own
5
+ * `buildGraphIr`, which is the exact pair `chant graph --format ir` runs, so
6
+ * what is asserted is what a renderer receives. The fixture is
7
+ * `src/__fixtures__/graph-roots/`: two roots, a local child module, and one
8
+ * instance of every decision these issues asked to have pinned.
9
+ */
10
+
11
+ import { dirname, join } from "node:path";
12
+ import { fileURLToPath } from "node:url";
13
+ import { beforeAll, describe, expect, it } from "vitest";
14
+ import { buildGraphIr, entityReferences, entityStack, type IREdge } from "@intentius/chant/graph-ir";
15
+ import type { Declarable } from "@intentius/chant/declarable";
16
+ import { renderTerraformRoots } from "./roots";
17
+ import { referenceFromAccessor } from "./edges";
18
+ import type { TerraformEntity } from "./parse";
19
+
20
+ const TREE = join(dirname(fileURLToPath(import.meta.url)), "..", "__fixtures__", "graph-roots");
21
+
22
+ let entities: Map<string, Declarable>;
23
+ let edges: IREdge[];
24
+ let groups: ReturnType<typeof buildGraphIr>["groups"];
25
+
26
+ beforeAll(async () => {
27
+ const rendered = await renderTerraformRoots({
28
+ projectRoot: TREE,
29
+ roots: { app: { dir: "./app" }, network: { dir: "./network" } },
30
+ });
31
+ expect(rendered.warnings).toEqual([]);
32
+ entities = rendered.entities;
33
+ const ir = buildGraphIr(entities);
34
+ edges = ir.edges;
35
+ groups = ir.groups;
36
+ });
37
+
38
+ /** The edge between two nodes through one consumer attribute, if there is one. */
39
+ function edge(from: string, to: string, viaAttr: string): IREdge | undefined {
40
+ return edges.find((e) => e.from === from && e.to === to && e.viaAttr === viaAttr);
41
+ }
42
+
43
+ describe("referenceFromAccessor", () => {
44
+ it("classifies every form that becomes an edge", () => {
45
+ expect(referenceFromAccessor("aws_vpc.main.id")).toEqual({ address: "aws_vpc.main", attr: "id" });
46
+ expect(referenceFromAccessor("module.cdn.url")).toEqual({ address: "module.cdn", attr: "url" });
47
+ expect(referenceFromAccessor("data.aws_ami.ubuntu.id")).toEqual({
48
+ address: "data.aws_ami.ubuntu",
49
+ attr: "id",
50
+ });
51
+ expect(referenceFromAccessor("var.region")).toEqual({ address: "var.region" });
52
+ expect(referenceFromAccessor("local.tags")).toEqual({ address: "local.tags" });
53
+ });
54
+
55
+ it("reads no reference out of the meta-arguments that are not one", () => {
56
+ // `count.index`, `each.value`, `path.module`, `terraform.workspace` and
57
+ // `self.x` all look like `<head>.<name>` and none of them names a block.
58
+ expect(referenceFromAccessor("count.index")).toBeUndefined();
59
+ expect(referenceFromAccessor("each.value")).toBeUndefined();
60
+ expect(referenceFromAccessor("path.module")).toBeUndefined();
61
+ expect(referenceFromAccessor("terraform.workspace")).toBeUndefined();
62
+ expect(referenceFromAccessor("self.private_ip")).toBeUndefined();
63
+ });
64
+ });
65
+
66
+ describe("edges out of a parsed estate (#2265)", () => {
67
+ it("emits the whole edge set, and only it", () => {
68
+ expect(
69
+ edges.map((e) => [e.from, e.to, e.viaAttr ?? "", e.toAttr ?? ""].join(" ")),
70
+ ).toEqual([
71
+ "app/aws_instance.web app/aws_s3_bucket.assets depends_on ",
72
+ "app/aws_instance.web app/data.aws_iam_policy.boundary tags arn",
73
+ "app/aws_instance.web app/module.cdn depends_on ",
74
+ "app/aws_instance.web app/var.subnets count ",
75
+ "app/aws_instance.web app/var.subnets subnet_id ",
76
+ "app/module.cdn/aws_cloudfront_distribution.cdn app/module.cdn/var.bucket origin_id ",
77
+ "app/module.cdn app/aws_s3_bucket.assets bucket id",
78
+ "app/output.cdn_url app/module.cdn value url",
79
+ "app/provider.aws app/var.region region ",
80
+ "network/aws_vpc.main network/locals tags ",
81
+ "network/aws_vpc.main network/var.cidr cidr_block ",
82
+ "network/output.vpc_id network/aws_vpc.main value id",
83
+ ]);
84
+ });
85
+
86
+ it("makes a `module` reference an edge to the call block, in both directions of use", () => {
87
+ // The call reads a resource; an output reads the call.
88
+ expect(edge("app/module.cdn", "app/aws_s3_bucket.assets", "bucket")).toEqual({
89
+ from: "app/module.cdn",
90
+ to: "app/aws_s3_bucket.assets",
91
+ kind: "ref",
92
+ viaAttr: "bucket",
93
+ toAttr: "id",
94
+ });
95
+ expect(edge("app/output.cdn_url", "app/module.cdn", "value")?.toAttr).toBe("url");
96
+ });
97
+
98
+ it("makes `depends_on` an edge with no producer attribute", () => {
99
+ const ordering = edges.filter((e) => e.viaAttr === "depends_on");
100
+ expect(ordering.map((e) => e.to).sort()).toEqual(["app/aws_s3_bucket.assets", "app/module.cdn"]);
101
+ for (const e of ordering) expect(e.toAttr).toBeUndefined();
102
+ });
103
+
104
+ it("keeps a `count` block one node, and its edges block-to-block", () => {
105
+ const web = entities.get("app/aws_instance.web") as TerraformEntity;
106
+ // `count = length(var.subnets)` over a list: the declaration is one block,
107
+ // so it is one node whatever the count evaluates to, and the expansion is
108
+ // recorded rather than left to be inferred from a node count.
109
+ expect(web.props.expansion).toBe("count");
110
+ expect([...entities.keys()].filter((k) => k.startsWith("app/aws_instance.web"))).toEqual([
111
+ "app/aws_instance.web",
112
+ ]);
113
+ // The meta-argument is an expression like any other, so it carries an edge.
114
+ expect(edge("app/aws_instance.web", "app/var.subnets", "count")).toBeDefined();
115
+ // ...and reading `var.subnets[count.index]` elsewhere in the block is a
116
+ // second edge through a second attribute, not a second instance.
117
+ expect(edge("app/aws_instance.web", "app/var.subnets", "subnet_id")).toBeDefined();
118
+ });
119
+
120
+ it("makes `var` and `local` edges, and resolves a local to the block that declares it", () => {
121
+ expect(edge("network/aws_vpc.main", "network/var.cidr", "cidr_block")).toBeDefined();
122
+ expect(edge("network/aws_vpc.main", "network/locals", "tags")).toBeDefined();
123
+ });
124
+
125
+ it("names the consumer attribute on every edge and the producer attribute only when one was read", () => {
126
+ for (const e of edges) expect(e.viaAttr).toBeTruthy();
127
+ // `data.aws_iam_policy.boundary.arn` named exactly one producer attribute.
128
+ expect(edge("app/aws_instance.web", "app/data.aws_iam_policy.boundary", "tags")?.toAttr).toBe("arn");
129
+ // `[aws_s3_bucket.assets]` named none.
130
+ expect(edge("app/aws_instance.web", "app/aws_s3_bucket.assets", "depends_on")?.toAttr).toBeUndefined();
131
+ });
132
+
133
+ it("resolves a child module's references inside the child's own scope", () => {
134
+ // `var.bucket` in `module.cdn` is the CHILD's variable. The root declares
135
+ // no `var.bucket` at all, so a scope-blind resolution would have produced
136
+ // no edge here rather than the wrong one; the assertion that matters is
137
+ // that the edge lands on the child's key.
138
+ expect(
139
+ edge(
140
+ "app/module.cdn/aws_cloudfront_distribution.cdn",
141
+ "app/module.cdn/var.bucket",
142
+ "origin_id",
143
+ ),
144
+ ).toBeDefined();
145
+ });
146
+
147
+ it("draws no edge for a `provider` meta-argument", () => {
148
+ // `provider = aws.replica` names a block by type AND alias, which this
149
+ // lexicon's `provider.<type>` key cannot be resolved to without guessing.
150
+ expect(edges.some((e) => e.viaAttr === "provider")).toBe(false);
151
+ });
152
+
153
+ it("draws no edge between two roots", () => {
154
+ const rootOf = (id: string): string => id.split("/")[0];
155
+ for (const e of edges) expect(rootOf(e.from)).toBe(rootOf(e.to));
156
+ });
157
+
158
+ it("publishes the references on the entity, in core's lexicon-neutral shape", () => {
159
+ // The channel core reads (#2265): an entity key, plus the two attribute
160
+ // names `IREdge` carries. Nothing terraform-shaped crosses the boundary.
161
+ expect(entityReferences(entities.get("app/output.cdn_url")!)).toEqual([
162
+ { to: "app/module.cdn", viaAttr: "value", toAttr: "url" },
163
+ ]);
164
+ // An entity that references nothing carries no channel at all.
165
+ expect(entityReferences(entities.get("app/var.region")!)).toEqual([]);
166
+ });
167
+
168
+ it("drops a reference to something outside the graph rather than dangling it", () => {
169
+ // Only ids that are nodes become edges, so an entity key that never made
170
+ // it into the IR cannot leave a half-edge behind.
171
+ const ids = new Set([...entities.keys()]);
172
+ for (const e of edges) {
173
+ expect(ids.has(e.from)).toBe(true);
174
+ expect(ids.has(e.to)).toBe(true);
175
+ }
176
+ });
177
+ });
178
+
179
+ describe("grouping by root (#2266)", () => {
180
+ it("keys byStack by root name, one entry per declared root", () => {
181
+ expect(Object.keys(groups.byStack ?? {})).toEqual(["app", "network"]);
182
+ expect(groups.byStack?.["network"]).toEqual([
183
+ "network/aws_vpc.main",
184
+ "network/locals",
185
+ "network/output.vpc_id",
186
+ "network/var.cidr",
187
+ ]);
188
+ });
189
+
190
+ it("keeps byLexicon saying terraform", () => {
191
+ expect(Object.keys(groups.byLexicon ?? {})).toEqual(["terraform"]);
192
+ expect(groups.byLexicon?.["terraform"].length).toBe(
193
+ (groups.byStack?.["app"].length ?? 0) + (groups.byStack?.["network"].length ?? 0),
194
+ );
195
+ });
196
+
197
+ it("puts every node in exactly one root's bucket, the one its id is prefixed with", () => {
198
+ for (const [root, ids] of Object.entries(groups.byStack ?? {})) {
199
+ for (const id of ids) expect(id.startsWith(`${root}/`)).toBe(true);
200
+ }
201
+ });
202
+
203
+ it("says which unit an entity belongs to on the entity, not by convention on its id", () => {
204
+ expect(entityStack(entities.get("app/module.cdn/var.bucket")!)).toBe("app");
205
+ expect(entityStack(entities.get("network/aws_vpc.main")!)).toBe("network");
206
+ });
207
+ });