@intentius/chant-lexicon-terraform 0.124.31 → 0.124.32

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 (141) hide show
  1. package/dist/__fixtures__/terragrunt/affected-cases.js +111 -0
  2. package/dist/__fixtures__/terragrunt/record.js +57 -0
  3. package/dist/behaviour/index.js +6 -0
  4. package/dist/behaviour/kinds.js +194 -0
  5. package/dist/behaviour/predict.js +297 -0
  6. package/dist/behaviour/reference-catalog.js +178 -0
  7. package/dist/behaviour/request.js +482 -0
  8. package/dist/change-set.js +234 -0
  9. package/dist/changed-roots.js +97 -0
  10. package/dist/codegen/docs-cli.js +3 -0
  11. package/dist/codegen/docs.js +44 -0
  12. package/dist/codegen/generate-cli.js +9 -0
  13. package/dist/codegen/generate.js +59 -0
  14. package/dist/codegen/package.js +43 -0
  15. package/dist/commands.js +106 -0
  16. package/dist/components/capability-plugin.d.ts +38 -2
  17. package/dist/components/capability-plugin.d.ts.map +1 -1
  18. package/dist/components/capability-plugin.js +260 -0
  19. package/dist/composites/terraform-adopt-op.js +152 -0
  20. package/dist/composites/terraform-apply-op.js +183 -0
  21. package/dist/composites/terraform-pin-rollout-op.js +45 -0
  22. package/dist/composites/terraform-watch-op.js +219 -0
  23. package/dist/config.js +118 -0
  24. package/dist/describe-resources.js +984 -0
  25. package/dist/generated/index.js +1 -0
  26. package/dist/hcl/descend.js +208 -0
  27. package/dist/hcl/edges.js +287 -0
  28. package/dist/hcl/jsonencode.js +442 -0
  29. package/dist/hcl/marker.js +212 -0
  30. package/dist/hcl/parse.js +372 -0
  31. package/dist/hcl/references.js +156 -0
  32. package/dist/hcl/roots.js +98 -0
  33. package/dist/hcl/suppressions.js +189 -0
  34. package/dist/hcl/value.js +72 -0
  35. package/dist/index.d.ts +1 -1
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +46 -0
  38. package/dist/integrity.json +2 -2
  39. package/dist/lint/audit-catalog.js +501 -0
  40. package/dist/lint/audit-lineage.js +442 -0
  41. package/dist/lint/post-synth/blocks.js +87 -0
  42. package/dist/lint/post-synth/fixtures/load.js +94 -0
  43. package/dist/lint/post-synth/flat-attributes.js +135 -0
  44. package/dist/lint/post-synth/index.js +90 -0
  45. package/dist/lint/post-synth/module-source.js +153 -0
  46. package/dist/lint/post-synth/scope.js +104 -0
  47. package/dist/lint/post-synth/terragrunt.js +63 -0
  48. package/dist/lint/post-synth/tf001.js +138 -0
  49. package/dist/lint/post-synth/tf002.js +143 -0
  50. package/dist/lint/post-synth/tf003.js +60 -0
  51. package/dist/lint/post-synth/tf004.js +54 -0
  52. package/dist/lint/post-synth/tf005.js +68 -0
  53. package/dist/lint/post-synth/tf006.js +43 -0
  54. package/dist/lint/post-synth/tf007.js +66 -0
  55. package/dist/lint/post-synth/tf008.js +64 -0
  56. package/dist/lint/post-synth/tf009.js +49 -0
  57. package/dist/lint/post-synth/tf010.js +40 -0
  58. package/dist/lint/post-synth/tf011.js +38 -0
  59. package/dist/lint/post-synth/tf012.js +36 -0
  60. package/dist/lint/post-synth/tf013.js +53 -0
  61. package/dist/lint/post-synth/tf014.js +61 -0
  62. package/dist/lint/post-synth/tf015.js +62 -0
  63. package/dist/lint/post-synth/tf016.js +66 -0
  64. package/dist/lint/post-synth/tf017.js +39 -0
  65. package/dist/lint/post-synth/tf018.js +63 -0
  66. package/dist/lint/post-synth/tf019.js +60 -0
  67. package/dist/lint/post-synth/tf020.js +103 -0
  68. package/dist/lint/post-synth/tf021.js +117 -0
  69. package/dist/lint/post-synth/tf022.js +51 -0
  70. package/dist/lint/post-synth/tf024.js +62 -0
  71. package/dist/lint/post-synth/tf025.js +83 -0
  72. package/dist/lint/post-synth/tf026.js +74 -0
  73. package/dist/lint/post-synth/tf027.js +66 -0
  74. package/dist/lint/post-synth/tf028.js +83 -0
  75. package/dist/lint/post-synth/tf029.js +73 -0
  76. package/dist/lint/post-synth/tf030.js +253 -0
  77. package/dist/lint/post-synth/tf031.js +332 -0
  78. package/dist/lint/post-synth/tf032.js +201 -0
  79. package/dist/lint/post-synth/tf033.js +126 -0
  80. package/dist/lint/post-synth/tf034.js +45 -0
  81. package/dist/lint/post-synth/tf035.js +63 -0
  82. package/dist/lint/post-synth/tf036.js +97 -0
  83. package/dist/lint/post-synth/tf037.js +80 -0
  84. package/dist/lint/post-synth/tf038.js +63 -0
  85. package/dist/lint/post-synth/tf039.js +55 -0
  86. package/dist/lint/post-synth/tf040.js +67 -0
  87. package/dist/lint/post-synth/tf041.js +53 -0
  88. package/dist/lint/post-synth/tf042.js +38 -0
  89. package/dist/lint/post-synth/tf043.js +45 -0
  90. package/dist/lint/post-synth/tf044.js +45 -0
  91. package/dist/lint/post-synth/tf045.js +44 -0
  92. package/dist/lint/rules/fixtures/load.js +50 -0
  93. package/dist/lint/rules/index.js +4 -0
  94. package/dist/lint/rules/plan-before-apply.js +136 -0
  95. package/dist/lint/secret-shape.js +149 -0
  96. package/dist/live-ownership.js +57 -0
  97. package/dist/lsp/completions.js +95 -0
  98. package/dist/lsp/context.js +358 -0
  99. package/dist/lsp/hover.js +45 -0
  100. package/dist/lsp/option-keys.js +93 -0
  101. package/dist/manifest.json +1 -1
  102. package/dist/op/activities/index.js +30 -0
  103. package/dist/op/activities/live-detect.js +143 -0
  104. package/dist/op/activities/pin-rollout.js +45 -0
  105. package/dist/op/activities/terraform.js +1270 -0
  106. package/dist/op/activity-contracts.js +162 -0
  107. package/dist/op/adoption.js +272 -0
  108. package/dist/op/builders.js +125 -0
  109. package/dist/op/resolve-root-mode.js +77 -0
  110. package/dist/package-cli.js +16 -0
  111. package/dist/pin/edit-ts.js +76 -0
  112. package/dist/pin/edit.js +118 -0
  113. package/dist/pin/forge.js +65 -0
  114. package/dist/pin/index.js +16 -0
  115. package/dist/pin/rollout.d.ts.map +1 -1
  116. package/dist/pin/rollout.js +382 -0
  117. package/dist/pin/scan.js +287 -0
  118. package/dist/pin/source.js +171 -0
  119. package/dist/pin/waves.js +109 -0
  120. package/dist/plan-digest.js +67 -0
  121. package/dist/plugin.js +323 -0
  122. package/dist/serializer.js +23 -0
  123. package/dist/state-ownership.js +29 -0
  124. package/dist/terragrunt/affected.d.ts.map +1 -1
  125. package/dist/terragrunt/affected.js +290 -0
  126. package/dist/terragrunt/index.js +10 -0
  127. package/dist/terragrunt/mocks.js +270 -0
  128. package/dist/terragrunt/run.js +264 -0
  129. package/dist/terragrunt/units.js +195 -0
  130. package/dist/terragrunt/wave.js +125 -0
  131. package/dist/validate-cli.js +4 -0
  132. package/dist/validate.js +31 -0
  133. package/package.json +10 -10
  134. package/src/codegen/package.ts +2 -2
  135. package/src/components/capability-plugin.test.ts +97 -2
  136. package/src/components/capability-plugin.ts +117 -12
  137. package/src/components/pr-loop.tofu.test.ts +51 -11
  138. package/src/index.ts +1 -0
  139. package/src/pin/rollout.ts +2 -1
  140. package/src/terragrunt/affected.test.ts +11 -0
  141. package/src/terragrunt/affected.ts +2 -1
@@ -0,0 +1,372 @@
1
+ /**
2
+ * The one shared HCL parse for the terraform lexicon.
3
+ *
4
+ * Two entry points, one entity shape. `parseTerraformRootDir` reads the `.tf`
5
+ * files of a directory (non-recursive, matching Terraform's own module
6
+ * scoping) and is what `buildRoots()` calls. `parseTerraformRootContent` takes
7
+ * the joined-file string `chant audit` hands a lexicon for a discovered root
8
+ * module: every `.tf` in filename order behind a `# file: <name>` line comment.
9
+ * Both funnel through `blocksToEntities`, so an entity the audit path sees is
10
+ * the entity the build path sees.
11
+ *
12
+ * Core owns the parser glue: `loadHcl2json()` lazy-loads `@cdktn/hcl2json`
13
+ * (a ~1.8 MB wasm blob) and raises a one-line install hint when it is absent.
14
+ * `parseTerraformDir()` next to it is NOT reused: it returns carve's `TfGraph`,
15
+ * a scoring-and-excision shape with no room for the per-block bodies a
16
+ * serializer and the post-synth checks read. The import path is the wildcard
17
+ * core's exports map already carries (`"./*"` to `./src/*.ts`), so nothing
18
+ * changed in `packages/core/package.json` for this.
19
+ */
20
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
21
+ import { join } from "node:path";
22
+ import { loadHcl2json } from "@intentius/chant/terraform/hcl2json";
23
+ import { DECLARABLE_MARKER } from "@intentius/chant/declarable";
24
+ import { scanSuppressions, directivesFor } from "./suppressions.js";
25
+ /** `entityType` per block kind. */
26
+ export const TERRAFORM_TYPE = "Terraform::Terraform";
27
+ export const PROVIDER_TYPE = "Terraform::Provider";
28
+ export const RESOURCE_TYPE = "Terraform::Resource";
29
+ export const DATA_TYPE = "Terraform::Data";
30
+ export const MODULE_TYPE = "Terraform::Module";
31
+ export const VARIABLE_TYPE = "Terraform::Variable";
32
+ export const OUTPUT_TYPE = "Terraform::Output";
33
+ export const LOCALS_TYPE = "Terraform::Locals";
34
+ /** A `live { estate = "..." }` block, or an `estate.chdf.hcl` sidecar's content (#2103). */
35
+ export const LIVE_TYPE = "Terraform::Live";
36
+ /**
37
+ * Terragrunt config entities (#3417). A `terragrunt.hcl` or `root.hcl` is not
38
+ * a Terraform module, so its blocks are read into these types and never into
39
+ * `TERRAFORM_TYPE`: TF001's `required_version` check and the rest of the
40
+ * module rules keep to the `.tf` files.
41
+ */
42
+ export const TERRAGRUNT_CONFIG_TYPE = "Terraform::TerragruntConfig";
43
+ export const TERRAGRUNT_SOURCE_TYPE = "Terraform::TerragruntSource";
44
+ export const TERRAGRUNT_DEPENDENCY_TYPE = "Terraform::TerragruntDependency";
45
+ export const TERRAGRUNT_REMOTE_STATE_TYPE = "Terraform::TerragruntRemoteState";
46
+ export const TERRAGRUNT_GENERATE_TYPE = "Terraform::TerragruntGenerate";
47
+ /** The Terragrunt config files read, by their default names. A `--config` rename is not followed. */
48
+ export const TERRAGRUNT_FILENAMES = ["root.hcl", "terragrunt.hcl"];
49
+ /** Whether `name` (a bare filename) is a Terragrunt config file this lexicon reads. */
50
+ export function isTerragruntFileName(name) {
51
+ return TERRAGRUNT_FILENAMES.includes(name);
52
+ }
53
+ /** The sidecar filename choudoufu reads an estate declaration from when no in-block `live { }` is used. */
54
+ export const LIVE_SIDECAR_FILENAME = "estate.chdf.hcl";
55
+ /** The expansion meta-argument a block body carries, if any. `count` wins if both are present (Terraform rejects that combination anyway). */
56
+ function expansionOf(body) {
57
+ if ("count" in body)
58
+ return "count";
59
+ if ("for_each" in body)
60
+ return "for_each";
61
+ return undefined;
62
+ }
63
+ /**
64
+ * Build one entity. Written out rather than run through `createResource`
65
+ * from `@intentius/chant/runtime`: that factory is for generated resource
66
+ * classes and hides `props` behind a non-enumerable descriptor plus an
67
+ * `attrMap` of `AttrRef`s this lexicon has nothing to put in. The literal
68
+ * carries the same marker, so `isDeclarable()` and `isResourceDeclarable()`
69
+ * both hold.
70
+ */
71
+ export function terraformEntity(entityType, address, body, file, root, extra, source = "", line, suppressions) {
72
+ const expansion = expansionOf(body);
73
+ return {
74
+ [DECLARABLE_MARKER]: true,
75
+ lexicon: "terraform",
76
+ entityType,
77
+ kind: "resource",
78
+ stack: root,
79
+ props: {
80
+ address,
81
+ body,
82
+ file,
83
+ root,
84
+ source,
85
+ line,
86
+ ...(expansion ? { expansion } : {}),
87
+ ...(extra?.mode !== undefined ? { mode: extra.mode } : {}),
88
+ ...(extra?.estate !== undefined ? { estate: extra.estate } : {}),
89
+ ...(extra?.workspace !== undefined ? { workspace: extra.workspace } : {}),
90
+ ...(extra?.delete !== undefined ? { delete: extra.delete } : {}),
91
+ ...(extra?.configEstate !== undefined ? { configEstate: extra.configEstate } : {}),
92
+ ...(extra?.callers !== undefined && extra.callers.length > 0 ? { callers: extra.callers } : {}),
93
+ },
94
+ suppressions,
95
+ };
96
+ }
97
+ /** The `# file: <name>` boundary `chant audit` writes between joined `.tf` files. */
98
+ const FILE_MARKER = /^# file: (.+)$/;
99
+ /**
100
+ * Split the joined form back into files. A string with no marker at all is one
101
+ * anonymous file, so a caller that hands over a single `.tf` still parses.
102
+ */
103
+ export function splitBundleContent(content, fallbackName = "main.tf") {
104
+ const files = [];
105
+ let current;
106
+ for (const line of content.split("\n")) {
107
+ const marker = FILE_MARKER.exec(line);
108
+ if (marker) {
109
+ current = { name: marker[1].trim(), source: "" };
110
+ files.push(current);
111
+ continue;
112
+ }
113
+ if (!current) {
114
+ current = { name: fallbackName, source: "" };
115
+ files.push(current);
116
+ }
117
+ current.source += current.source === "" ? line : `\n${line}`;
118
+ }
119
+ return files.filter((f) => f.source.trim() !== "");
120
+ }
121
+ /** Every `.tf` directly under `dir`, in filename order. Non-recursive. */
122
+ export function listTerraformFiles(dir) {
123
+ return readdirSync(dir)
124
+ .filter((f) => f.endsWith(".tf"))
125
+ .sort();
126
+ }
127
+ /** The Terragrunt config files directly under `dir` (`root.hcl`, `terragrunt.hcl`), in filename order like the audit's bundle. */
128
+ export function listTerragruntFiles(dir) {
129
+ return TERRAGRUNT_FILENAMES.filter((name) => existsSync(join(dir, name)));
130
+ }
131
+ /**
132
+ * Read the `estate.chdf.hcl` sidecar beside `dir`'s `.tf` files, if present.
133
+ * Sibling to {@link listTerraformFiles}: the sidecar is plain HCL (a bare
134
+ * `estate = "..."` attribute, no wrapper block) and is never a `.tf` file, so
135
+ * it falls outside that glob and needs its own read.
136
+ */
137
+ export function readLiveSidecarFile(dir) {
138
+ const path = join(dir, LIVE_SIDECAR_FILENAME);
139
+ if (!existsSync(path))
140
+ return undefined;
141
+ return { name: LIVE_SIDECAR_FILENAME, source: readFileSync(path, "utf-8") };
142
+ }
143
+ function asArray(value) {
144
+ return Array.isArray(value) ? value : [];
145
+ }
146
+ function asRecord(value) {
147
+ return typeof value === "object" && value !== null && !Array.isArray(value)
148
+ ? value
149
+ : {};
150
+ }
151
+ /** Every `live { ... }` block nested inside every `terraform { ... }` block of `tree`. */
152
+ function liveBlocksIn(tree) {
153
+ const out = [];
154
+ for (const tfBody of asArray(tree["terraform"])) {
155
+ const body = asRecord(tfBody);
156
+ for (const liveBody of asArray(body["live"]))
157
+ out.push(asRecord(liveBody));
158
+ }
159
+ return out;
160
+ }
161
+ /** The lock file `terraform init` writes beside a root's `.tf` files. */
162
+ export const LOCK_FILENAME = ".terraform.lock.hcl";
163
+ /**
164
+ * Parse a set of files into entities keyed `<root>/<address>`. Two blocks that
165
+ * genuinely share an address (two `locals` blocks, or the same address in two
166
+ * files of one root) are numbered `~2`, `~3` rather than overwriting.
167
+ *
168
+ * Recognises both ways an estate is declared: a `live { }` block nested in a
169
+ * `terraform { }` block of any file here, and, when `sidecar` is given, the
170
+ * `estate.chdf.hcl` sidecar's bare `estate = "..."` attribute. Either becomes
171
+ * a {@link LIVE_TYPE} entity, and the root is live (`mode: "live"`) exactly
172
+ * when `modeOptions.binary` is `"choudoufu"` and one of the two named an
173
+ * estate; that verdict, and the estate name when live, is then stamped onto
174
+ * every entity this call returns, sidecar and in-block declaration alike so
175
+ * a post-synth check or `describeResources()` reads it without re-parsing.
176
+ * When both forms are present the sidecar wins, matching choudoufu's own
177
+ * "the sidecar is the leading one" (choudoufu refuses the combination
178
+ * outright; this lexicon does not police that here, see TF024/TF025 for
179
+ * what it does police on a live root).
180
+ *
181
+ * `callers` is the module-call chain these files were reached through
182
+ * (chant #2112), empty for a root module. When it is non-empty the keys
183
+ * become `<root>/module.<name>/<address>` (one segment per caller) and every
184
+ * entity carries the chain on `props.callers`. `./descend.ts` is what fills
185
+ * it in; nothing else should pass it.
186
+ */
187
+ export async function blocksToEntities(files, root, hcl2json, modeOptions, sidecar, callers = []) {
188
+ const parser = hcl2json ?? (await loadHcl2json());
189
+ const entities = new Map();
190
+ const prefix = callers.length > 0 ? `${root}/${callers.join("/")}` : root;
191
+ const add = (entityType, address, body, file, scan, scanAddress = address) => {
192
+ const { line, suppressions } = directivesFor(scan, scanAddress);
193
+ const entity = terraformEntity(entityType, address, (typeof body === "object" && body !== null ? body : {}), file.name, root, {
194
+ ...(modeOptions?.workspace !== undefined ? { workspace: modeOptions.workspace } : {}),
195
+ ...(modeOptions?.delete !== undefined ? { delete: modeOptions.delete } : {}),
196
+ ...(modeOptions?.configEstate !== undefined ? { configEstate: modeOptions.configEstate } : {}),
197
+ ...(callers.length > 0 ? { callers } : {}),
198
+ }, file.source, line, suppressions);
199
+ let key = `${prefix}/${address}`;
200
+ for (let n = 2; entities.has(key); n++)
201
+ key = `${prefix}/${address}~${n}`;
202
+ entities.set(key, entity);
203
+ };
204
+ /** `terraform` and `locals` carry no labels: the tree holds a bare body array. */
205
+ const unlabelled = (tree, section, entityType, file, scan) => {
206
+ for (const body of asArray(tree[section]))
207
+ add(entityType, section, body, file, scan);
208
+ };
209
+ /** `provider`, `module`, `variable`, `output`: one label, so `Record<name, body[]>`. */
210
+ const oneLabel = (tree, section, entityType, address, file, scan) => {
211
+ for (const [name, bodies] of Object.entries(asRecord(tree[section]))) {
212
+ for (const body of asArray(bodies))
213
+ add(entityType, address(name), body, file, scan);
214
+ }
215
+ };
216
+ /** `resource`, `data`: two labels, so `Record<type, Record<name, body[]>>`. */
217
+ const twoLabels = (tree, section, entityType, address, file, scan) => {
218
+ for (const [type, named] of Object.entries(asRecord(tree[section]))) {
219
+ for (const [name, bodies] of Object.entries(asRecord(named))) {
220
+ for (const body of asArray(bodies))
221
+ add(entityType, address(type, name), body, file, scan);
222
+ }
223
+ }
224
+ };
225
+ for (const file of files) {
226
+ const tree = (await parser.parse(file.name, file.source));
227
+ const scan = scanSuppressions(file.name, file.source);
228
+ if (isTerragruntFileName(file.name)) {
229
+ terragruntBlocks(tree, file, scan, add);
230
+ continue;
231
+ }
232
+ unlabelled(tree, "terraform", TERRAFORM_TYPE, file, scan);
233
+ for (const liveBody of liveBlocksIn(tree))
234
+ add(LIVE_TYPE, "live", liveBody, file, scan);
235
+ unlabelled(tree, "locals", LOCALS_TYPE, file, scan);
236
+ oneLabel(tree, "provider", PROVIDER_TYPE, (n) => `provider.${n}`, file, scan);
237
+ oneLabel(tree, "module", MODULE_TYPE, (n) => `module.${n}`, file, scan);
238
+ oneLabel(tree, "variable", VARIABLE_TYPE, (n) => `var.${n}`, file, scan);
239
+ oneLabel(tree, "output", OUTPUT_TYPE, (n) => `output.${n}`, file, scan);
240
+ twoLabels(tree, "resource", RESOURCE_TYPE, (t, n) => `${t}.${n}`, file, scan);
241
+ twoLabels(tree, "data", DATA_TYPE, (t, n) => `data.${t}.${n}`, file, scan);
242
+ }
243
+ if (sidecar) {
244
+ const tree = (await parser.parse(sidecar.name, sidecar.source));
245
+ if (typeof tree["estate"] === "string")
246
+ add(LIVE_TYPE, "live", tree, sidecar, scanSuppressions(sidecar.name, sidecar.source));
247
+ }
248
+ // Which of the (at most two) Live entities names the estate, preferring the
249
+ // sidecar over an in-block declaration when both are present.
250
+ let estate;
251
+ for (const entity of entities.values()) {
252
+ if (entity.entityType !== LIVE_TYPE)
253
+ continue;
254
+ const props = entity.props;
255
+ const name = typeof props.body["estate"] === "string" ? props.body["estate"] : undefined;
256
+ if (name === undefined)
257
+ continue;
258
+ if (estate === undefined || props.file === LIVE_SIDECAR_FILENAME)
259
+ estate = name;
260
+ }
261
+ const mode = modeOptions?.binary === "choudoufu" && estate !== undefined ? "live" : "state";
262
+ for (const [key, entity] of entities) {
263
+ const te = entity;
264
+ const stamped = {
265
+ ...te,
266
+ props: {
267
+ ...te.props,
268
+ mode,
269
+ ...(mode === "live" ? { estate } : {}),
270
+ ...(modeOptions?.lockFile !== undefined ? { lockFile: modeOptions.lockFile } : {}),
271
+ },
272
+ };
273
+ entities.set(key, stamped);
274
+ }
275
+ return entities;
276
+ }
277
+ /**
278
+ * One Terragrunt config file's blocks, as entities (#3417): the file's own
279
+ * settings (`terragrunt_version_constraint` and the `include` labels), the
280
+ * `terraform` block that names the unit's source, `remote_state`, each
281
+ * `dependency` and each `generate`. Everything else in the file (`inputs`,
282
+ * `locals`, `iam_role`) is left unread.
283
+ */
284
+ function terragruntBlocks(tree, file, scan, add) {
285
+ const config = { includes: Object.keys(asRecord(tree["include"])) };
286
+ if (typeof tree["terragrunt_version_constraint"] === "string") {
287
+ config["terragrunt_version_constraint"] = tree["terragrunt_version_constraint"];
288
+ }
289
+ add(TERRAGRUNT_CONFIG_TYPE, "terragrunt", config, file, scan, "");
290
+ for (const body of asArray(tree["terraform"]))
291
+ add(TERRAGRUNT_SOURCE_TYPE, "terragrunt.terraform", body, file, scan, "terraform");
292
+ for (const body of asArray(tree["remote_state"]))
293
+ add(TERRAGRUNT_REMOTE_STATE_TYPE, "remote_state", body, file, scan);
294
+ for (const [name, bodies] of Object.entries(asRecord(tree["dependency"]))) {
295
+ for (const body of asArray(bodies))
296
+ add(TERRAGRUNT_DEPENDENCY_TYPE, `dependency.${name}`, body, file, scan);
297
+ }
298
+ for (const [name, bodies] of Object.entries(asRecord(tree["generate"]))) {
299
+ for (const body of asArray(bodies))
300
+ add(TERRAGRUNT_GENERATE_TYPE, `generate.${name}`, body, file, scan);
301
+ }
302
+ }
303
+ /**
304
+ * Parse a root module directory. Reads every `.tf` directly under `dir`, plus
305
+ * the `estate.chdf.hcl` sidecar beside them when present. Throws whatever the
306
+ * parser throws for malformed HCL; `buildRoots()` is where that becomes a
307
+ * warning.
308
+ */
309
+ export async function parseTerraformRootDir(dir, root, hcl2json, modeOptions, callers = []) {
310
+ // A child module is a Terraform module, never a Terragrunt unit (#3417).
311
+ const names = [...listTerraformFiles(dir), ...(callers.length === 0 ? listTerragruntFiles(dir) : [])];
312
+ const files = names.map((name) => ({
313
+ name,
314
+ source: readFileSync(join(dir, name), "utf-8"),
315
+ }));
316
+ // A child module gets no sidecar read: `estate.chdf.hcl` declares the
317
+ // estate a ROOT runs against, and choudoufu reads it from the root's own
318
+ // directory only. See `./descend.ts`.
319
+ const sidecar = callers.length === 0 ? readLiveSidecarFile(dir) : undefined;
320
+ // Only the root's own directory carries a lock file that matters (#3190).
321
+ const opts = callers.length === 0 ? { ...modeOptions, lockFile: existsSync(join(dir, LOCK_FILENAME)) } : modeOptions;
322
+ return blocksToEntities(files, root, hcl2json, opts, sidecar, callers);
323
+ }
324
+ /**
325
+ * Parse the joined-file string form `chant audit` produces for a discovered
326
+ * root module (`AuditInput.content`). `auditEntities()` is what calls it; that
327
+ * hook's single-argument contract carries no project config, so `modeOptions`
328
+ * is left undefined there and every entity gets `mode: "state"`; the audit
329
+ * path does not detect live mode (#2103). `modeOptions` exists here mainly for
330
+ * tests exercising the mode-stamping behaviour directly against inline HCL,
331
+ * the same way `parseTerraformRootDir`'s callers exercise it against files.
332
+ */
333
+ export async function parseTerraformRootContent(content, root, hcl2json, modeOptions) {
334
+ return blocksToEntities(splitBundleContent(content), root, hcl2json, modeOptions);
335
+ }
336
+ /** The root name an audit of the audited directory itself parses under. */
337
+ export const AUDIT_ROOT_NAME = "audit-root";
338
+ /**
339
+ * The root name one `chant audit` input parses under (#2217).
340
+ *
341
+ * A build names its roots from `terraform.roots`; an audit has no such
342
+ * config, so the name comes from the input's own path, which is the one thing
343
+ * that tells two discovered roots apart. `envs/prod` becomes `envs.prod`,
344
+ * because an entity key is `<root>/<address>` with the module chain between
345
+ * them and a `/` inside the root name would read as a module scope. The
346
+ * audited directory itself keeps the name {@link AUDIT_ROOT_NAME}, so a
347
+ * single-root audit reads the way it did before an input path was available.
348
+ */
349
+ export function auditRootName(path) {
350
+ const trimmed = (path ?? "").replace(/^\.\//, "").replace(/\/+$/, "");
351
+ if (trimmed === "" || trimmed === ".")
352
+ return AUDIT_ROOT_NAME;
353
+ return trimmed.replace(/\//g, ".");
354
+ }
355
+ /**
356
+ * The module scope an entity key names: the part before the address.
357
+ *
358
+ * `"app/aws_s3_bucket.assets"` is scope `"app"` (the root module);
359
+ * `"app/module.cdn/aws_s3_bucket.assets"` is scope `"app/module.cdn"`. Keys
360
+ * are built here (see {@link blocksToEntities}), so the split lives here too:
361
+ * the reference index (`./references.ts`) and the module-scoped checks read
362
+ * scopes through it rather than re-deriving the key shape.
363
+ */
364
+ export function scopeOfKey(key) {
365
+ const slash = key.lastIndexOf("/");
366
+ return slash === -1 ? "" : key.slice(0, slash);
367
+ }
368
+ /** The `module.<name>` chain in an entity key, outermost first. Empty for a root-module block. */
369
+ export function callersOfKey(key) {
370
+ const parts = key.split("/");
371
+ return parts.length <= 2 ? [] : parts.slice(1, -1);
372
+ }
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Which declarations does a module scope actually reference (chant #2112)?
3
+ *
4
+ * One pass over every entity's body, collecting the reference forms Terraform
5
+ * writes as expressions: `var.<name>`, `local.<name>`, `data.<type>.<name>`,
6
+ * `module.<name>`, and the `provider = <type>.<alias>` / `providers = { <type>
7
+ * = <type>.<alias> }` meta-arguments. The result is keyed per module scope
8
+ * (`./parse.ts`'s `scopeOfKey`), because Terraform's own namespaces are: a
9
+ * `var.region` inside `module.cdn` is the child module's variable, never the
10
+ * root's, and a rule that pooled the two would call a genuinely unused root
11
+ * variable used.
12
+ *
13
+ * ## It is a string scan, and the rules built on it say so
14
+ *
15
+ * hcl2json gives back an expression as a STRING, with the `${...}` wrapper
16
+ * preserved: `count = length(var.subnets)` arrives as `"${length(var.subnets)}"`
17
+ * and `bucket = var.name` as `"${var.name}"`. There is no expression tree to
18
+ * walk, so this scans the strings with regexes. That reads a reference inside
19
+ * a comment-free body correctly and is deliberately generous at the edges: a
20
+ * name that appears inside a quoted literal (`description = "set var.region"`)
21
+ * counts as a reference, and a reference built dynamically
22
+ * (`lookup(local.by_env, terraform.workspace)` reaching a value nothing names
23
+ * directly) does not. Both directions are why TF020 is report-only and credits
24
+ * tflint's `terraform_unused_declarations` as `overlaps` rather than
25
+ * `equivalent`: it is the same question answered with a coarser instrument.
26
+ *
27
+ * The index records WHICH entity each reference came from, so a check can ask
28
+ * "is anything other than the declaration itself referring to this?". A
29
+ * `variable "port"` whose own `validation` block reads `var.port` is the case
30
+ * that needs it: without the exclusion every validated variable would look
31
+ * used.
32
+ */
33
+ import { isResourceDeclarable } from "@intentius/chant/declarable";
34
+ import { scopeOfKey } from "./parse.js";
35
+ /** An identifier as Terraform's grammar allows it: letters, digits, `_`, `-`. */
36
+ const NAME = "[A-Za-z_][A-Za-z0-9_-]*";
37
+ const VAR_RE = new RegExp(`\\bvar\\.(${NAME})`, "g");
38
+ const LOCAL_RE = new RegExp(`\\blocal\\.(${NAME})`, "g");
39
+ const DATA_RE = new RegExp(`\\bdata\\.(${NAME})\\.(${NAME})`, "g");
40
+ const MODULE_RE = new RegExp(`\\bmodule\\.(${NAME})`, "g");
41
+ /** A provider reference once any `${...}` wrapper is off: `aws.west`. */
42
+ const PROVIDER_REF_RE = new RegExp(`^(${NAME})\\.(${NAME})$`);
43
+ /** hcl2json's wrapper around a bare expression, which a `provider` value always is. */
44
+ const INTERPOLATION_RE = /^\$\{(.*)\}$/s;
45
+ /**
46
+ * Every reference token in one string, in the vocabulary the index keys by:
47
+ * `var.region`, `local.tags`, `data.aws_ami.ubuntu`, `module.cdn`.
48
+ *
49
+ * `data.` is matched before `local.`/`var.` can see it, and a `module.cdn.url`
50
+ * yields `module.cdn`, since the call is what a reference makes used.
51
+ */
52
+ export function collectReferences(text) {
53
+ const found = new Set();
54
+ for (const m of text.matchAll(DATA_RE))
55
+ found.add(`data.${m[1]}.${m[2]}`);
56
+ for (const m of text.matchAll(VAR_RE))
57
+ found.add(`var.${m[1]}`);
58
+ for (const m of text.matchAll(LOCAL_RE))
59
+ found.add(`local.${m[1]}`);
60
+ for (const m of text.matchAll(MODULE_RE))
61
+ found.add(`module.${m[1]}`);
62
+ return [...found];
63
+ }
64
+ /**
65
+ * The `provider`/`providers` meta-argument's own reference form, which is not
66
+ * an interpolation and has no prefix of its own: `provider = aws.west` names
67
+ * the `provider "aws" { alias = "west" }` block. Returned in the index's
68
+ * vocabulary, `provider.aws.west`, so it cannot collide with a resource
69
+ * address that happens to look the same.
70
+ */
71
+ export function providerReference(value) {
72
+ if (typeof value !== "string")
73
+ return undefined;
74
+ const trimmed = value.trim();
75
+ const inner = INTERPOLATION_RE.exec(trimmed);
76
+ const m = PROVIDER_REF_RE.exec((inner ? inner[1] : trimmed).trim());
77
+ return m ? `provider.${m[1]}.${m[2]}` : undefined;
78
+ }
79
+ /** Every reference in one block body, provider meta-arguments included. */
80
+ export function referencesInBody(body) {
81
+ const found = new Set();
82
+ const visit = (value, key, depth) => {
83
+ if (depth > 6)
84
+ return;
85
+ if (typeof value === "string") {
86
+ for (const ref of collectReferences(value))
87
+ found.add(ref);
88
+ if (key === "provider") {
89
+ const provider = providerReference(value);
90
+ if (provider)
91
+ found.add(provider);
92
+ }
93
+ return;
94
+ }
95
+ if (Array.isArray(value)) {
96
+ for (const item of value)
97
+ visit(item, key, depth + 1);
98
+ return;
99
+ }
100
+ if (typeof value === "object" && value !== null) {
101
+ for (const [k, v] of Object.entries(value)) {
102
+ // `providers = { aws = aws.west }` maps a child module's provider name
103
+ // to the caller's block, so every VALUE under `providers` is a
104
+ // provider reference, whatever its key.
105
+ visit(v, key === "providers" ? "provider" : k, depth + 1);
106
+ }
107
+ }
108
+ };
109
+ visit(body, "", 0);
110
+ return [...found];
111
+ }
112
+ /**
113
+ * Build the index over a whole build's entities. Scopes come from the entity
114
+ * keys, so a root and each of its descended child modules are separate, and
115
+ * two roots never pool.
116
+ */
117
+ export function buildReferenceIndex(entities) {
118
+ const scopes = new Map();
119
+ for (const [key, entity] of entities) {
120
+ if (!isResourceDeclarable(entity))
121
+ continue;
122
+ const props = entity.props;
123
+ const body = (typeof props.body === "object" && props.body !== null ? props.body : {});
124
+ const scope = scopeOfKey(key);
125
+ let refs = scopes.get(scope);
126
+ if (!refs) {
127
+ refs = new Map();
128
+ scopes.set(scope, refs);
129
+ }
130
+ for (const ref of referencesInBody(body)) {
131
+ const from = refs.get(ref);
132
+ if (from)
133
+ from.add(key);
134
+ else
135
+ refs.set(ref, new Set([key]));
136
+ }
137
+ }
138
+ return { scopes };
139
+ }
140
+ /** The entity keys in `scope` whose body references `token`. */
141
+ export function referencesTo(index, scope, token) {
142
+ return index.scopes.get(scope)?.get(token) ?? new Set();
143
+ }
144
+ /**
145
+ * Is `token` referenced anywhere in `scope` other than by `declaredBy` itself?
146
+ * The exclusion is what keeps a variable read only by its own `validation`
147
+ * block, or a local read only by its own `locals` block, from looking used.
148
+ */
149
+ export function isReferenced(index, scope, token, declaredBy) {
150
+ const from = referencesTo(index, scope, token);
151
+ for (const key of from) {
152
+ if (key !== declaredBy)
153
+ return true;
154
+ }
155
+ return false;
156
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Terraform build roots.
3
+ *
4
+ * An estate that keeps its `.tf` tree has no typed chant source for it. What
5
+ * it can declare is the directory: each entry in `terraform.roots` names a
6
+ * root module that parses at build time into entities, so the blocks are
7
+ * serialized into the build output and seen by the post-synth checks. Same
8
+ * shape `lexicons/k8s/src/kustomize/root.ts` uses for kustomize overlays: the
9
+ * render lives here, the plugin's `buildRoots()` member stays thin.
10
+ *
11
+ * Unlike the kustomize renderer, a missing directory or a `.tf` the parser
12
+ * refuses is a warning and zero entities for that root, never a throw. Reading
13
+ * someone else's estate is the whole job here, and half of it parsing is more
14
+ * useful than none of it, especially when the audit path (#2085) walks
15
+ * repositories it did not write.
16
+ *
17
+ * A root's own directory is not the end of the parse: a `module` block with a
18
+ * local source is followed into its directory and parsed as a child scope of
19
+ * the root (#2112). `./descend.ts` owns that walk and every refusal in it; the
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.
27
+ */
28
+ import { existsSync } from "node:fs";
29
+ import { isAbsolute, resolve } from "node:path";
30
+ import { isResourceDeclarable } from "@intentius/chant/declarable";
31
+ import { LIVE_TYPE, parseTerraformRootDir } from "./parse.js";
32
+ import { descendModules, resolveCallModuleType } from "./descend.js";
33
+ import { resolveEntityReferences } from "./edges.js";
34
+ /**
35
+ * Parse each configured root into entities keyed `<root>/<address>`, plus its
36
+ * local child modules, keyed `<root>/module.<name>/<address>` (#2112). Roots
37
+ * are visited in declaration order, and one root's failure never stops the
38
+ * next.
39
+ */
40
+ export async function renderTerraformRoots(opts) {
41
+ const entities = new Map();
42
+ const warnings = [];
43
+ // One verdict for the whole render: the mode is a project-level setting, so
44
+ // a refused `"all"` is said once rather than once per root (#2112).
45
+ const callModuleType = resolveCallModuleType(opts.callModuleType);
46
+ if (callModuleType.warning)
47
+ warnings.push(callModuleType.warning);
48
+ for (const [name, root] of Object.entries(opts.roots)) {
49
+ const dir = isAbsolute(root.dir) ? root.dir : resolve(opts.projectRoot, root.dir);
50
+ if (!existsSync(dir)) {
51
+ warnings.push(`terraform.roots.${name}: directory not found at ${dir}, no entities contributed`);
52
+ continue;
53
+ }
54
+ try {
55
+ const modeOptions = { binary: opts.binary, workspace: root.workspace, delete: root.delete, configEstate: root.estate };
56
+ const parsed = await parseTerraformRootDir(dir, name, opts.hcl2json, modeOptions);
57
+ for (const [key, entity] of parsed)
58
+ entities.set(key, entity);
59
+ // Local child modules, keyed `<root>/module.<name>/<address>` (#2112).
60
+ // Their refusals are warnings on the root that called them, since the
61
+ // call site is where a reader can act on one.
62
+ const descended = await descendModules(parsed, {
63
+ dir,
64
+ root: name,
65
+ projectRoot: opts.projectRoot,
66
+ ...(opts.moduleRoot !== undefined ? { moduleRoot: isAbsolute(opts.moduleRoot) ? opts.moduleRoot : resolve(opts.projectRoot, opts.moduleRoot) } : {}),
67
+ callModuleType: callModuleType.effective,
68
+ hcl2json: opts.hcl2json,
69
+ modeOptions,
70
+ });
71
+ for (const [key, entity] of descended.entities)
72
+ entities.set(key, entity);
73
+ warnings.push(...descended.warnings);
74
+ // A `live` block or `estate.chdf.hcl` sidecar only takes effect under
75
+ // choudoufu; under any other binary it parses fine but is inert, which
76
+ // is worth a warning rather than silence (#2103).
77
+ for (const entity of parsed.values()) {
78
+ if (entity.entityType !== LIVE_TYPE || !isResourceDeclarable(entity))
79
+ continue;
80
+ if (entity.props.mode === "state") {
81
+ warnings.push(`terraform.roots.${name}: declares a live estate (a \`live\` block or ${JSON.stringify("estate.chdf.hcl")} sidecar), but terraform.binary is ${JSON.stringify(opts.binary ?? "terraform")}, not "choudoufu", so the live declaration is inert.`);
82
+ }
83
+ break;
84
+ }
85
+ }
86
+ catch (err) {
87
+ const message = err instanceof Error ? err.message : String(err);
88
+ warnings.push(`terraform.roots.${name}: could not parse ${dir}, ${message}`);
89
+ }
90
+ }
91
+ // Every root and every descended child module is parsed by now, which is
92
+ // what reference resolution needs: an edge points at an entity key, and the
93
+ // keys only all exist once the last descent has run (#2265). One pass over
94
+ // the whole map, one expression cache, resolution still bounded to each
95
+ // block's own module scope.
96
+ await resolveEntityReferences(entities, opts.hcl2json);
97
+ return { entities, warnings };
98
+ }