@intentius/chant-lexicon-terraform 0.60.0 → 0.61.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 (40) hide show
  1. package/dist/describe-resources.d.ts +59 -0
  2. package/dist/describe-resources.d.ts.map +1 -1
  3. package/dist/hcl/edges.d.ts +124 -0
  4. package/dist/hcl/edges.d.ts.map +1 -0
  5. package/dist/hcl/parse.d.ts +42 -0
  6. package/dist/hcl/parse.d.ts.map +1 -1
  7. package/dist/hcl/roots.d.ts +6 -0
  8. package/dist/hcl/roots.d.ts.map +1 -1
  9. package/dist/index.d.ts +1 -1
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/integrity.json +2 -2
  12. package/dist/manifest.json +1 -1
  13. package/dist/op/activities/terraform.d.ts +44 -8
  14. package/dist/op/activities/terraform.d.ts.map +1 -1
  15. package/dist/op/adoption.d.ts +110 -35
  16. package/dist/op/adoption.d.ts.map +1 -1
  17. package/package.json +2 -2
  18. package/src/__fixtures__/ACCEPTANCE.md +92 -58
  19. package/src/__fixtures__/graph-roots/README.md +21 -0
  20. package/src/__fixtures__/graph-roots/app/main.tf +41 -0
  21. package/src/__fixtures__/graph-roots/app/modules/cdn/main.tf +7 -0
  22. package/src/__fixtures__/graph-roots/network/main.tf +16 -0
  23. package/src/__fixtures__/live-estate/README.md +65 -19
  24. package/src/__fixtures__/live-estate/adoptable.tf +13 -0
  25. package/src/__fixtures__/live-ls.json +5 -5
  26. package/src/__fixtures__/live-plan.json +67 -35
  27. package/src/composites/terraform-adopt-op.acceptance.test.ts +36 -50
  28. package/src/composites/terraform-apply-op.acceptance.test.ts +4 -2
  29. package/src/describe-resources.live.test.ts +76 -4
  30. package/src/describe-resources.test.ts +35 -0
  31. package/src/describe-resources.ts +110 -3
  32. package/src/hcl/edges.test.ts +207 -0
  33. package/src/hcl/edges.ts +316 -0
  34. package/src/hcl/parse.ts +53 -0
  35. package/src/hcl/roots.ts +14 -0
  36. package/src/index.ts +1 -0
  37. package/src/op/activities/choudoufu.test.ts +3 -3
  38. package/src/op/activities/terraform.ts +70 -21
  39. package/src/op/adoption.test.ts +129 -11
  40. package/src/op/adoption.ts +181 -49
@@ -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
+ });
@@ -0,0 +1,316 @@
1
+ /**
2
+ * Resolve each block's references into graph edges (chant #2265).
3
+ *
4
+ * `chant graph --format ir` used to emit a complete, module-descended,
5
+ * fully-attributed node set for a Terraform estate and zero edges. Core's
6
+ * `collectEdges` walks a node's config bag for `AttrRef` objects and `Ref`
7
+ * intrinsics; a block body out of hcl2json holds `"${aws_vpc.main.id}"`, a
8
+ * string, so the walk found nothing and said so. This module supplies what
9
+ * that walk cannot see: the references, already resolved to the entity keys
10
+ * `blocksToEntities` minted, published on each entity as core's lexicon-neutral
11
+ * `EntityReference` (see `@intentius/chant/graph-ir`).
12
+ *
13
+ * ## Where the reference forms come from
14
+ *
15
+ * The `${...}` body is tokenized by hcl2json's own expression AST
16
+ * (`getReferencesInExpression`), never by a regex over the string. That is the
17
+ * same instrument `packages/core/src/terraform/parse.ts` uses for the carve-out
18
+ * advisor, and for the same reason: a quoted address used as a map key
19
+ * (`var.m["aws_s3_bucket.assets.arn"]`) and an escaped `$${...}` literal are
20
+ * not references, and no regex over the raw string can tell. `./references.ts`
21
+ * IS such a regex scan and stays one: it answers "is this declaration used
22
+ * anywhere", deliberately generously, for a report-only rule. An edge a
23
+ * renderer draws is a stricter claim and gets the stricter instrument.
24
+ *
25
+ * Each accessor the AST returns is classified by core's own `refFromAccessor`
26
+ * for the three forms it already knows (`<type>.<name>`, `module.<name>`,
27
+ * `data.<type>.<name>`), and here for the two it deliberately excludes as
28
+ * non-resources, `var.<name>` and `local.<name>`.
29
+ *
30
+ * ## Four decisions a renderer will draw
31
+ *
32
+ * **Which forms become edges.** All five: `resource`, `data`, `module`, `var`
33
+ * and `local`. The first three are uncontroversial. The last two are edges
34
+ * because this lexicon emits a NODE for every `variable` and every `locals`
35
+ * block, and on a real estate those are the majority of them (108 variables
36
+ * and 10 locals out of 247 nodes, on the estate the issue was filed against).
37
+ * Dropping their edges would leave nearly half the graph as unconnected cards,
38
+ * which is the same picture the zero-edge bug drew. "Which resources consume
39
+ * `var.region`" is also a question a reader of a root module actually asks,
40
+ * and `terraform graph` itself answers it.
41
+ *
42
+ * A `local.<name>` resolves to the `locals` BLOCK that declares that name,
43
+ * found by looking `name` up in each block's body, so two `locals` blocks in
44
+ * one scope resolve independently rather than both matching.
45
+ *
46
+ * `provider = aws.west` is NOT an edge. The reference names a `provider`
47
+ * block by type AND alias, and this lexicon's entity key for one is
48
+ * `provider.<type>` with the alias inside the body, so two aliased providers
49
+ * of one type key as `provider.aws` and `provider.aws~2` and the reference
50
+ * cannot be resolved to either without guessing. `./references.ts` still
51
+ * records it for TF020, where "is it used at all" is answerable without
52
+ * knowing which block.
53
+ *
54
+ * **`depends_on` is an edge.** It arrives from hcl2json as
55
+ * `["${aws_vpc.main}"]` under the key `depends_on`, so it resolves through the
56
+ * same path as any other reference and lands as `viaAttr: "depends_on"` with
57
+ * no `toAttr`, which is exactly what it is: an ordering edge with no attribute
58
+ * flowing along it.
59
+ *
60
+ * **`count` / `for_each` are block-to-block.** chant's entity is the block, so
61
+ * `count = 3` is one node and one edge, not three. The expansion itself is
62
+ * recorded on the entity (`props.expansion`, `./parse.ts`) rather than left to
63
+ * be inferred from a node count that never grows. A `count = length(var.x)`
64
+ * also yields a real edge to `var.x` through `viaAttr: "count"`, since the
65
+ * meta-argument is an expression like any other.
66
+ *
67
+ * **Attribute naming.** `viaAttr` is the consumer-side TOP-LEVEL attribute the
68
+ * reference sits under and `toAttr` the producer-side attribute it read, which
69
+ * is the same pair carve's `TfEdge` carries as `via` and `attrs` and the pair
70
+ * behold already renders. One edge per (consumer, producer, `viaAttr`), and
71
+ * `toAttr` is set only when that edge read exactly one producer attribute:
72
+ * `subnet_id = aws_subnet.a.id` gives `id`, while
73
+ * `tags = merge(aws_vpc.main.tags, { id = aws_vpc.main.id })` names two and
74
+ * gets none rather than an arbitrary one.
75
+ *
76
+ * ## Scope, and what is deliberately not resolved
77
+ *
78
+ * Resolution is bounded to the referring block's own module scope
79
+ * (`scopeOfKey`), which is Terraform's own namespace: a `var.region` inside
80
+ * `module.cdn` is the child module's variable and never the root's. That also
81
+ * means no edge ever crosses roots, which is correct and intended (#2265):
82
+ * separate roots read each other by NAME through a data source, never by
83
+ * reference, and a value-match pass over those is a consumer's job, not this
84
+ * one's.
85
+ */
86
+
87
+ import { isResourceDeclarable, type Declarable } from "@intentius/chant/declarable";
88
+ import type { EntityReference } from "@intentius/chant/graph-ir";
89
+ import { loadHcl2json, type Hcl2Json } from "@intentius/chant/terraform/parse";
90
+ import { refFromAccessor } from "@intentius/chant/terraform/graph";
91
+ import { LOCALS_TYPE, scopeOfKey, type BlockBody, type TerraformEntity } from "./parse";
92
+
93
+ /** An identifier as Terraform's grammar allows it. Same as `./references.ts`. */
94
+ const NAME = "[A-Za-z_][A-Za-z0-9_-]*";
95
+
96
+ /** `var.<name>` / `local.<name>` at the head of a traversal accessor. */
97
+ const SCALAR_HEAD_RE = new RegExp(`^(var|local)\\.(${NAME})(?:\\.|$)`);
98
+
99
+ /** One reference read out of a body, before it is resolved to an entity key. */
100
+ interface RawReference {
101
+ /** The address in this lexicon's own vocabulary: `aws_vpc.main`, `var.region`, `local.tags`. */
102
+ address: string;
103
+ /** Producer-side attribute, when the accessor named one. */
104
+ attr?: string;
105
+ /** Consumer-side top-level attribute the reference sits under. */
106
+ via: string;
107
+ }
108
+
109
+ /**
110
+ * Classify one AST traversal accessor. `var`/`local` are handled here because
111
+ * core's `refFromAccessor` excludes them by design (they are not carvable
112
+ * resources); everything else defers to it, quoted map keys and numeric
113
+ * indexes included.
114
+ */
115
+ export function referenceFromAccessor(accessor: string): { address: string; attr?: string } | undefined {
116
+ const scalar = SCALAR_HEAD_RE.exec(accessor);
117
+ if (scalar) return { address: `${scalar[1]}.${scalar[2]}` };
118
+ return refFromAccessor(accessor) ?? undefined;
119
+ }
120
+
121
+ /** Every `${...}` string in a body, paired with the top-level attribute it sits under. */
122
+ function expressionsInBody(body: BlockBody): Array<{ via: string; expression: string }> {
123
+ const out: Array<{ via: string; expression: string }> = [];
124
+ const visit = (value: unknown, via: string, depth: number): void => {
125
+ if (depth > 8) return;
126
+ if (typeof value === "string") {
127
+ if (value.includes("${")) out.push({ via, expression: value });
128
+ return;
129
+ }
130
+ if (Array.isArray(value)) {
131
+ for (const item of value) visit(item, via, depth + 1);
132
+ return;
133
+ }
134
+ if (typeof value === "object" && value !== null) {
135
+ for (const [key, inner] of Object.entries(value as Record<string, unknown>)) {
136
+ visit(inner, depth === 0 ? key : via, depth + 1);
137
+ }
138
+ }
139
+ };
140
+ visit(body, "", 0);
141
+ return out;
142
+ }
143
+
144
+ /** The `${...}` strings of every entity in the map, deduplicated. */
145
+ function allExpressions(entities: ReadonlyMap<string, Declarable>): string[] {
146
+ const seen = new Set<string>();
147
+ for (const entity of entities.values()) {
148
+ if (!isResourceDeclarable(entity)) continue;
149
+ const body = bodyOf(entity as TerraformEntity);
150
+ for (const { expression } of expressionsInBody(body)) seen.add(expression);
151
+ }
152
+ return [...seen];
153
+ }
154
+
155
+ function bodyOf(entity: TerraformEntity): BlockBody {
156
+ const props = entity.props as Partial<TerraformEntity["props"]>;
157
+ return (typeof props.body === "object" && props.body !== null ? props.body : {}) as BlockBody;
158
+ }
159
+
160
+ /**
161
+ * Resolve every expression once through the AST. An expression the parser
162
+ * refuses in isolation resolves to no references: fewer edges, never a phantom
163
+ * one, which is the same trade `resolveExpressionRefs` makes in core.
164
+ */
165
+ async function resolveAccessors(
166
+ parser: Hcl2Json,
167
+ expressions: readonly string[],
168
+ ): Promise<Map<string, string[]>> {
169
+ const out = new Map<string, string[]>();
170
+ for (const expression of expressions) {
171
+ try {
172
+ const found = await parser.getReferencesInExpression("expression.tf", expression);
173
+ out.set(
174
+ expression,
175
+ found.map((r) => r.value),
176
+ );
177
+ } catch {
178
+ out.set(expression, []);
179
+ }
180
+ }
181
+ return out;
182
+ }
183
+
184
+ /** Address to entity key, and local name to the `locals` block declaring it, per module scope. */
185
+ export interface ScopeIndex {
186
+ byAddress: Map<string, string>;
187
+ byLocal: Map<string, string>;
188
+ }
189
+
190
+ function buildScopeIndex(entities: ReadonlyMap<string, Declarable>): Map<string, ScopeIndex> {
191
+ const scopes = new Map<string, ScopeIndex>();
192
+ for (const [key, entity] of entities) {
193
+ if (!isResourceDeclarable(entity)) continue;
194
+ const te = entity as TerraformEntity;
195
+ const address = (te.props as Partial<TerraformEntity["props"]>).address;
196
+ if (typeof address !== "string") continue;
197
+ const scope = scopeOfKey(key);
198
+ let index = scopes.get(scope);
199
+ if (!index) {
200
+ index = { byAddress: new Map(), byLocal: new Map() };
201
+ scopes.set(scope, index);
202
+ }
203
+ // First key wins: two blocks that genuinely share an address are keyed
204
+ // `~2`, `~3` (`./parse.ts`), and a reference cannot say which it meant.
205
+ if (!index.byAddress.has(address)) index.byAddress.set(address, key);
206
+ if (te.entityType === LOCALS_TYPE) {
207
+ for (const name of Object.keys(bodyOf(te))) {
208
+ if (!index.byLocal.has(name)) index.byLocal.set(name, key);
209
+ }
210
+ }
211
+ }
212
+ return scopes;
213
+ }
214
+
215
+ /** The entity key an address names within one scope, or undefined. */
216
+ function resolveAddress(address: string, index: ScopeIndex): string | undefined {
217
+ if (address.startsWith("local.")) return index.byLocal.get(address.slice("local.".length));
218
+ return index.byAddress.get(address);
219
+ }
220
+
221
+ /** Every reference in one body, as raw addresses paired with the attribute they sit under. */
222
+ function rawReferences(body: BlockBody, accessors: ReadonlyMap<string, readonly string[]>): RawReference[] {
223
+ const out: RawReference[] = [];
224
+ for (const { via, expression } of expressionsInBody(body)) {
225
+ for (const accessor of accessors.get(expression) ?? []) {
226
+ const ref = referenceFromAccessor(accessor);
227
+ if (ref) out.push({ address: ref.address, ...(ref.attr ? { attr: ref.attr } : {}), via });
228
+ }
229
+ }
230
+ return out;
231
+ }
232
+
233
+ /**
234
+ * The references one block declares, resolved to entity keys and collapsed to
235
+ * one entry per (producer, `viaAttr`). Sorted, so the IR a build emits is
236
+ * byte-stable across runs.
237
+ */
238
+ export function referencesOfEntity(
239
+ key: string,
240
+ entity: TerraformEntity,
241
+ scopes: ReadonlyMap<string, ScopeIndex>,
242
+ accessors: ReadonlyMap<string, readonly string[]>,
243
+ ): EntityReference[] {
244
+ const index = scopes.get(scopeOfKey(key));
245
+ if (!index) return [];
246
+
247
+ const grouped = new Map<string, { to: string; viaAttr: string; attrs: Set<string> }>();
248
+ for (const raw of rawReferences(bodyOf(entity), accessors)) {
249
+ const to = resolveAddress(raw.address, index);
250
+ if (!to || to === key) continue;
251
+ const groupKey = `${to}${raw.via}`;
252
+ let group = grouped.get(groupKey);
253
+ if (!group) {
254
+ group = { to, viaAttr: raw.via, attrs: new Set() };
255
+ grouped.set(groupKey, group);
256
+ }
257
+ if (raw.attr) group.attrs.add(raw.attr);
258
+ }
259
+
260
+ const out: EntityReference[] = [];
261
+ for (const { to, viaAttr, attrs } of grouped.values()) {
262
+ out.push({
263
+ to,
264
+ ...(viaAttr ? { viaAttr } : {}),
265
+ // Exactly one, or none: an edge carrying an arbitrary pick out of two
266
+ // would be a label a reader cannot trust.
267
+ ...(attrs.size === 1 ? { toAttr: [...attrs][0] } : {}),
268
+ });
269
+ }
270
+ return out.sort((a, b) => cmp(a.to, b.to) || cmp(a.viaAttr ?? "", b.viaAttr ?? ""));
271
+ }
272
+
273
+ /** Code-point ordering, so output does not depend on the machine's locale. */
274
+ function cmp(a: string, b: string): number {
275
+ return a < b ? -1 : a > b ? 1 : 0;
276
+ }
277
+
278
+ /**
279
+ * Stamp `references` onto every terraform entity in `entities`, in place.
280
+ *
281
+ * Takes the whole build's entity map rather than one root's, because one
282
+ * shared expression cache across every root is what keeps this to one wasm
283
+ * call per DISTINCT expression instead of one per occurrence. Resolution is
284
+ * still per module scope, so pooling the parse pools no references.
285
+ *
286
+ * Best effort, like everything else on this path: a parser that cannot be
287
+ * loaded, or one that does not expose the expression AST, leaves every entity
288
+ * unreferenced rather than failing a build over a diagram.
289
+ */
290
+ export async function resolveEntityReferences(
291
+ entities: Map<string, Declarable>,
292
+ hcl2json?: Hcl2Json,
293
+ ): Promise<void> {
294
+ const expressions = allExpressions(entities);
295
+ if (expressions.length === 0) return;
296
+
297
+ let parser: Hcl2Json;
298
+ try {
299
+ parser = hcl2json ?? (await loadHcl2json());
300
+ } catch {
301
+ return;
302
+ }
303
+ if (typeof parser.getReferencesInExpression !== "function") return;
304
+
305
+ const accessors = await resolveAccessors(parser, expressions);
306
+ const scopes = buildScopeIndex(entities);
307
+
308
+ for (const [key, entity] of entities) {
309
+ if (!isResourceDeclarable(entity)) continue;
310
+ const te = entity as TerraformEntity;
311
+ const references = referencesOfEntity(key, te, scopes, accessors);
312
+ if (references.length === 0) continue;
313
+ const stamped: TerraformEntity = { ...te, references };
314
+ entities.set(key, stamped);
315
+ }
316
+ }
package/src/hcl/parse.ts CHANGED
@@ -22,6 +22,7 @@ import { existsSync, readdirSync, readFileSync } from "node:fs";
22
22
  import { join } from "node:path";
23
23
  import { loadHcl2json, type Hcl2Json } from "@intentius/chant/terraform/parse";
24
24
  import { DECLARABLE_MARKER, type Declarable } from "@intentius/chant/declarable";
25
+ import type { EntityReference } from "@intentius/chant/graph-ir";
25
26
  import type { SuppressionDirective } from "@intentius/chant/lint/suppressions";
26
27
  import type { TerraformDeleteMode } from "../config";
27
28
  import { scanSuppressions, directivesFor, type FileScan } from "./suppressions";
@@ -29,6 +30,18 @@ import { scanSuppressions, directivesFor, type FileScan } from "./suppressions";
29
30
  /** A parsed HCL block body, as `@cdktf/hcl2json` encodes it. */
30
31
  export type BlockBody = Record<string, unknown>;
31
32
 
33
+ /**
34
+ * The meta-argument that expands a block into instances, when it carries one
35
+ * (chant #2265). Recorded because chant's entity is the BLOCK, one node
36
+ * whatever `count` evaluates to, so an edge out of an expanded block is
37
+ * block-to-block and says nothing about how many instances reference how many
38
+ * others. That is the honest shape for a read of the declaration alone, since
39
+ * the instance count is a plan-time answer and often a state-time one, but it
40
+ * is a shape a reader should be told rather than left to infer from a node
41
+ * count that never grows.
42
+ */
43
+ export type TerraformExpansion = "count" | "for_each";
44
+
32
45
  /** Whether a root runs under choudoufu with a declared estate (#2103). */
33
46
  export type TerraformRootMode = "live" | "state";
34
47
 
@@ -99,7 +112,37 @@ export interface TerraformEntity extends Declarable {
99
112
  * quotes still exist. Empty when a caller built the entity by hand.
100
113
  */
101
114
  readonly source: string;
115
+ /**
116
+ * `count` or `for_each` when the block carries one (chant #2265). See
117
+ * {@link TerraformExpansion} for why one node still stands for the whole
118
+ * expansion.
119
+ */
120
+ readonly expansion?: TerraformExpansion;
102
121
  };
122
+ /**
123
+ * The deployable unit this entity belongs to: the root name, which is what
124
+ * one `terraform apply` runs against (chant #2266).
125
+ *
126
+ * Duplicates `props.root`, deliberately. `props` is what this lexicon's own
127
+ * checks and serializer read; `stack` is the lexicon-neutral field core's
128
+ * `buildGraphIr` reads to key `groups.byStack`, so a five-root project draws
129
+ * as five boundary boxes instead of one bucket named `terraform`. Core has
130
+ * no business reaching into a lexicon's `props` to find out, and this
131
+ * lexicon has no business knowing how the grouping is built, so the fact is
132
+ * said once in each vocabulary. A child module's blocks carry the calling
133
+ * ROOT's name, not the module's: the module is not separately applied.
134
+ */
135
+ readonly stack: string;
136
+ /**
137
+ * Every reference in this block, resolved to the entity keys it points at
138
+ * (chant #2265), in core's lexicon-neutral {@link EntityReference} shape.
139
+ *
140
+ * Absent until `./edges.ts` resolves it, which needs the whole root's entity
141
+ * set and so cannot happen inside the per-file parse below. An entity that
142
+ * never went through that pass carries none, which reads as "no references
143
+ * were resolved", never as "this block references nothing".
144
+ */
145
+ readonly references?: readonly EntityReference[];
103
146
  /**
104
147
  * `# chant-ignore`/`chant-ignore-file`/`chant-ignore-block` directives that
105
148
  * apply to this entity (chant #2111): the ones anchored to its own block,
@@ -126,6 +169,13 @@ export const LIVE_TYPE = "Terraform::Live";
126
169
  /** The sidecar filename choudoufu reads an estate declaration from when no in-block `live { }` is used. */
127
170
  export const LIVE_SIDECAR_FILENAME = "estate.chdf.hcl";
128
171
 
172
+ /** The expansion meta-argument a block body carries, if any. `count` wins if both are present (Terraform rejects that combination anyway). */
173
+ function expansionOf(body: BlockBody): TerraformExpansion | undefined {
174
+ if ("count" in body) return "count";
175
+ if ("for_each" in body) return "for_each";
176
+ return undefined;
177
+ }
178
+
129
179
  /**
130
180
  * Build one entity. Written out rather than run through `createResource`
131
181
  * from `@intentius/chant/runtime`: that factory is for generated resource
@@ -145,11 +195,13 @@ export function terraformEntity(
145
195
  line?: number,
146
196
  suppressions?: readonly SuppressionDirective[],
147
197
  ): TerraformEntity {
198
+ const expansion = expansionOf(body);
148
199
  return {
149
200
  [DECLARABLE_MARKER]: true,
150
201
  lexicon: "terraform",
151
202
  entityType,
152
203
  kind: "resource",
204
+ stack: root,
153
205
  props: {
154
206
  address,
155
207
  body,
@@ -157,6 +209,7 @@ export function terraformEntity(
157
209
  root,
158
210
  source,
159
211
  line,
212
+ ...(expansion ? { expansion } : {}),
160
213
  ...(extra?.mode !== undefined ? { mode: extra.mode } : {}),
161
214
  ...(extra?.estate !== undefined ? { estate: extra.estate } : {}),
162
215
  ...(extra?.workspace !== undefined ? { workspace: extra.workspace } : {}),
package/src/hcl/roots.ts CHANGED
@@ -18,6 +18,12 @@
18
18
  * local source is followed into its directory and parsed as a child scope of
19
19
  * the root (#2112). `./descend.ts` owns that walk and every refusal in it; the
20
20
  * refusals surface here as this render's warnings.
21
+ *
22
+ * One pass runs after every root has parsed: `./edges.ts` resolves each
23
+ * block's `${...}` references into the entity keys they name, so the graph IR
24
+ * has edges to draw (#2265). It runs here rather than inside the per-file
25
+ * parse because an edge's target may be a block in a file, or a child module,
26
+ * that has not been read yet.
21
27
  */
22
28
 
23
29
  import { existsSync } from "node:fs";
@@ -27,6 +33,7 @@ import type { Hcl2Json } from "@intentius/chant/terraform/parse";
27
33
  import type { TerraformRootConfig } from "../config";
28
34
  import { LIVE_TYPE, parseTerraformRootDir } from "./parse";
29
35
  import { descendModules, resolveCallModuleType, type CallModuleType } from "./descend";
36
+ import { resolveEntityReferences } from "./edges";
30
37
 
31
38
  export interface TerraformRootsResult {
32
39
  entities: Map<string, Declarable>;
@@ -117,5 +124,12 @@ export async function renderTerraformRoots(
117
124
  }
118
125
  }
119
126
 
127
+ // Every root and every descended child module is parsed by now, which is
128
+ // what reference resolution needs: an edge points at an entity key, and the
129
+ // keys only all exist once the last descent has run (#2265). One pass over
130
+ // the whole map, one expression cache, resolution still bounded to each
131
+ // block's own module scope.
132
+ await resolveEntityReferences(entities, opts.hcl2json);
133
+
120
134
  return { entities, warnings };
121
135
  }
package/src/index.ts CHANGED
@@ -66,6 +66,7 @@ export {
66
66
  renderAdoptionLedger,
67
67
  type AdoptionCandidate,
68
68
  type AdoptionLedger,
69
+ type AdoptionMatch,
69
70
  } from "./op/adoption";
70
71
 
71
72
  // The Init/Plan/Gate/Apply composite (#2086).