@intentius/chant-lexicon-terraform 0.102.0 → 0.104.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 (270) hide show
  1. package/dist/__fixtures__/terragrunt/affected-cases.d.ts +23 -0
  2. package/dist/__fixtures__/terragrunt/affected-cases.d.ts.map +1 -0
  3. package/dist/__fixtures__/terragrunt/record.d.ts +17 -0
  4. package/dist/__fixtures__/terragrunt/record.d.ts.map +1 -0
  5. package/dist/change-set.d.ts +67 -0
  6. package/dist/change-set.d.ts.map +1 -0
  7. package/dist/changed-roots.d.ts +30 -0
  8. package/dist/changed-roots.d.ts.map +1 -0
  9. package/dist/commands.d.ts +26 -0
  10. package/dist/commands.d.ts.map +1 -0
  11. package/dist/components/capability-plugin.d.ts +54 -0
  12. package/dist/components/capability-plugin.d.ts.map +1 -0
  13. package/dist/composites/terraform-pin-rollout-op.d.ts +44 -0
  14. package/dist/composites/terraform-pin-rollout-op.d.ts.map +1 -0
  15. package/dist/hcl/descend.d.ts +1 -1
  16. package/dist/hcl/descend.d.ts.map +1 -1
  17. package/dist/hcl/edges.d.ts +2 -2
  18. package/dist/hcl/edges.d.ts.map +1 -1
  19. package/dist/hcl/parse.d.ts +29 -1
  20. package/dist/hcl/parse.d.ts.map +1 -1
  21. package/dist/hcl/roots.d.ts +1 -1
  22. package/dist/hcl/roots.d.ts.map +1 -1
  23. package/dist/hcl/suppressions.d.ts.map +1 -1
  24. package/dist/index.d.ts +4 -1
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/integrity.json +13 -4
  27. package/dist/lint/audit-catalog.d.ts.map +1 -1
  28. package/dist/lint/post-synth/index.d.ts.map +1 -1
  29. package/dist/lint/post-synth/module-source.d.ts +27 -1
  30. package/dist/lint/post-synth/module-source.d.ts.map +1 -1
  31. package/dist/lint/post-synth/terragrunt.d.ts +37 -0
  32. package/dist/lint/post-synth/terragrunt.d.ts.map +1 -0
  33. package/dist/lint/post-synth/tf002.d.ts +3 -1
  34. package/dist/lint/post-synth/tf002.d.ts.map +1 -1
  35. package/dist/lint/post-synth/tf038.d.ts +22 -0
  36. package/dist/lint/post-synth/tf038.d.ts.map +1 -0
  37. package/dist/lint/post-synth/tf039.d.ts +23 -0
  38. package/dist/lint/post-synth/tf039.d.ts.map +1 -0
  39. package/dist/lint/post-synth/tf040.d.ts +30 -0
  40. package/dist/lint/post-synth/tf040.d.ts.map +1 -0
  41. package/dist/lint/post-synth/tf041.d.ts +20 -0
  42. package/dist/lint/post-synth/tf041.d.ts.map +1 -0
  43. package/dist/lint/post-synth/tf042.d.ts +18 -0
  44. package/dist/lint/post-synth/tf042.d.ts.map +1 -0
  45. package/dist/lint/post-synth/tf043.d.ts +17 -0
  46. package/dist/lint/post-synth/tf043.d.ts.map +1 -0
  47. package/dist/lint/post-synth/tf044.d.ts +17 -0
  48. package/dist/lint/post-synth/tf044.d.ts.map +1 -0
  49. package/dist/lint/post-synth/tf045.d.ts +22 -0
  50. package/dist/lint/post-synth/tf045.d.ts.map +1 -0
  51. package/dist/manifest.json +1 -1
  52. package/dist/okf/index.md +8 -0
  53. package/dist/okf/rules/TF038.md +11 -0
  54. package/dist/okf/rules/TF039.md +11 -0
  55. package/dist/okf/rules/TF040.md +11 -0
  56. package/dist/okf/rules/TF041.md +11 -0
  57. package/dist/okf/rules/TF042.md +11 -0
  58. package/dist/okf/rules/TF043.md +11 -0
  59. package/dist/okf/rules/TF044.md +11 -0
  60. package/dist/okf/rules/TF045.md +11 -0
  61. package/dist/op/activities/index.d.ts +4 -0
  62. package/dist/op/activities/index.d.ts.map +1 -1
  63. package/dist/op/activities/pin-rollout.d.ts +46 -0
  64. package/dist/op/activities/pin-rollout.d.ts.map +1 -0
  65. package/dist/op/activities/terraform.d.ts +24 -50
  66. package/dist/op/activities/terraform.d.ts.map +1 -1
  67. package/dist/op/activity-contracts.d.ts +48 -0
  68. package/dist/op/activity-contracts.d.ts.map +1 -1
  69. package/dist/op/builders.d.ts +9 -0
  70. package/dist/op/builders.d.ts.map +1 -1
  71. package/dist/pin/edit-ts.d.ts +21 -0
  72. package/dist/pin/edit-ts.d.ts.map +1 -0
  73. package/dist/pin/edit.d.ts +52 -0
  74. package/dist/pin/edit.d.ts.map +1 -0
  75. package/dist/pin/forge.d.ts +39 -0
  76. package/dist/pin/forge.d.ts.map +1 -0
  77. package/dist/pin/index.d.ts +22 -0
  78. package/dist/pin/index.d.ts.map +1 -0
  79. package/dist/pin/rollout.d.ts +116 -0
  80. package/dist/pin/rollout.d.ts.map +1 -0
  81. package/dist/pin/scan.d.ts +54 -0
  82. package/dist/pin/scan.d.ts.map +1 -0
  83. package/dist/pin/source.d.ts +93 -0
  84. package/dist/pin/source.d.ts.map +1 -0
  85. package/dist/pin/waves.d.ts +66 -0
  86. package/dist/pin/waves.d.ts.map +1 -0
  87. package/dist/plan-digest.d.ts +59 -0
  88. package/dist/plan-digest.d.ts.map +1 -0
  89. package/dist/plugin.d.ts.map +1 -1
  90. package/dist/rules/module-source.ts +56 -1
  91. package/dist/rules/terragrunt.ts +76 -0
  92. package/dist/rules/tf002.ts +1 -1
  93. package/dist/rules/tf038.ts +71 -0
  94. package/dist/rules/tf039.ts +64 -0
  95. package/dist/rules/tf040.ts +76 -0
  96. package/dist/rules/tf041.ts +56 -0
  97. package/dist/rules/tf042.ts +42 -0
  98. package/dist/rules/tf043.ts +49 -0
  99. package/dist/rules/tf044.ts +51 -0
  100. package/dist/rules/tf045.ts +47 -0
  101. package/dist/terragrunt/affected.d.ts +119 -0
  102. package/dist/terragrunt/affected.d.ts.map +1 -0
  103. package/dist/terragrunt/index.d.ts +11 -0
  104. package/dist/terragrunt/index.d.ts.map +1 -0
  105. package/dist/terragrunt/mocks.d.ts +147 -0
  106. package/dist/terragrunt/mocks.d.ts.map +1 -0
  107. package/dist/terragrunt/run.d.ts +130 -0
  108. package/dist/terragrunt/run.d.ts.map +1 -0
  109. package/dist/terragrunt/units.d.ts +103 -0
  110. package/dist/terragrunt/units.d.ts.map +1 -0
  111. package/dist/terragrunt/wave.d.ts +89 -0
  112. package/dist/terragrunt/wave.d.ts.map +1 -0
  113. package/package.json +12 -2
  114. package/src/__fixtures__/change-set/choudoufu-set-plan.change-set.golden.json +95 -0
  115. package/src/__fixtures__/change-set/choudoufu-set-plan.json +76 -0
  116. package/src/__fixtures__/change-set/choudoufu.part.golden.json +173 -0
  117. package/src/__fixtures__/change-set/choudoufu.plan.json +610 -0
  118. package/src/__fixtures__/change-set/terraform.import-forget.plan.json +81 -0
  119. package/src/__fixtures__/change-set/terraform.part.golden.json +173 -0
  120. package/src/__fixtures__/change-set/terraform.plan.json +589 -0
  121. package/src/__fixtures__/change-set/tofu.part.golden.json +173 -0
  122. package/src/__fixtures__/change-set/tofu.plan.json +588 -0
  123. package/src/__fixtures__/change-set/v1.tf.txt +21 -0
  124. package/src/__fixtures__/change-set/v2-module-app.tf.txt +4 -0
  125. package/src/__fixtures__/change-set/v2.tf.txt +21 -0
  126. package/src/__fixtures__/pin/choudoufu-live-waves-n5.json +83 -0
  127. package/src/__fixtures__/pin/largeset-oci-n5/apply-order.txt +5 -0
  128. package/src/__fixtures__/pin/largeset-oci-n5/estates/e01/estate.chdf.hcl +6 -0
  129. package/src/__fixtures__/pin/largeset-oci-n5/estates/e01/main.tf +33 -0
  130. package/src/__fixtures__/pin/largeset-oci-n5/estates/e02/estate.chdf.hcl +6 -0
  131. package/src/__fixtures__/pin/largeset-oci-n5/estates/e02/main.tf +45 -0
  132. package/src/__fixtures__/pin/largeset-oci-n5/estates/e03/estate.chdf.hcl +6 -0
  133. package/src/__fixtures__/pin/largeset-oci-n5/estates/e03/main.tf +47 -0
  134. package/src/__fixtures__/pin/largeset-oci-n5/estates/e04/estate.chdf.hcl +6 -0
  135. package/src/__fixtures__/pin/largeset-oci-n5/estates/e04/main.tf +31 -0
  136. package/src/__fixtures__/pin/largeset-oci-n5/estates/e05/estate.chdf.hcl +6 -0
  137. package/src/__fixtures__/pin/largeset-oci-n5/estates/e05/main.tf +27 -0
  138. package/src/__fixtures__/pin/largeset-oci-n5/fixture.json +77 -0
  139. package/src/__fixtures__/plan-summary/largeset-n5-bump.json +2071 -0
  140. package/src/__fixtures__/plan-summary/largeset-n5.summary.golden.json +95 -0
  141. package/src/__fixtures__/plan-summary/largeset-n5.summary.golden.md +22 -0
  142. package/src/__fixtures__/plan-summary/largeset-n5.summary.golden.txt +12 -0
  143. package/src/__fixtures__/terragrunt/affected/catalog/units/web/terragrunt.hcl +3 -0
  144. package/src/__fixtures__/terragrunt/affected/gitignore +3 -0
  145. package/src/__fixtures__/terragrunt/affected/live/dev/app/terragrunt.hcl +3 -0
  146. package/src/__fixtures__/terragrunt/affected/live/dev/vpc/terragrunt.hcl +3 -0
  147. package/src/__fixtures__/terragrunt/affected/live/prod/app/terragrunt.hcl +3 -0
  148. package/src/__fixtures__/terragrunt/affected/live/stg/terragrunt.stack.hcl +5 -0
  149. package/src/__fixtures__/terragrunt/affected/modules/app/main.tf +6 -0
  150. package/src/__fixtures__/terragrunt/affected/modules/app/policy.json +1 -0
  151. package/src/__fixtures__/terragrunt/affected/modules/net/main.tf +2 -0
  152. package/src/__fixtures__/terragrunt/affected/modules/thing/main.tf +3 -0
  153. package/src/__fixtures__/terragrunt/affected/root.hcl +5 -0
  154. package/src/__fixtures__/terragrunt/affected-cases.ts +126 -0
  155. package/src/__fixtures__/terragrunt/five-units/catalog/units/thing/terragrunt.hcl +3 -0
  156. package/src/__fixtures__/terragrunt/five-units/live/dev/app/terragrunt.hcl +13 -0
  157. package/src/__fixtures__/terragrunt/five-units/live/dev/db/terragrunt.hcl +8 -0
  158. package/src/__fixtures__/terragrunt/five-units/live/dev/vpc/terragrunt.hcl +3 -0
  159. package/src/__fixtures__/terragrunt/five-units/live/prod/app/terragrunt.hcl +8 -0
  160. package/src/__fixtures__/terragrunt/five-units/live/prod/vpc/terragrunt.hcl +3 -0
  161. package/src/__fixtures__/terragrunt/five-units/modules/thing/main.tf +7 -0
  162. package/src/__fixtures__/terragrunt/five-units/root.hcl +5 -0
  163. package/src/__fixtures__/terragrunt/record.ts +56 -0
  164. package/src/__fixtures__/terragrunt/recorded/affected-find.json +50 -0
  165. package/src/__fixtures__/terragrunt/recorded/failed-plan-report.json +36 -0
  166. package/src/__fixtures__/terragrunt/recorded/find.json +67 -0
  167. package/src/__fixtures__/terragrunt/recorded/version.txt +1 -0
  168. package/src/__fixtures__/terragrunt/recorded/wave-1/apply-report.json +22 -0
  169. package/src/__fixtures__/terragrunt/recorded/wave-1/json/live/dev/vpc/tfplan.json +1 -0
  170. package/src/__fixtures__/terragrunt/recorded/wave-1/json/live/prod/vpc/tfplan.json +1 -0
  171. package/src/__fixtures__/terragrunt/recorded/wave-1/plan-report.json +22 -0
  172. package/src/__fixtures__/terragrunt/recorded/wave-2/json/live/dev/db/tfplan.json +1 -0
  173. package/src/__fixtures__/terragrunt/recorded/wave-2/json/live/prod/app/tfplan.json +1 -0
  174. package/src/__fixtures__/terragrunt/recorded/wave-2/plan-report.json +22 -0
  175. package/src/change-set-combined-run.test.ts +156 -0
  176. package/src/change-set.test.ts +151 -0
  177. package/src/change-set.ts +273 -0
  178. package/src/changed-roots.test.ts +58 -0
  179. package/src/changed-roots.ts +102 -0
  180. package/src/commands.ts +112 -0
  181. package/src/components/capability-plugin.test.ts +91 -0
  182. package/src/components/capability-plugin.ts +131 -0
  183. package/src/components/pr-loop.tofu.test.ts +244 -0
  184. package/src/composites/terraform-pin-rollout-op.test.ts +69 -0
  185. package/src/composites/terraform-pin-rollout-op.ts +60 -0
  186. package/src/hcl/descend.ts +1 -1
  187. package/src/hcl/edges.ts +2 -2
  188. package/src/hcl/parse.test.ts +25 -0
  189. package/src/hcl/parse.ts +91 -6
  190. package/src/hcl/roots.ts +1 -1
  191. package/src/hcl/suppressions.ts +8 -2
  192. package/src/index.ts +18 -0
  193. package/src/lint/audit-catalog.ts +82 -0
  194. package/src/lint/audit.test.ts +3 -1
  195. package/src/lint/post-synth/fixtures/TF038/positive.tf +3 -0
  196. package/src/lint/post-synth/fixtures/TF039/positive.tf +4 -0
  197. package/src/lint/post-synth/fixtures/TF041/negative/terragrunt.hcl +12 -0
  198. package/src/lint/post-synth/fixtures/TF041/positive/terragrunt.hcl +7 -0
  199. package/src/lint/post-synth/fixtures/TF041/positive-apply/terragrunt.hcl +8 -0
  200. package/src/lint/post-synth/fixtures/TF042/negative/terragrunt.hcl +13 -0
  201. package/src/lint/post-synth/fixtures/TF042/positive/terragrunt.hcl +9 -0
  202. package/src/lint/post-synth/fixtures/TF043/negative/terragrunt.hcl +3 -0
  203. package/src/lint/post-synth/fixtures/TF043/negative-other/terragrunt.hcl +3 -0
  204. package/src/lint/post-synth/fixtures/TF043/positive-git/terragrunt.hcl +3 -0
  205. package/src/lint/post-synth/fixtures/TF043/positive-oci/terragrunt.hcl +3 -0
  206. package/src/lint/post-synth/fixtures/TF043/positive-tfr/terragrunt.hcl +3 -0
  207. package/src/lint/post-synth/fixtures/TF044/negative/terragrunt.hcl +9 -0
  208. package/src/lint/post-synth/fixtures/TF044/positive/terragrunt.hcl +7 -0
  209. package/src/lint/post-synth/fixtures/TF044/positive-generate/terragrunt.hcl +9 -0
  210. package/src/lint/post-synth/fixtures/TF045/negative/root.hcl +1 -0
  211. package/src/lint/post-synth/fixtures/TF045/negative/terragrunt.hcl +3 -0
  212. package/src/lint/post-synth/fixtures/TF045/positive/root.hcl +9 -0
  213. package/src/lint/post-synth/fixtures/TF045/positive/terragrunt.hcl +3 -0
  214. package/src/lint/post-synth/fixtures/TF045/unit-only/terragrunt.hcl +3 -0
  215. package/src/lint/post-synth/index.ts +16 -0
  216. package/src/lint/post-synth/module-source.test.ts +42 -1
  217. package/src/lint/post-synth/module-source.ts +56 -1
  218. package/src/lint/post-synth/terragrunt.ts +76 -0
  219. package/src/lint/post-synth/tf002.ts +1 -1
  220. package/src/lint/post-synth/tf038.test.ts +51 -0
  221. package/src/lint/post-synth/tf038.ts +71 -0
  222. package/src/lint/post-synth/tf039.test.ts +40 -0
  223. package/src/lint/post-synth/tf039.ts +64 -0
  224. package/src/lint/post-synth/tf040.test.ts +55 -0
  225. package/src/lint/post-synth/tf040.ts +76 -0
  226. package/src/lint/post-synth/tf041.test.ts +50 -0
  227. package/src/lint/post-synth/tf041.ts +56 -0
  228. package/src/lint/post-synth/tf042.test.ts +19 -0
  229. package/src/lint/post-synth/tf042.ts +42 -0
  230. package/src/lint/post-synth/tf043.test.ts +65 -0
  231. package/src/lint/post-synth/tf043.ts +49 -0
  232. package/src/lint/post-synth/tf044.test.ts +27 -0
  233. package/src/lint/post-synth/tf044.ts +51 -0
  234. package/src/lint/post-synth/tf045.test.ts +30 -0
  235. package/src/lint/post-synth/tf045.ts +47 -0
  236. package/src/op/activities/index.ts +4 -0
  237. package/src/op/activities/pin-rollout.ts +77 -0
  238. package/src/op/activities/terraform.test.ts +13 -0
  239. package/src/op/activities/terraform.ts +46 -86
  240. package/src/op/activity-contracts.test.ts +4 -1
  241. package/src/op/activity-contracts.ts +42 -0
  242. package/src/op/builders.ts +13 -0
  243. package/src/pin/bundle.test.ts +53 -0
  244. package/src/pin/edit-ts.test.ts +39 -0
  245. package/src/pin/edit-ts.ts +77 -0
  246. package/src/pin/edit.test.ts +199 -0
  247. package/src/pin/edit.ts +157 -0
  248. package/src/pin/forge.ts +85 -0
  249. package/src/pin/index.ts +22 -0
  250. package/src/pin/rollout.test.ts +232 -0
  251. package/src/pin/rollout.ts +451 -0
  252. package/src/pin/scan.ts +309 -0
  253. package/src/pin/source.ts +206 -0
  254. package/src/pin/waves.test.ts +101 -0
  255. package/src/pin/waves.ts +125 -0
  256. package/src/plan-digest.ts +99 -0
  257. package/src/plan-summary.test.ts +154 -0
  258. package/src/plugin.ts +25 -2
  259. package/src/terragrunt/affected.acceptance.test.ts +83 -0
  260. package/src/terragrunt/affected.test.ts +177 -0
  261. package/src/terragrunt/affected.ts +348 -0
  262. package/src/terragrunt/index.ts +11 -0
  263. package/src/terragrunt/mocks.acceptance.test.ts +105 -0
  264. package/src/terragrunt/mocks.test.ts +309 -0
  265. package/src/terragrunt/mocks.ts +324 -0
  266. package/src/terragrunt/run.ts +369 -0
  267. package/src/terragrunt/terragrunt.acceptance.test.ts +122 -0
  268. package/src/terragrunt/terragrunt.test.ts +332 -0
  269. package/src/terragrunt/units.ts +235 -0
  270. package/src/terragrunt/wave.ts +181 -0
@@ -0,0 +1,244 @@
1
+ /**
2
+ * The pull-request loop (#3183) over five real Terraform roots, through the
3
+ * commands the generated CI runs: `chant components pr-plan` on the pull
4
+ * request and `chant components pr-apply` on the merge, each talking to a
5
+ * fake GitHub.
6
+ *
7
+ * The issue's "done when": a pull request touching one of five roots plans
8
+ * only that root and its dependents, applies on merge with the approved
9
+ * digest, and refuses with a clear message if the plan changed after review.
10
+ *
11
+ * The roots use `terraform_data`, which is built into `tofu` and `terraform`,
12
+ * with local state, so nothing downloads a provider or reaches a cloud. The
13
+ * estate is net -> a, b and a -> app, with dns on its own; a and b read net's
14
+ * output and app reads a's, through `stackOutput()`. Skipped when neither
15
+ * binary is on the PATH.
16
+ */
17
+
18
+ import { execFileSync } from "node:child_process";
19
+ import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
20
+ import { tmpdir } from "node:os";
21
+ import { dirname, join } from "node:path";
22
+ import { afterAll, beforeAll, describe, expect, test, vi } from "vitest";
23
+ import type { ParsedArgs } from "@intentius/chant/cli/registry";
24
+ import type { PrReport } from "@intentius/chant/pr-loop";
25
+ import { terraformPlugin } from "../plugin";
26
+
27
+ const REPO = join(import.meta.dirname, "..", "..", "..", "..");
28
+
29
+ function which(bin: string): boolean {
30
+ try {
31
+ execFileSync(bin, ["version"], { stdio: "ignore" });
32
+ return true;
33
+ } catch {
34
+ return false;
35
+ }
36
+ }
37
+ const BINARY = which("tofu") ? "tofu" : which("terraform") ? "terraform" : undefined;
38
+
39
+ const ROOTS: Record<string, string> = {
40
+ net: 'resource "terraform_data" "vpc" {\n input = "10.0.0.0/16"\n}\n\noutput "cidr" {\n value = terraform_data.vpc.output\n}\n',
41
+ a: 'variable "cidr" {\n type = string\n}\n\nresource "terraform_data" "subnet" {\n input = "${var.cidr}/a-v1"\n}\n\noutput "id" {\n value = terraform_data.subnet.output\n}\n',
42
+ b: 'variable "cidr" {\n type = string\n}\n\nresource "terraform_data" "subnet" {\n input = "${var.cidr}/b-v1"\n}\n',
43
+ app: 'variable "subnet" {\n type = string\n}\n\nresource "terraform_data" "app" {\n input = "app on ${var.subnet}"\n}\n',
44
+ dns: 'resource "terraform_data" "zone" {\n input = "example.test"\n}\n',
45
+ };
46
+
47
+ const COMPONENTS = `import { phase, stackOutput, type Component } from "@intentius/chant/components";
48
+
49
+ const apply = (root: string, vars?: Record<string, unknown>) =>
50
+ [phase("Apply", [{ kind: "terraform-apply", root, ...(vars ? { vars } : {}) }])];
51
+
52
+ export const net: Component = { name: "net", dependsOn: [], deploy: apply("net") };
53
+ export const a: Component = { name: "a", dependsOn: ["net"], deploy: apply("a", { cidr: stackOutput("net", "cidr") }) };
54
+ export const b: Component = { name: "b", dependsOn: ["net"], deploy: apply("b", { cidr: stackOutput("net", "cidr") }) };
55
+ export const app: Component = { name: "app", dependsOn: ["a"], deploy: apply("app", { subnet: stackOutput("a", "id") }) };
56
+ export const dns: Component = { name: "dns", dependsOn: [], deploy: apply("dns") };
57
+ `;
58
+
59
+ /** GitHub as far as the loop uses it: comments, statuses, reviews and the merged pull request of a commit. */
60
+ class FakeGithub {
61
+ comments: Array<{ id: number; pr: number; body: string }> = [];
62
+ statuses: Array<{ sha: string; state: string; context: string; description: string }> = [];
63
+ reviews: Record<number, Array<{ user: { login: string }; state: string }>> = {};
64
+ mergedBy: Record<string, number> = {};
65
+ private nextId = 1;
66
+
67
+ fetch = async (url: string, init: { method: string; body?: string }) => {
68
+ const path = new URL(url).pathname.replace(/^\/repos\/acme\/infra/, "");
69
+ const body = init.body ? JSON.parse(init.body) : undefined;
70
+ const ok = (value: unknown) => ({ ok: true, status: 200, text: async () => JSON.stringify(value) });
71
+ let m: RegExpMatchArray | null;
72
+ if ((m = path.match(/^\/issues\/(\d+)\/comments$/))) {
73
+ const pr = Number(m[1]);
74
+ if (init.method === "GET") return ok(this.comments.filter((c) => c.pr === pr));
75
+ const c = { id: this.nextId++, pr, body: body.body };
76
+ this.comments.push(c);
77
+ return ok(c);
78
+ }
79
+ if ((m = path.match(/^\/issues\/comments\/(\d+)$/))) {
80
+ const c = this.comments.find((x) => x.id === Number(m![1]))!;
81
+ c.body = body.body;
82
+ return ok(c);
83
+ }
84
+ if ((m = path.match(/^\/statuses\/([0-9a-f]+)$/))) {
85
+ this.statuses.push({ sha: m[1], ...body });
86
+ return ok({});
87
+ }
88
+ if ((m = path.match(/^\/pulls\/(\d+)\/reviews$/))) return ok(this.reviews[Number(m[1])] ?? []);
89
+ if ((m = path.match(/^\/commits\/([0-9a-f]+)\/pulls$/))) {
90
+ const pr = this.mergedBy[m[1]];
91
+ return ok(pr ? [{ number: pr, merged_at: "2026-10-03T00:00:00Z", merge_commit_sha: m[1] }] : []);
92
+ }
93
+ return { ok: false, status: 404, text: async () => "" };
94
+ };
95
+ }
96
+
97
+ const args = (overrides: Partial<ParsedArgs>): ParsedArgs => ({
98
+ command: "components",
99
+ path: ".",
100
+ format: "",
101
+ fix: false,
102
+ watch: false,
103
+ verbose: false,
104
+ help: false,
105
+ live: false,
106
+ env: "local",
107
+ noReleaseRecord: true,
108
+ ...overrides,
109
+ });
110
+
111
+ describe.skipIf(!BINARY)(`the pull-request loop over five ${BINARY ?? "terraform"} roots`, () => {
112
+ let dir: string;
113
+ let cwd: string;
114
+ const github = new FakeGithub();
115
+ const env = { ...process.env };
116
+ const git = (...a: string[]) => execFileSync("git", a, { cwd: dir, encoding: "utf8" }).trim();
117
+ const write = (path: string, text: string) => {
118
+ mkdirSync(dirname(join(dir, path)), { recursive: true });
119
+ writeFileSync(join(dir, path), text);
120
+ };
121
+ const report = (stage: "plan" | "apply"): PrReport => JSON.parse(readFileSync(join(dir, ".chant", "pr", `pr-${stage}.json`), "utf8"));
122
+ const state = (root: string) => readFileSync(join(dir, "roots", root, "terraform.tfstate"), "utf8");
123
+ const ctx = (overrides: Partial<ParsedArgs>) => ({ args: args(overrides), plugins: [terraformPlugin], serializers: [terraformPlugin.serializer] });
124
+ let handlers: typeof import("@intentius/chant/cli/handlers/pr");
125
+ let recordGateApproval: typeof import("@intentius/chant/cli/handlers/operator").recordGateApproval;
126
+
127
+ beforeAll(async () => {
128
+ dir = realpathSync(mkdtempSync(join(tmpdir(), "chant-pr-loop-")));
129
+ symlinkSync(join(REPO, "node_modules"), join(dir, "node_modules"));
130
+ for (const [name, text] of Object.entries(ROOTS)) write(`roots/${name}/main.tf`, text);
131
+ write(
132
+ "chant.config.json",
133
+ JSON.stringify({ lexicons: ["terraform"], terraform: { binary: BINARY, roots: Object.fromEntries(Object.keys(ROOTS).map((r) => [r, { dir: `roots/${r}` }])) } }, null, 2),
134
+ );
135
+ write("estate.component.ts", COMPONENTS);
136
+ write(".gitignore", "node_modules\n.terraform\n.terraform.lock.hcl\n*.tfstate*\nchant.tfplan\n.chant\n");
137
+ git("init", "-q", "-b", "main");
138
+ git("config", "user.email", "ci@example.test");
139
+ git("config", "user.name", "ci");
140
+ git("add", "-A");
141
+ git("commit", "-qm", "five roots");
142
+
143
+ cwd = process.cwd();
144
+ process.chdir(dir);
145
+ Object.assign(process.env, {
146
+ GITHUB_REPOSITORY: "acme/infra",
147
+ GITHUB_TOKEN: "t0k",
148
+ GITHUB_API_URL: "https://api.github.com",
149
+ GITHUB_SERVER_URL: "https://github.com",
150
+ GITHUB_RUN_ID: "1",
151
+ });
152
+ vi.stubGlobal("fetch", github.fetch);
153
+ handlers = await import("@intentius/chant/cli/handlers/pr");
154
+ ({ recordGateApproval } = await import("@intentius/chant/cli/handlers/operator"));
155
+
156
+ // Every root applied once, so each has state and outputs to read.
157
+ const { runComponentsFanOut } = await import("@intentius/chant/cli/handlers/fan-out");
158
+ write("all.json", JSON.stringify({ changed: ["net", "dns"] }));
159
+ expect(await runComponentsFanOut(ctx({ fromAffected: "all.json" }) as never)).toBe(0);
160
+ }, 240_000);
161
+
162
+ afterAll(() => {
163
+ vi.unstubAllGlobals();
164
+ if (cwd) process.chdir(cwd);
165
+ for (const key of Object.keys(process.env)) if (!(key in env)) delete process.env[key];
166
+ if (dir) rmSync(dir, { recursive: true, force: true });
167
+ });
168
+
169
+ /** A pull request that rewrites root a's subnet to `version`, merged by `merge`. */
170
+ async function openPullRequest(pr: number, version: string): Promise<PrReport> {
171
+ git("checkout", "-qb", `pr-${pr}`);
172
+ write("roots/a/main.tf", ROOTS.a.replace("a-v1", version));
173
+ git("commit", "-qam", `a ${version}`);
174
+ expect(await handlers.runComponentsPrPlan(ctx({ base: "main", pr, forge: "github" }) as never)).toBe(0);
175
+ return report("plan");
176
+ }
177
+
178
+ async function merge(pr: number): Promise<number> {
179
+ git("checkout", "-q", "main");
180
+ const before = git("rev-parse", "HEAD");
181
+ git("merge", "-q", "--no-ff", `pr-${pr}`, "-m", `Merge pull request #${pr}`);
182
+ github.mergedBy[git("rev-parse", "HEAD")] = pr;
183
+ return handlers.runComponentsPrApply(ctx({ base: before, forge: "github", requireReview: true }) as never);
184
+ }
185
+
186
+ test("a change to one root plans that root and its dependent, and nothing else", async () => {
187
+ const plan = await openPullRequest(1, "a-v2");
188
+
189
+ expect(plan.status).toBe("planned");
190
+ expect(plan.selection).toMatchObject({ changed: ["a"], dependents: ["app"], waves: [["a"], ["app"]] });
191
+ expect(plan.members.map((m) => [m.member, m.counts.update])).toEqual([
192
+ ["a", 1],
193
+ ["app", 0],
194
+ ]);
195
+ expect(plan.changeSet.digest).toBe(plan.digest);
196
+ expect(plan.approval).toEqual({ status: "pending" });
197
+
198
+ const note = github.comments.find((c) => c.pr === 1)!;
199
+ expect(note.body.startsWith("<!-- chant-pr:local -->")).toBe(true);
200
+ expect(note.body).toContain(`chant approve pr-1 pr-apply --plan ${plan.digest} --approver github:<your-login> --sign`);
201
+ expect(github.statuses.at(-1)).toMatchObject({ context: "chant/plan", state: "success", sha: plan.head });
202
+ }, 240_000);
203
+
204
+ test("on merge it applies the plan the reviewer approved", async () => {
205
+ const plan = report("plan");
206
+ github.reviews[1] = [{ user: { login: "alice" }, state: "APPROVED" }];
207
+ expect((await recordGateApproval("pr-1", "pr-apply", { actor: "github:alice", plan: plan.digest })).ok).toBe(true);
208
+
209
+ expect(await merge(1)).toBe(0);
210
+ const applied = report("apply");
211
+ expect(applied.digest).toBe(plan.digest);
212
+ expect(applied.status).toBe("applied");
213
+ expect(applied.approval).toEqual({ status: "approved", approvedBy: ["github:alice"] });
214
+ expect(applied.members.map((m) => [m.member, m.status, m.inputsMoved])).toEqual([
215
+ ["a", "applied", undefined],
216
+ ["app", "applied", ["a"]],
217
+ ]);
218
+ expect(state("a")).toContain("10.0.0.0/16/a-v2");
219
+ // The same note, now with the apply's outcome.
220
+ expect(github.comments.filter((c) => c.pr === 1)).toHaveLength(1);
221
+ expect(github.comments.find((c) => c.pr === 1)!.body).toContain("### chant apply for `local`");
222
+ expect(github.statuses.at(-1)).toMatchObject({ context: "chant/apply", state: "success", sha: applied.head });
223
+ }, 240_000);
224
+
225
+ test("a plan that changed after review is refused, naming both digests, and nothing applies", async () => {
226
+ const reviewed = await openPullRequest(2, "a-v3");
227
+ github.reviews[2] = [{ user: { login: "alice" }, state: "APPROVED" }];
228
+ expect((await recordGateApproval("pr-2", "pr-apply", { actor: "github:alice", plan: reviewed.digest })).ok).toBe(true);
229
+ // Pushed after the review.
230
+ write("roots/a/main.tf", ROOTS.a.replace("a-v1", "a-v4"));
231
+ git("commit", "-qam", "a v4");
232
+
233
+ expect(await merge(2)).toBe(3);
234
+ const refused = report("apply");
235
+ expect(refused.status).toBe("refused");
236
+ expect(refused.refusal).toBe("plan-changed");
237
+ expect(refused.approval).toEqual({ status: "changed", approved: reviewed.digest });
238
+ expect(refused.message).toContain(`approved: ${reviewed.digest}; planned now: ${refused.digest}`);
239
+ expect(refused.message).toContain("nothing was applied");
240
+ expect(state("a")).toContain("a-v2");
241
+ expect(state("a")).not.toContain("a-v4");
242
+ expect(github.statuses.at(-1)).toMatchObject({ context: "chant/apply", state: "failure", description: "refused: the plan changed after review" });
243
+ }, 240_000);
244
+ });
@@ -0,0 +1,69 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { validateActivitySteps, type ActivityStep, type OpConfig } from "@intentius/chant/op";
3
+ import { TerraformPinRolloutOp } from "./terraform-pin-rollout-op";
4
+ import { terraformPinRolloutContract } from "../op/activity-contracts";
5
+ import { parsePinRolloutArgs, pinRolloutExitCode, terraformCommands } from "../commands";
6
+
7
+ function props(config: Parameters<typeof TerraformPinRolloutOp>[0]): OpConfig {
8
+ return (TerraformPinRolloutOp(config).op as unknown as { props: OpConfig }).props;
9
+ }
10
+
11
+ const BASE = { name: "vpc-1-4", module: "oci://registry.example.com/modules/vpc", from: "1.3.0", to: "1.4.0" };
12
+
13
+ describe("TerraformPinRolloutOp (#3189)", () => {
14
+ test("one Rollout phase, one step, the rollout's status as the run outcome", () => {
15
+ const op = props({ ...BASE, canaries: ["live/dev/vpc"], mode: "pull-request", schedule: "*/30 * * * *" });
16
+ expect(op.phases.map((p) => p.name)).toEqual(["Rollout"]);
17
+ const step = op.phases[0]!.steps[0] as ActivityStep;
18
+ expect(step.fn).toBe("terraformPinRollout");
19
+ expect(step.args).toEqual({ module: BASE.module, from: "1.3.0", to: "1.4.0", canaries: ["live/dev/vpc"], mode: "pull-request" });
20
+ expect(step.outcomeAttribute).toEqual({ name: "Rollout", from: "status" });
21
+ expect(op.schedule).toEqual({ cron: "*/30 * * * *", overlap: "skip" });
22
+ });
23
+
24
+ test("its step validates against the activity's contract", () => {
25
+ const op = props({ ...BASE, roots: [{ root: "a" }, { root: "b", dependsOn: ["a"] }] });
26
+ const contracts = new Map([["terraformPinRollout", terraformPinRolloutContract]]);
27
+ expect(validateActivitySteps(op, contracts)).toEqual([]);
28
+ });
29
+ });
30
+
31
+ describe("chant terraform pin-rollout", () => {
32
+ test("is mounted as the terraform group's pin-rollout verb", () => {
33
+ expect(terraformCommands.name).toBe("terraform");
34
+ expect(terraformCommands.commands.map((c) => c.name)).toEqual(["pin-rollout"]);
35
+ });
36
+
37
+ test("parses roots, dependencies, a generated root and canaries into the activity's args", () => {
38
+ const { args, json } = parsePinRolloutArgs([
39
+ "--module=oci://r/m/vpc",
40
+ "--from", "1.3.0",
41
+ "--to", "1.4.0",
42
+ "--root", "a",
43
+ "--depends-on", "b=a",
44
+ "--ts-source", "c=c/vpc.ts",
45
+ "--canary", "a",
46
+ "--pull-request",
47
+ "--json",
48
+ ]);
49
+ expect(json).toBe(true);
50
+ expect(args).toEqual({
51
+ module: "oci://r/m/vpc",
52
+ from: "1.3.0",
53
+ to: "1.4.0",
54
+ mode: "pull-request",
55
+ roots: [{ root: "a" }, { root: "b", dependsOn: ["a"] }, { root: "c", tsSource: "c/vpc.ts" }],
56
+ canaries: ["a"],
57
+ });
58
+ });
59
+
60
+ test("report is the default mode, and the required flags are required", () => {
61
+ expect(parsePinRolloutArgs(["--module", "m", "--from", "1", "--to", "2"]).args.mode).toBe("report");
62
+ expect(() => parsePinRolloutArgs(["--module", "m", "--from", "1"])).toThrow(/--to is required/);
63
+ expect(() => parsePinRolloutArgs(["--module", "m", "--from", "1", "--to", "2", "--bogus"])).toThrow(/Unknown flag: --bogus/);
64
+ });
65
+
66
+ test("waiting exits 3, stopped exits 1, the rest 0", () => {
67
+ expect(["complete", "opened", "would-open", "waiting", "stopped"].map(pinRolloutExitCode)).toEqual([0, 0, 0, 3, 1]);
68
+ });
69
+ });
@@ -0,0 +1,60 @@
1
+ /**
2
+ * TerraformPinRolloutOp composite (#3189): roll a module version out as
3
+ * pin-bump pull requests, one wave at a time.
4
+ *
5
+ * One phase, one `terraformPinRollout` step. Each run reads the rollout's
6
+ * state from the forge and opens at most the next wave's PR, so the Op is
7
+ * meant to run again: on a `schedule`, or from the pipeline that applies a
8
+ * merged wave. Wave N+1 opens only on a run after wave N's PR merged and each
9
+ * of its roots reported its apply check as passed on the merge commit. Until
10
+ * then a run reports where the rollout stands and opens nothing. A failed
11
+ * root fails the run, naming it.
12
+ *
13
+ * The run's `Rollout` outcome is the rollout's status: `opened`, `waiting`,
14
+ * `complete`, or `would-open` in report mode.
15
+ *
16
+ * @example
17
+ * ```typescript
18
+ * import { TerraformPinRolloutOp } from "@intentius/chant-lexicon-terraform";
19
+ *
20
+ * export const { op } = TerraformPinRolloutOp({
21
+ * name: "vpc-1-4",
22
+ * module: "oci://registry.example.com/modules/vpc",
23
+ * from: "1.3.0",
24
+ * to: "1.4.0",
25
+ * canaries: ["live/dev/vpc"],
26
+ * mode: "pull-request",
27
+ * schedule: "*\/30 * * * *",
28
+ * });
29
+ * ```
30
+ */
31
+
32
+ import { Op, phase, OpResource } from "@intentius/chant/op";
33
+ import { terraformPinRollout } from "../op/builders";
34
+ import type { TerraformPinRolloutArgs } from "../op/activities/pin-rollout";
35
+
36
+ export interface TerraformPinRolloutOpConfig extends TerraformPinRolloutArgs {
37
+ /** Op name (kebab-case). */
38
+ name: string;
39
+ /** Cron expression, so the rollout re-reads the forge and moves on without anyone running it. */
40
+ schedule?: string;
41
+ }
42
+
43
+ export interface TerraformPinRolloutOpResources {
44
+ /** Op resource. One Rollout phase. */
45
+ op: InstanceType<typeof OpResource>;
46
+ }
47
+
48
+ export function TerraformPinRolloutOp(config: TerraformPinRolloutOpConfig): TerraformPinRolloutOpResources {
49
+ const { name, schedule, ...args } = config;
50
+ const step = terraformPinRollout(args);
51
+ step.outcomeAttribute = { name: "Rollout", from: "status" };
52
+ const op = Op({
53
+ name,
54
+ overview: `Roll ${config.module} ${config.from} -> ${config.to} out as one pin-bump pull request per wave`,
55
+ labels: { Surface: "terraform-pin-rollout" },
56
+ ...(schedule ? { schedule: { cron: schedule, overlap: "skip" as const } } : {}),
57
+ phases: [phase("Rollout", [step])],
58
+ });
59
+ return { op };
60
+ }
@@ -49,7 +49,7 @@
49
49
  import { existsSync, statSync } from "node:fs";
50
50
  import { isAbsolute, relative, resolve } from "node:path";
51
51
  import type { Declarable } from "@intentius/chant/declarable";
52
- import type { Hcl2Json } from "@intentius/chant/terraform/parse";
52
+ import type { Hcl2Json } from "@intentius/chant/terraform/hcl2json";
53
53
  import { isResourceDeclarable } from "@intentius/chant/declarable";
54
54
  import { classifyModuleSource } from "../lint/post-synth/module-source";
55
55
  import {
package/src/hcl/edges.ts CHANGED
@@ -14,7 +14,7 @@
14
14
  *
15
15
  * The `${...}` body is tokenized by hcl2json's own expression AST
16
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
17
+ * same instrument `packages/core/src/terraform/hcl2json.ts` uses for the carve-out
18
18
  * advisor, and for the same reason: a quoted address used as a map key
19
19
  * (`var.m["aws_s3_bucket.assets.arn"]`) and an escaped `$${...}` literal are
20
20
  * not references, and no regex over the raw string can tell. `./references.ts`
@@ -86,7 +86,7 @@
86
86
 
87
87
  import { isResourceDeclarable, type Declarable } from "@intentius/chant/declarable";
88
88
  import type { EntityReference } from "@intentius/chant/graph-ir";
89
- import { loadHcl2json, type Hcl2Json } from "@intentius/chant/terraform/parse";
89
+ import { loadHcl2json, type Hcl2Json } from "@intentius/chant/terraform/hcl2json";
90
90
  import { refFromAccessor } from "@intentius/chant/terraform/graph";
91
91
  import { LOCALS_TYPE, scopeOfKey, type BlockBody, type TerraformEntity } from "./parse";
92
92
 
@@ -67,3 +67,28 @@ describe("blocksToEntities: suppression attachment (chant #2111)", () => {
67
67
  expect(second.suppressions).toBeUndefined();
68
68
  });
69
69
  });
70
+
71
+ describe("Terragrunt config files (#3417)", () => {
72
+ const files = [
73
+ {
74
+ name: "terragrunt.hcl",
75
+ source: 'terraform {\n source = "../m"\n}\ndependency "vpc" {\n config_path = "../vpc"\n}\nremote_state {\n backend = "s3"\n}\n',
76
+ },
77
+ ];
78
+
79
+ test("terragrunt.hcl and root.hcl become Terragrunt entities, never module entities", async () => {
80
+ const entities = await blocksToEntities(files, "unit");
81
+ expect([...entities.values()].map((e) => e.entityType).sort()).toEqual([
82
+ "Terraform::TerragruntConfig",
83
+ "Terraform::TerragruntDependency",
84
+ "Terraform::TerragruntRemoteState",
85
+ "Terraform::TerragruntSource",
86
+ ]);
87
+ expect([...entities.keys()]).toContain("unit/dependency.vpc");
88
+ });
89
+
90
+ test("the same text under a .tf name is read as a Terraform terraform block", async () => {
91
+ const entities = await blocksToEntities([{ name: "main.tf", source: 'terraform {\n required_version = ">= 1.5"\n}\n' }], "unit");
92
+ expect([...entities.values()].map((e) => e.entityType)).toEqual(["Terraform::Terraform"]);
93
+ });
94
+ });
package/src/hcl/parse.ts CHANGED
@@ -20,7 +20,7 @@
20
20
 
21
21
  import { existsSync, readdirSync, readFileSync } from "node:fs";
22
22
  import { join } from "node:path";
23
- import { loadHcl2json, type Hcl2Json } from "@intentius/chant/terraform/parse";
23
+ import { loadHcl2json, type Hcl2Json } from "@intentius/chant/terraform/hcl2json";
24
24
  import { DECLARABLE_MARKER, type Declarable } from "@intentius/chant/declarable";
25
25
  import type { EntityReference } from "@intentius/chant/graph-ir";
26
26
  import type { SuppressionDirective } from "@intentius/chant/lint/suppressions";
@@ -83,6 +83,13 @@ export interface TerraformEntity extends Declarable {
83
83
  readonly mode?: TerraformRootMode;
84
84
  /** The declared estate name. Present only when `mode` is `"live"`. */
85
85
  readonly estate?: string;
86
+ /**
87
+ * Whether `.terraform.lock.hcl` sits beside the root's `.tf` files (#3190,
88
+ * TF040). Stamped on every entity of a root parsed from a directory, and
89
+ * absent when the parse had no directory to look in (a fetched
90
+ * repository, inline source), which reads as "not determined".
91
+ */
92
+ readonly lockFile?: boolean;
86
93
  /** `terraform.roots.<name>.workspace`, recorded so TF025 can flag a non-default one on a live root. */
87
94
  readonly workspace?: string;
88
95
  /**
@@ -166,6 +173,26 @@ export const LOCALS_TYPE = "Terraform::Locals";
166
173
  /** A `live { estate = "..." }` block, or an `estate.chdf.hcl` sidecar's content (#2103). */
167
174
  export const LIVE_TYPE = "Terraform::Live";
168
175
 
176
+ /**
177
+ * Terragrunt config entities (#3417). A `terragrunt.hcl` or `root.hcl` is not
178
+ * a Terraform module, so its blocks are read into these types and never into
179
+ * `TERRAFORM_TYPE`: TF001's `required_version` check and the rest of the
180
+ * module rules keep to the `.tf` files.
181
+ */
182
+ export const TERRAGRUNT_CONFIG_TYPE = "Terraform::TerragruntConfig";
183
+ export const TERRAGRUNT_SOURCE_TYPE = "Terraform::TerragruntSource";
184
+ export const TERRAGRUNT_DEPENDENCY_TYPE = "Terraform::TerragruntDependency";
185
+ export const TERRAGRUNT_REMOTE_STATE_TYPE = "Terraform::TerragruntRemoteState";
186
+ export const TERRAGRUNT_GENERATE_TYPE = "Terraform::TerragruntGenerate";
187
+
188
+ /** The Terragrunt config files read, by their default names. A `--config` rename is not followed. */
189
+ export const TERRAGRUNT_FILENAMES: readonly string[] = ["root.hcl", "terragrunt.hcl"];
190
+
191
+ /** Whether `name` (a bare filename) is a Terragrunt config file this lexicon reads. */
192
+ export function isTerragruntFileName(name: string): boolean {
193
+ return TERRAGRUNT_FILENAMES.includes(name);
194
+ }
195
+
169
196
  /** The sidecar filename choudoufu reads an estate declaration from when no in-block `live { }` is used. */
170
197
  export const LIVE_SIDECAR_FILENAME = "estate.chdf.hcl";
171
198
 
@@ -260,6 +287,11 @@ export function listTerraformFiles(dir: string): string[] {
260
287
  .sort();
261
288
  }
262
289
 
290
+ /** The Terragrunt config files directly under `dir` (`root.hcl`, `terragrunt.hcl`), in filename order like the audit's bundle. */
291
+ export function listTerragruntFiles(dir: string): string[] {
292
+ return TERRAGRUNT_FILENAMES.filter((name) => existsSync(join(dir, name)));
293
+ }
294
+
263
295
  /**
264
296
  * Read the `estate.chdf.hcl` sidecar beside `dir`'s `.tf` files, if present.
265
297
  * Sibling to {@link listTerraformFiles}: the sidecar is plain HCL (a bare
@@ -313,8 +345,13 @@ export interface TerraformRootModeOptions {
313
345
  * declaration silently overrides.
314
346
  */
315
347
  configEstate?: string;
348
+ /** Stamp `props.lockFile` on every entity (#3190). Set by the two directory-backed parses only. */
349
+ lockFile?: boolean;
316
350
  }
317
351
 
352
+ /** The lock file `terraform init` writes beside a root's `.tf` files. */
353
+ export const LOCK_FILENAME = ".terraform.lock.hcl";
354
+
318
355
  /**
319
356
  * Parse a set of files into entities keyed `<root>/<address>`. Two blocks that
320
357
  * genuinely share an address (two `locals` blocks, or the same address in two
@@ -351,8 +388,15 @@ export async function blocksToEntities(
351
388
  const entities = new Map<string, Declarable>();
352
389
  const prefix = callers.length > 0 ? `${root}/${callers.join("/")}` : root;
353
390
 
354
- const add = (entityType: string, address: string, body: unknown, file: TerraformFile, scan: FileScan): void => {
355
- const { line, suppressions } = directivesFor(scan, address);
391
+ const add = (
392
+ entityType: string,
393
+ address: string,
394
+ body: unknown,
395
+ file: TerraformFile,
396
+ scan: FileScan,
397
+ scanAddress: string = address,
398
+ ): void => {
399
+ const { line, suppressions } = directivesFor(scan, scanAddress);
356
400
  const entity = terraformEntity(
357
401
  entityType,
358
402
  address,
@@ -418,6 +462,10 @@ export async function blocksToEntities(
418
462
  for (const file of files) {
419
463
  const tree = (await parser.parse(file.name, file.source)) as Record<string, unknown>;
420
464
  const scan = scanSuppressions(file.name, file.source);
465
+ if (isTerragruntFileName(file.name)) {
466
+ terragruntBlocks(tree, file, scan, add);
467
+ continue;
468
+ }
421
469
  unlabelled(tree, "terraform", TERRAFORM_TYPE, file, scan);
422
470
  for (const liveBody of liveBlocksIn(tree)) add(LIVE_TYPE, "live", liveBody, file, scan);
423
471
  unlabelled(tree, "locals", LOCALS_TYPE, file, scan);
@@ -450,7 +498,12 @@ export async function blocksToEntities(
450
498
  const te = entity as TerraformEntity;
451
499
  const stamped: TerraformEntity = {
452
500
  ...te,
453
- props: { ...te.props, mode, ...(mode === "live" ? { estate } : {}) },
501
+ props: {
502
+ ...te.props,
503
+ mode,
504
+ ...(mode === "live" ? { estate } : {}),
505
+ ...(modeOptions?.lockFile !== undefined ? { lockFile: modeOptions.lockFile } : {}),
506
+ },
454
507
  };
455
508
  entities.set(key, stamped);
456
509
  }
@@ -458,6 +511,34 @@ export async function blocksToEntities(
458
511
  return entities;
459
512
  }
460
513
 
514
+ /**
515
+ * One Terragrunt config file's blocks, as entities (#3417): the file's own
516
+ * settings (`terragrunt_version_constraint` and the `include` labels), the
517
+ * `terraform` block that names the unit's source, `remote_state`, each
518
+ * `dependency` and each `generate`. Everything else in the file (`inputs`,
519
+ * `locals`, `iam_role`) is left unread.
520
+ */
521
+ function terragruntBlocks(
522
+ tree: Record<string, unknown>,
523
+ file: TerraformFile,
524
+ scan: FileScan,
525
+ add: (entityType: string, address: string, body: unknown, file: TerraformFile, scan: FileScan, scanAddress?: string) => void,
526
+ ): void {
527
+ const config: BlockBody = { includes: Object.keys(asRecord(tree["include"])) };
528
+ if (typeof tree["terragrunt_version_constraint"] === "string") {
529
+ config["terragrunt_version_constraint"] = tree["terragrunt_version_constraint"];
530
+ }
531
+ add(TERRAGRUNT_CONFIG_TYPE, "terragrunt", config, file, scan, "");
532
+ for (const body of asArray(tree["terraform"])) add(TERRAGRUNT_SOURCE_TYPE, "terragrunt.terraform", body, file, scan, "terraform");
533
+ for (const body of asArray(tree["remote_state"])) add(TERRAGRUNT_REMOTE_STATE_TYPE, "remote_state", body, file, scan);
534
+ for (const [name, bodies] of Object.entries(asRecord(tree["dependency"]))) {
535
+ for (const body of asArray(bodies)) add(TERRAGRUNT_DEPENDENCY_TYPE, `dependency.${name}`, body, file, scan);
536
+ }
537
+ for (const [name, bodies] of Object.entries(asRecord(tree["generate"]))) {
538
+ for (const body of asArray(bodies)) add(TERRAGRUNT_GENERATE_TYPE, `generate.${name}`, body, file, scan);
539
+ }
540
+ }
541
+
461
542
  /**
462
543
  * Parse a root module directory. Reads every `.tf` directly under `dir`, plus
463
544
  * the `estate.chdf.hcl` sidecar beside them when present. Throws whatever the
@@ -471,7 +552,9 @@ export async function parseTerraformRootDir(
471
552
  modeOptions?: TerraformRootModeOptions,
472
553
  callers: readonly string[] = [],
473
554
  ): Promise<Map<string, Declarable>> {
474
- const files = listTerraformFiles(dir).map((name) => ({
555
+ // A child module is a Terraform module, never a Terragrunt unit (#3417).
556
+ const names = [...listTerraformFiles(dir), ...(callers.length === 0 ? listTerragruntFiles(dir) : [])];
557
+ const files = names.map((name) => ({
475
558
  name,
476
559
  source: readFileSync(join(dir, name), "utf-8"),
477
560
  }));
@@ -479,7 +562,9 @@ export async function parseTerraformRootDir(
479
562
  // estate a ROOT runs against, and choudoufu reads it from the root's own
480
563
  // directory only. See `./descend.ts`.
481
564
  const sidecar = callers.length === 0 ? readLiveSidecarFile(dir) : undefined;
482
- return blocksToEntities(files, root, hcl2json, modeOptions, sidecar, callers);
565
+ // Only the root's own directory carries a lock file that matters (#3190).
566
+ const opts = callers.length === 0 ? { ...modeOptions, lockFile: existsSync(join(dir, LOCK_FILENAME)) } : modeOptions;
567
+ return blocksToEntities(files, root, hcl2json, opts, sidecar, callers);
483
568
  }
484
569
 
485
570
  /**
package/src/hcl/roots.ts CHANGED
@@ -29,7 +29,7 @@
29
29
  import { existsSync } from "node:fs";
30
30
  import { isAbsolute, resolve } from "node:path";
31
31
  import { isResourceDeclarable, type Declarable } from "@intentius/chant/declarable";
32
- import type { Hcl2Json } from "@intentius/chant/terraform/parse";
32
+ import type { Hcl2Json } from "@intentius/chant/terraform/hcl2json";
33
33
  import type { TerraformRootConfig } from "../config";
34
34
  import { LIVE_TYPE, parseTerraformRootDir } from "./parse";
35
35
  import { descendModules, resolveCallModuleType, type CallModuleType } from "./descend";
@@ -27,9 +27,9 @@
27
27
  import type { SuppressionDirective, SuppressionIds } from "@intentius/chant/lint/suppressions";
28
28
 
29
29
  /** A block header keyword with zero labels (`terraform {`, `locals {`). */
30
- const BARE_RE = /^\s*(terraform|locals)\s*\{/;
30
+ const BARE_RE = /^\s*(terraform|locals|remote_state)\s*\{/;
31
31
  /** One label (`provider "aws" {`, `module "vpc" {`, `variable "x" {`, `output "x" {`). */
32
- const ONE_LABEL_RE = /^\s*(provider|module|variable|output)\s+"([^"]*)"\s*\{/;
32
+ const ONE_LABEL_RE = /^\s*(provider|module|variable|output|dependency|generate)\s+"([^"]*)"\s*\{/;
33
33
  /** Two labels (`resource "aws_s3_bucket" "assets" {`, `data "aws_ami" "x" {`). */
34
34
  const TWO_LABEL_RE = /^\s*(resource|data)\s+"([^"]*)"\s+"([^"]*)"\s*\{/;
35
35
 
@@ -43,6 +43,12 @@ function addressFor(keyword: string, labels: string[]): string {
43
43
  return "terraform";
44
44
  case "locals":
45
45
  return "locals";
46
+ case "remote_state":
47
+ return "remote_state";
48
+ case "dependency":
49
+ return `dependency.${labels[0]}`;
50
+ case "generate":
51
+ return `generate.${labels[0]}`;
46
52
  case "provider":
47
53
  return `provider.${labels[0]}`;
48
54
  case "module":