@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,51 @@
1
+ /**
2
+ * TF044: a Terragrunt config keeps state in a local backend.
3
+ *
4
+ * Read from `remote_state { backend = "local" }` and from a `generate` block
5
+ * whose `contents` declare `backend "local"`. State on the runner's disk is
6
+ * gone when the job ends, a second job cannot read what the first applied, and
7
+ * a git-range run, which checks out each side in a temporary directory, never
8
+ * sees state at all. A shared backend (S3, GCS, azurerm, a Terraform HTTP
9
+ * backend) holds it.
10
+ *
11
+ * A backend named by an expression is not determined and passes. A local
12
+ * backend is sometimes right for a throwaway sandbox; suppress the rule there
13
+ * with `# chant-ignore TF044`.
14
+ */
15
+
16
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
17
+ import { TERRAGRUNT_GENERATE_TYPE, TERRAGRUNT_REMOTE_STATE_TYPE } from "../../hcl/parse";
18
+ import { terragruntEntities } from "./terragrunt";
19
+
20
+ const LOCAL_BACKEND_BLOCK = /\bbackend\s+"local"/;
21
+
22
+ export const tf044: PostSynthCheck = {
23
+ id: "TF044",
24
+ description: "Terragrunt config keeps state in a local backend",
25
+
26
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
27
+ const diagnostics: PostSynthDiagnostic[] = [];
28
+ const report = (key: string, file: string, how: string): void => {
29
+ diagnostics.push({
30
+ checkId: "TF044",
31
+ severity: "warning",
32
+ message:
33
+ `${how} in ${file} keeps state in a local backend. The state lives on the runner's disk, so a later job cannot read it ` +
34
+ "and a git-range run, which checks out each side in a temporary directory, never sees it. Use a shared backend such as S3 or GCS.",
35
+ entity: key,
36
+ lexicon: "terraform",
37
+ });
38
+ };
39
+
40
+ for (const block of terragruntEntities(ctx.entities, TERRAGRUNT_REMOTE_STATE_TYPE)) {
41
+ if (block.body.backend === "local") report(block.key, block.file, "`remote_state` sets `backend = \"local\"`");
42
+ }
43
+ for (const block of terragruntEntities(ctx.entities, TERRAGRUNT_GENERATE_TYPE)) {
44
+ const contents = block.body.contents;
45
+ if (typeof contents === "string" && LOCAL_BACKEND_BLOCK.test(contents)) {
46
+ report(block.key, block.file, `\`generate "${block.address.replace(/^generate\./, "")}"\` writes a \`backend "local"\` block`);
47
+ }
48
+ }
49
+ return diagnostics;
50
+ },
51
+ };
@@ -0,0 +1,30 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { loadTreeFixture } from "./fixtures/load";
3
+ import { tf045 } from "./tf045";
4
+
5
+ describe("TF045: no Terragrunt config sets terragrunt_version_constraint", () => {
6
+ test("flags a root.hcl with none, once, anchored on root.hcl", async () => {
7
+ const ctx = await loadTreeFixture("TF045", "positive");
8
+ const diags = tf045.check(ctx);
9
+ expect(diags).toHaveLength(1);
10
+ expect(diags[0]).toMatchObject({ checkId: "TF045", severity: "warning", lexicon: "terraform" });
11
+ expect((ctx.entities.get(diags[0].entity!) as unknown as { props: { file: string } }).props.file).toBe("root.hcl");
12
+ expect(diags[0].message).toContain("root.hcl");
13
+ });
14
+
15
+ test("a constraint in root.hcl passes", async () => {
16
+ expect(tf045.check(await loadTreeFixture("TF045", "negative"))).toEqual([]);
17
+ });
18
+
19
+ test("a unit that includes a parent that was not read is not determined", async () => {
20
+ expect(tf045.check(await loadTreeFixture("TF045", "unit-only"))).toEqual([]);
21
+ });
22
+
23
+ test("a repo with no Terragrunt config reports nothing", async () => {
24
+ expect(tf045.check({ outputs: new Map(), entities: new Map() } as never)).toEqual([]);
25
+ });
26
+
27
+ test("check metadata", () => {
28
+ expect(tf045.id).toBe("TF045");
29
+ });
30
+ });
@@ -0,0 +1,47 @@
1
+ /**
2
+ * TF045: no Terragrunt config sets `terragrunt_version_constraint`.
3
+ *
4
+ * The attribute makes Terragrunt refuse to run on a release outside the range.
5
+ * Without it each machine and each CI image runs whatever Terragrunt it
6
+ * installed, and a behaviour change between releases reaches a plan with no
7
+ * diff. It is usually set once, in the root config every unit includes.
8
+ *
9
+ * The rule looks at the whole set of configs in the scan and reports once. It
10
+ * reports only when the set holds a config that includes no other file (the
11
+ * root config, or a self-contained unit) and none of the configs sets the
12
+ * attribute. A set made only of units that include a parent it did not read,
13
+ * such as one unit directory passed alone, is not determined: the parent may
14
+ * set it. The finding is anchored on `root.hcl` when the set has one.
15
+ *
16
+ * Off by default: the `report-only` tier keeps it out of the `recommended`
17
+ * preset, since many repos pin Terragrunt in the toolchain (`.tool-versions`,
18
+ * a CI image) rather than in config.
19
+ */
20
+
21
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
22
+ import { TERRAGRUNT_CONFIG_TYPE } from "../../hcl/parse";
23
+ import { terragruntEntities } from "./terragrunt";
24
+
25
+ export const tf045: PostSynthCheck = {
26
+ id: "TF045",
27
+ description: "No Terragrunt config sets terragrunt_version_constraint",
28
+
29
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
30
+ const configs = terragruntEntities(ctx.entities, TERRAGRUNT_CONFIG_TYPE);
31
+ if (configs.some((c) => c.body.terragrunt_version_constraint !== undefined)) return [];
32
+ const tops = configs.filter((c) => Array.isArray(c.body.includes) && c.body.includes.length === 0);
33
+ if (tops.length === 0) return [];
34
+ const anchor = [...tops].sort((a, b) => Number(b.file === "root.hcl") - Number(a.file === "root.hcl") || a.key.localeCompare(b.key))[0];
35
+ return [
36
+ {
37
+ checkId: "TF045",
38
+ severity: "warning",
39
+ message:
40
+ `No Terragrunt config sets \`terragrunt_version_constraint\` (${anchor.file} is the top config read). ` +
41
+ 'Set it once in the root config, for example `terragrunt_version_constraint = ">= 1.1.0"`, so every machine runs a release the repo was tested with.',
42
+ entity: anchor.key,
43
+ lexicon: "terraform",
44
+ },
45
+ ];
46
+ },
47
+ };
@@ -16,6 +16,8 @@
16
16
  * - choudoufuLiveLs: `live-ls -estate=<estate> -json [-consistent]` (#2103).
17
17
  * - choudoufuLiveCheck: `live-check -json`, no cloud calls (#2103).
18
18
  * - choudoufuAdopt: the tag writes that claim an adoption ledger's matches (#2105).
19
+ * - terraformPinRollout: one run of a pin-bump rollout, opening at most one
20
+ * wave's pull request (#3189). It loads the HCL parser on first call.
19
21
  *
20
22
  * The module is dependency-light on purpose: it shells out to the configured
21
23
  * binary and reads the `terraform` config namespace, and never touches the
@@ -82,3 +84,5 @@ export type {
82
84
  } from "./terraform";
83
85
 
84
86
  export { detectLiveEstate } from "./live-detect";
87
+ export { terraformPinRollout } from "./pin-rollout";
88
+ export type { TerraformPinRolloutArgs, TerraformPinRolloutResult } from "./pin-rollout";
@@ -0,0 +1,77 @@
1
+ /**
2
+ * `terraformPinRollout`: one run of a pin-bump rollout (#3189), as an Op
3
+ * activity. `chant terraform pin-rollout` calls the same function.
4
+ *
5
+ * Each run reads where the rollout stands on the forge and opens at most one
6
+ * wave's pull request (`../../pin/rollout.ts`). Run it again, by hand or on
7
+ * an Op `schedule`, to move on once a wave's PR has merged and its roots have
8
+ * applied. A run that finds a failed root or a PR closed without merging
9
+ * throws, naming it, so the run fails rather than reporting success.
10
+ *
11
+ * The HCL parser, the pin code and the TypeScript editor load on first call,
12
+ * so importing the activities loads none of them.
13
+ */
14
+
15
+ import { readFileSync } from "node:fs";
16
+ import { isAbsolute, join } from "node:path";
17
+ import type { PinRoot, PinRolloutMode, PinRolloutResult } from "../../pin";
18
+
19
+ export interface TerraformPinRolloutArgs {
20
+ /** The module, as its `source` names it without the pin: `oci://registry.example.com/modules/vpc`. */
21
+ module: string;
22
+ /** The pin the roots are at now. */
23
+ from: string;
24
+ /** The pin to move them to: an exact version, tag, ref or `sha256:` digest. */
25
+ to: string;
26
+ /** The roots, by directory relative to the repository. Default: every root that calls the module. */
27
+ roots?: PinRoot[];
28
+ /** Roots that form wave 1. */
29
+ canaries?: string[];
30
+ /** A `choudoufu live-waves -json` document to take the waves from, instead of canaries and dependency order. */
31
+ wavesFrom?: string;
32
+ /** The directory choudoufu ran in, relative to the repository, when its roots are relative to that. */
33
+ wavesPrefix?: string;
34
+ /** `report` (default) or `pull-request`. */
35
+ mode?: PinRolloutMode;
36
+ /** The branch PRs target. Default: the remote's default branch. */
37
+ base?: string;
38
+ /** Default `origin`. */
39
+ remote?: string;
40
+ /** The apply check each root reports on the merge commit, with `{root}` for its directory. Default `apply/{root}`. */
41
+ appliedCheck?: string;
42
+ /** A directory in the repository. Default: the working directory. */
43
+ cwd?: string;
44
+ }
45
+
46
+ export type TerraformPinRolloutResult = PinRolloutResult;
47
+
48
+ /** Run one step of a pin-bump rollout. Throws when the rollout has stopped. */
49
+ export async function terraformPinRollout(args: TerraformPinRolloutArgs): Promise<TerraformPinRolloutResult> {
50
+ const result = await readPinRollout(args);
51
+ if (result.status === "stopped") throw new Error(`pin rollout stopped: ${result.stop}\n${result.summary}`);
52
+ return result;
53
+ }
54
+
55
+ /** The same run, returning a stopped rollout instead of throwing. `chant terraform pin-rollout` calls this. */
56
+ export async function readPinRollout(args: TerraformPinRolloutArgs): Promise<TerraformPinRolloutResult> {
57
+ const { loadHcl2json } = await import("@intentius/chant/terraform/hcl2json");
58
+ const { runPinRollout, wavesFromChoudoufu } = await import("../../pin");
59
+ const { editPinsInTs } = await import("../../pin/edit-ts");
60
+ const cwd = args.cwd ?? process.cwd();
61
+ const waves = args.wavesFrom ? wavesFromChoudoufu(JSON.parse(readFileSync(isAbsolute(args.wavesFrom) ? args.wavesFrom : join(cwd, args.wavesFrom), "utf-8")), args.wavesPrefix) : undefined;
62
+ return runPinRollout({
63
+ module: args.module,
64
+ from: args.from,
65
+ to: args.to,
66
+ cwd,
67
+ parser: await loadHcl2json(),
68
+ editTs: editPinsInTs,
69
+ ...(args.roots ? { roots: args.roots } : {}),
70
+ ...(args.canaries ? { canaries: args.canaries } : {}),
71
+ ...(waves ? { waves } : {}),
72
+ ...(args.mode ? { mode: args.mode } : {}),
73
+ ...(args.base ? { base: args.base } : {}),
74
+ ...(args.remote ? { remote: args.remote } : {}),
75
+ ...(args.appliedCheck ? { appliedCheck: args.appliedCheck } : {}),
76
+ });
77
+ }
@@ -205,6 +205,19 @@ describe("terraformPlanCommand (#2086)", () => {
205
205
  test("-destroy is opt-in", () => {
206
206
  expect(terraformPlanCommand({ binary: "tofu", planFile: "p", destroy: true })).toContain(" -destroy ");
207
207
  });
208
+
209
+ test("vars become -var flags after the var files, sorted, non-strings as JSON (#3049)", () => {
210
+ expect(
211
+ terraformPlanCommand({
212
+ binary: "tofu",
213
+ planFile: "p",
214
+ varFiles: ["prod.tfvars"],
215
+ vars: { zones: ["a", "b"], vpc_id: "vpc-1 x" },
216
+ }),
217
+ ).toBe(
218
+ `tofu plan -input=false -detailed-exitcode -var-file=prod.tfvars -var='vpc_id=vpc-1 x' -var='zones=["a","b"]' -out=p`,
219
+ );
220
+ });
208
221
  });
209
222
 
210
223
  describe("terraformApplyCommand (#2086)", () => {
@@ -45,7 +45,9 @@ import { promisify } from "node:util";
45
45
  import { resolve, dirname, join } from "node:path";
46
46
  import { loadChantConfigUpward } from "@intentius/chant/config";
47
47
  import { liveReadKey, memoLiveRead } from "@intentius/chant/live-read-session";
48
- import { computePlanDigest } from "@intentius/chant/op";
48
+ import { terraformPlanDigest } from "../../plan-digest";
49
+ import { plannerForBinary, terraformChangeSetPart } from "../../change-set";
50
+ import type { ChangeSetPart } from "@intentius/chant/change-set";
49
51
  import type { TerraformConfig, TerraformRootConfig } from "../../config";
50
52
  import { detectLiveEstate } from "./live-detect";
51
53
  import {
@@ -148,6 +150,14 @@ export interface TerraformPlanArgs extends TerraformRootArgs {
148
150
  planFile?: string;
149
151
  /** `-destroy`: plan the removal of everything the root manages. */
150
152
  destroy?: boolean;
153
+ /**
154
+ * Input variables, passed as `-var` after the root's `-var-file`s, so they
155
+ * win over a var file that sets the same name (#3049). A string goes as it
156
+ * is; anything else as JSON, which terraform reads as HCL for a list, a map
157
+ * or an object. This is how a value another root produced, wired with
158
+ * `stackOutput()`, reaches the plan.
159
+ */
160
+ vars?: Record<string, unknown>;
151
161
  }
152
162
 
153
163
  export interface TerraformApplyArgs extends TerraformRootArgs {
@@ -249,91 +259,7 @@ export interface PlanChangeCounts {
249
259
  destroys: number;
250
260
  }
251
261
 
252
- /**
253
- * One resource's proposed change, as a plan digest sees it (#2300) — the
254
- * projection of a `terraform show -json` `resource_changes` entry that
255
- * survives into {@link terraformPlanDigest}.
256
- */
257
- export interface TerraformPlannedChange {
258
- address: unknown;
259
- mode: unknown;
260
- type: unknown;
261
- name: unknown;
262
- index: unknown;
263
- provider_name: unknown;
264
- deposed: unknown;
265
- actions: unknown;
266
- before: unknown;
267
- after: unknown;
268
- after_unknown: unknown;
269
- replace_paths: unknown;
270
- importing: unknown;
271
- }
272
-
273
- /**
274
- * The change set a `terraform show -json` plan describes, canonicalised
275
- * (#2300): every proposed change, at its address, with the values that would
276
- * be written, sorted so two renderings of the same plan project identically.
277
- *
278
- * Everything else in the document is dropped, and the drops are the point —
279
- * see `packages/core/src/lifecycle/plan-digest.ts` for the general rule. Here
280
- * that means `timestamp` and `terraform_version` (when the plan was taken and
281
- * by which binary), `format_version` (how it was rendered), `prior_state` and
282
- * `planned_values` (restatements of the same change set from another angle),
283
- * `configuration` (the source that produced it, which can be refactored
284
- * without changing a single proposed action) and `checks` / `relevant_
285
- * attributes` (diagnostics about the plan, not the plan). What is left is
286
- * what applying it would do.
287
- */
288
- export function terraformPlanChangeSet(planJson: unknown): {
289
- resourceChanges: TerraformPlannedChange[];
290
- outputChanges: unknown;
291
- } {
292
- const doc = planJson as { resource_changes?: unknown; output_changes?: unknown } | null | undefined;
293
- const raw = Array.isArray(doc?.resource_changes) ? doc.resource_changes : [];
294
- const resourceChanges = raw.map((entry): TerraformPlannedChange => {
295
- const e = (entry ?? {}) as Record<string, unknown>;
296
- const change = (e.change ?? {}) as Record<string, unknown>;
297
- return {
298
- address: e.address,
299
- mode: e.mode,
300
- type: e.type,
301
- name: e.name,
302
- index: e.index,
303
- provider_name: e.provider_name,
304
- deposed: e.deposed,
305
- actions: change.actions,
306
- before: change.before,
307
- after: change.after,
308
- after_unknown: change.after_unknown,
309
- replace_paths: change.replace_paths,
310
- importing: change.importing,
311
- };
312
- });
313
- // terraform already emits these in address order, but nothing in the format
314
- // promises it, and a digest that depends on the emitter's iteration order
315
- // would refuse a plan that is identical in every way that matters.
316
- resourceChanges.sort((a, b) =>
317
- `${String(a.address)}\u0000${String(a.deposed ?? "")}`.localeCompare(
318
- `${String(b.address)}\u0000${String(b.deposed ?? "")}`,
319
- ),
320
- );
321
- return { resourceChanges, outputChanges: doc?.output_changes ?? {} };
322
- }
323
-
324
- /**
325
- * The identity of a terraform plan (#2300): {@link terraformPlanChangeSet}
326
- * hashed. This is what a `TerraformApplyOp` gate binds its approval to, and
327
- * what a later run recomputes and compares.
328
- *
329
- * Taken over the `show -json` rendering rather than the plan file's bytes on
330
- * purpose: the file is an opaque, version-stamped archive, and two runs of
331
- * `plan -out` over an unchanged root produce different bytes. The rendering
332
- * is the same plan both times.
333
- */
334
- export function terraformPlanDigest(planJson: unknown): string {
335
- return computePlanDigest("terraform-plan", terraformPlanChangeSet(planJson));
336
- }
262
+ export { terraformPlanChangeSet, terraformPlanDigest, type TerraformPlannedChange } from "../../plan-digest";
337
263
 
338
264
  /** What {@link terraformPlan} resolved. Carries the plan itself, so no later step re-plans. */
339
265
  export interface TerraformPlanResult extends PlanChangeCounts {
@@ -354,6 +280,13 @@ export interface TerraformPlanResult extends PlanChangeCounts {
354
280
  * plan.out.planDigest })`.
355
281
  */
356
282
  planDigest: string;
283
+ /**
284
+ * This root's part of a change-set document (#3181): every proposed change
285
+ * as an entry, the root's name as the member, and {@link planDigest} as the
286
+ * member's plan digest. Hand the parts of a run's roots to
287
+ * `composeChangeSet` and bind the run's gate to the document's digest.
288
+ */
289
+ changeSet: ChangeSetPart;
357
290
  }
358
291
 
359
292
  /**
@@ -596,11 +529,16 @@ export function terraformPlanCommand(opts: {
596
529
  binary: string;
597
530
  planFile: string;
598
531
  varFiles?: string[];
532
+ vars?: Record<string, unknown>;
599
533
  destroy?: boolean;
600
534
  }): string {
601
535
  const parts = [opts.binary, "plan", "-input=false", "-detailed-exitcode"];
602
536
  if (opts.destroy) parts.push("-destroy");
603
537
  for (const varFile of opts.varFiles ?? []) parts.push(`-var-file=${quoteArg(varFile)}`);
538
+ for (const [name, value] of Object.entries(opts.vars ?? {}).sort(([a], [b]) => a.localeCompare(b))) {
539
+ if (value === undefined) continue;
540
+ parts.push(`-var=${quoteArg(`${name}=${typeof value === "string" ? value : JSON.stringify(value)}`)}`);
541
+ }
604
542
  parts.push(`-out=${quoteArg(opts.planFile)}`);
605
543
  return parts.join(" ");
606
544
  }
@@ -957,6 +895,7 @@ export async function terraformPlan(
957
895
  binary,
958
896
  planFile,
959
897
  ...(root.varFiles ? { varFiles: root.varFiles } : {}),
898
+ ...(args.vars ? { vars: args.vars } : {}),
960
899
  ...(args.destroy ? { destroy: true } : {}),
961
900
  });
962
901
 
@@ -991,6 +930,12 @@ export async function terraformPlan(
991
930
  json,
992
931
  text: textRun.stdout,
993
932
  planDigest: terraformPlanDigest(json),
933
+ changeSet: terraformChangeSetPart({
934
+ member: args.root,
935
+ plan: json,
936
+ planner: plannerForBinary(binary),
937
+ ...((root.estate ?? root.workspace) ? { scope: root.estate ?? root.workspace } : {}),
938
+ }),
994
939
  ...countPlanChanges(json),
995
940
  };
996
941
  }
@@ -1069,6 +1014,21 @@ export async function terraformApply(
1069
1014
  }
1070
1015
  }
1071
1016
 
1017
+ /**
1018
+ * `terraform output -json` in the named root, as `{ name: value }` (#3049).
1019
+ * A sensitive output's value comes back like any other, since the caller
1020
+ * asked for the values to hand them on.
1021
+ */
1022
+ export async function terraformOutputs(
1023
+ args: TerraformRootArgs,
1024
+ signal?: AbortSignal,
1025
+ ): Promise<Record<string, unknown>> {
1026
+ const { binary, root, dir } = await resolveRoot(args, signal);
1027
+ const { stdout } = await run(`${binary} output -json`, dir, terraformEnvironment(root), signal);
1028
+ const parsed = JSON.parse(stdout || "{}") as Record<string, { value?: unknown }>;
1029
+ return Object.fromEntries(Object.entries(parsed).map(([name, entry]) => [name, entry?.value]));
1030
+ }
1031
+
1072
1032
  /**
1073
1033
  * `terraform show` over current state, or over a saved plan when `planFile` is
1074
1034
  * given. Returns the parsed `-json` output and the `-no-color` render, plus
@@ -23,6 +23,7 @@ const ACTIVITY_NAMES = [
23
23
  "choudoufuLiveLs",
24
24
  "choudoufuLiveCheck",
25
25
  "choudoufuAdopt",
26
+ "terraformPinRollout",
26
27
  ];
27
28
 
28
29
  describe("terraform activity contracts (#2101)", () => {
@@ -51,9 +52,11 @@ describe("terraform activity contracts (#2101)", () => {
51
52
  });
52
53
 
53
54
  test("`root` is declared as the entity every step touches (#2022)", () => {
54
- for (const name of ACTIVITY_NAMES) {
55
+ for (const name of ACTIVITY_NAMES.filter((n) => n !== "terraformPinRollout")) {
55
56
  expect(CONTRACTS.get(name)?.entities).toEqual(["root"]);
56
57
  }
58
+ // A pin rollout names no `terraform.roots` key: it moves a module across roots found by directory.
59
+ expect(CONTRACTS.get("terraformPinRollout")?.entities).toBeUndefined();
57
60
  });
58
61
 
59
62
  // ── The failure classes the contracts exist to catch ───────────────────
@@ -76,6 +76,7 @@ export const terraformPlanContract = activityContract(
76
76
  ...rootArgs,
77
77
  planFile: z.string().optional(),
78
78
  destroy: z.boolean().optional(),
79
+ vars: z.record(z.string(), z.unknown()).optional(),
79
80
  }),
80
81
  z.object({
81
82
  ...planChangeCounts,
@@ -85,6 +86,8 @@ export const terraformPlanContract = activityContract(
85
86
  json: z.unknown(),
86
87
  text: z.string(),
87
88
  planDigest: z.string(),
89
+ /** The root's change-set part (#3181); its shape is `@intentius/chant/change-set`'s `ChangeSetPart`. */
90
+ changeSet: z.object({ member: z.object({ member: z.string(), planDigest: z.string().nullable() }).passthrough(), entries: z.array(z.unknown()) }),
88
91
  }),
89
92
  rootEntity,
90
93
  );
@@ -177,3 +180,42 @@ export const choudoufuAdoptContract = activityContract(
177
180
  }),
178
181
  rootEntity,
179
182
  );
183
+
184
+ /** One root of a pin-bump rollout ({@link PinRoot} in `../pin/waves.ts`). */
185
+ const pinRoot = z.strictObject({ root: z.string(), dependsOn: z.array(z.string()).optional(), tsSource: z.string().optional() });
186
+
187
+ /**
188
+ * `terraformPinRollout` (#3189). It names no `terraform.roots` key: a rollout
189
+ * moves a module's pin across many roots, found by directory, so it carries no
190
+ * `rootEntity`. `status` and `summary` are the fields an Op reads: the run's
191
+ * outcome and a body for a report step.
192
+ */
193
+ export const terraformPinRolloutContract = activityContract(
194
+ "terraformPinRollout",
195
+ z.strictObject({
196
+ module: z.string(),
197
+ from: z.string(),
198
+ to: z.string(),
199
+ roots: z.array(pinRoot).optional(),
200
+ canaries: z.array(z.string()).optional(),
201
+ wavesFrom: z.string().optional(),
202
+ wavesPrefix: z.string().optional(),
203
+ mode: z.enum(["report", "pull-request"]).optional(),
204
+ base: z.string().optional(),
205
+ remote: z.string().optional(),
206
+ appliedCheck: z.string().optional(),
207
+ cwd: z.string().optional(),
208
+ }),
209
+ z.object({
210
+ module: z.string(),
211
+ from: z.string(),
212
+ to: z.string(),
213
+ mode: z.enum(["report", "pull-request"]),
214
+ base: z.string(),
215
+ status: z.enum(["complete", "opened", "would-open", "waiting", "stopped"]),
216
+ roots: z.array(z.object({}).loose()),
217
+ waves: z.array(z.object({}).loose()),
218
+ stop: z.string().optional(),
219
+ summary: z.string(),
220
+ }),
221
+ );
@@ -32,6 +32,7 @@ import type {
32
32
  ChoudoufuLiveCheckArgs,
33
33
  ChoudoufuAdoptArgs,
34
34
  } from "./activities/terraform";
35
+ import type { TerraformPinRolloutArgs } from "./activities/pin-rollout";
35
36
 
36
37
  /** Extra opts every wrapper below accepts alongside its activity's own fields. */
37
38
  type StepOpts = { profile?: ActivityStep["profile"]; id?: string };
@@ -184,3 +185,15 @@ export const terraformShow = (
184
185
  { profile: profile ?? "fastIdempotent", ...(id ? { id } : {}) },
185
186
  );
186
187
  };
188
+
189
+ /**
190
+ * One run of a pin-bump rollout (#3189). `opts` is
191
+ * {@link TerraformPinRolloutArgs} itself. It names no root: the rollout finds
192
+ * the roots that call the module. Defaults to the `fastIdempotent` profile: a
193
+ * repeated run reads the forge again and finds the PR an earlier attempt
194
+ * opened.
195
+ */
196
+ export const terraformPinRollout = (opts: WithStepRefs<TerraformPinRolloutArgs> & StepOpts): NamedActivityStep => {
197
+ const { args, profile, id } = takeProfileAndId(opts as Record<string, unknown>);
198
+ return activity("terraformPinRollout", args, { profile: profile ?? "fastIdempotent", ...(id ? { id } : {}) });
199
+ };
@@ -0,0 +1,53 @@
1
+ /**
2
+ * The pin subpath bundles small (#3189, ruling #3421).
3
+ *
4
+ * terragucci's `tf-rollout` stage runs this code from one bundled file with
5
+ * no TypeScript toolchain. So `@intentius/chant-lexicon-terraform/pin` must
6
+ * not reach `typescript`, esbuild, zod, the HCL parser's wasm package, or
7
+ * chant's fold, lint and codegen modules. The TypeScript editor for generated
8
+ * roots (`./edit-ts.ts`) stays out of it. This bundles the entry point the way
9
+ * terragucci would and reads esbuild's own list of what went in, the same
10
+ * check `packages/core/src/change-set-bundle.test.ts` makes.
11
+ */
12
+
13
+ import { join } from "node:path";
14
+ import { build } from "esbuild";
15
+ import { describe, expect, test } from "vitest";
16
+
17
+ const REPO = join(new URL(".", import.meta.url).pathname, "..", "..", "..", "..");
18
+
19
+ /** About 35 KB today, unminified. */
20
+ const BUDGET_BYTES = 60 * 1024;
21
+
22
+ const FORBIDDEN = [/node_modules\/typescript\//, /node_modules\/esbuild\//, /node_modules\/zod\//, /node_modules\/@cdktn\//, /\/src\/fold\//, /\/src\/lint\//, /\/src\/codegen\//, /edit-ts\.ts$/];
23
+
24
+ describe("the pin bundle", () => {
25
+ test("holds the edit, the wave plan and the rollout, and nothing heavier", async () => {
26
+ const result = await build({
27
+ entryPoints: [join(REPO, "lexicons", "terraform", "src", "pin", "index.ts")],
28
+ bundle: true,
29
+ platform: "node",
30
+ format: "esm",
31
+ write: false,
32
+ metafile: true,
33
+ outdir: "out",
34
+ logLevel: "silent",
35
+ });
36
+ const inputs = Object.keys(result.metafile.inputs);
37
+ for (const pattern of FORBIDDEN) {
38
+ expect(inputs.filter((i) => pattern.test(i)), String(pattern)).toEqual([]);
39
+ }
40
+ expect(inputs.map((i) => i.replace(/^.*?(packages|lexicons)\//, "$1/")).sort()).toEqual([
41
+ "lexicons/terraform/src/pin/edit.ts",
42
+ "lexicons/terraform/src/pin/forge.ts",
43
+ "lexicons/terraform/src/pin/index.ts",
44
+ "lexicons/terraform/src/pin/rollout.ts",
45
+ "lexicons/terraform/src/pin/scan.ts",
46
+ "lexicons/terraform/src/pin/source.ts",
47
+ "lexicons/terraform/src/pin/waves.ts",
48
+ "packages/core/src/components/layers.ts",
49
+ ]);
50
+ const bytes = result.outputFiles.reduce((n, f) => n + f.contents.byteLength, 0);
51
+ expect(bytes).toBeLessThan(BUDGET_BYTES);
52
+ });
53
+ });
@@ -0,0 +1,39 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { editPinsInTs } from "./edit-ts";
3
+
4
+ const OCI = "oci://registry.example.com/modules/vpc";
5
+
6
+ describe("editPinsInTs (#3189)", () => {
7
+ test("moves the pin in a module declaration and leaves every other byte", () => {
8
+ const before = [
9
+ `// source: "${OCI}?tag=1.3.0" in a comment is not a declaration`,
10
+ `export const vpc = module("vpc", {`,
11
+ ` source: '${OCI}?tag=1.3.0',`,
12
+ ` cidr: "10.0.0.0/16",`,
13
+ `});`,
14
+ `export const registry = new TerraformModule({ source: "app.terraform.io/acme/vpc/aws", version: "= 1.3.0" });`,
15
+ `export const other = module("dns", { source: "${OCI.replace("vpc", "dns")}?tag=1.3.0" });`,
16
+ ``,
17
+ ].join("\n");
18
+ const after = editPinsInTs(before, "infra/vpc.ts", { module: OCI, from: "1.3.0", to: "1.4.0" });
19
+ expect(after.content).toBe(before.replace(`'${OCI}?tag=1.3.0'`, `'${OCI}?tag=1.4.0'`));
20
+ expect(after.calls).toMatchObject([{ outcome: "moved", file: "infra/vpc.ts", call: "line 2" }]);
21
+
22
+ const registry = editPinsInTs(before, "infra/vpc.ts", { module: "app.terraform.io/acme/vpc/aws", from: "1.3.0", to: "1.4.0" });
23
+ expect(registry.content).toBe(before.replace(`version: "= 1.3.0"`, `version: "= 1.4.0"`));
24
+ });
25
+
26
+ test("refuses a floating constraint with its reason", () => {
27
+ const before = `export const m = { source: "app.terraform.io/acme/vpc/aws", version: "~> 1.3" };\n`;
28
+ const result = editPinsInTs(before, "m.ts", { module: "app.terraform.io/acme/vpc/aws", from: "1.3.0", to: "1.4.0" });
29
+ expect(result.content).toBe(before);
30
+ expect(result.calls).toMatchObject([{ outcome: "refused", reason: expect.stringMatching(/is a constraint, not a pin/) }]);
31
+ });
32
+
33
+ test("refuses a version built from an expression", () => {
34
+ const before = `const v = "1.3.0";\nexport const m = { source: "app.terraform.io/acme/vpc/aws", version: v };\n`;
35
+ const result = editPinsInTs(before, "m.ts", { module: "app.terraform.io/acme/vpc/aws", from: "1.3.0", to: "1.4.0" });
36
+ expect(result.content).toBe(before);
37
+ expect(result.calls).toMatchObject([{ outcome: "refused", reason: expect.stringMatching(/expression/) }]);
38
+ });
39
+ });