@intentius/chant-lexicon-terraform 0.124.31 → 0.124.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. package/dist/__fixtures__/terragrunt/affected-cases.js +111 -0
  2. package/dist/__fixtures__/terragrunt/record.js +57 -0
  3. package/dist/behaviour/index.js +6 -0
  4. package/dist/behaviour/kinds.js +194 -0
  5. package/dist/behaviour/predict.js +297 -0
  6. package/dist/behaviour/reference-catalog.js +178 -0
  7. package/dist/behaviour/request.js +482 -0
  8. package/dist/change-set.js +234 -0
  9. package/dist/changed-roots.js +97 -0
  10. package/dist/codegen/docs-cli.js +3 -0
  11. package/dist/codegen/docs.js +44 -0
  12. package/dist/codegen/generate-cli.js +9 -0
  13. package/dist/codegen/generate.js +59 -0
  14. package/dist/codegen/package.js +43 -0
  15. package/dist/commands.js +106 -0
  16. package/dist/components/capability-plugin.d.ts +38 -2
  17. package/dist/components/capability-plugin.d.ts.map +1 -1
  18. package/dist/components/capability-plugin.js +260 -0
  19. package/dist/composites/terraform-adopt-op.js +152 -0
  20. package/dist/composites/terraform-apply-op.js +183 -0
  21. package/dist/composites/terraform-pin-rollout-op.js +45 -0
  22. package/dist/composites/terraform-watch-op.js +219 -0
  23. package/dist/config.js +118 -0
  24. package/dist/describe-resources.js +984 -0
  25. package/dist/generated/index.js +1 -0
  26. package/dist/hcl/descend.js +208 -0
  27. package/dist/hcl/edges.js +287 -0
  28. package/dist/hcl/jsonencode.js +442 -0
  29. package/dist/hcl/marker.js +212 -0
  30. package/dist/hcl/parse.js +372 -0
  31. package/dist/hcl/references.js +156 -0
  32. package/dist/hcl/roots.js +98 -0
  33. package/dist/hcl/suppressions.js +189 -0
  34. package/dist/hcl/value.js +72 -0
  35. package/dist/index.d.ts +1 -1
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +46 -0
  38. package/dist/integrity.json +2 -2
  39. package/dist/lint/audit-catalog.js +501 -0
  40. package/dist/lint/audit-lineage.js +442 -0
  41. package/dist/lint/post-synth/blocks.js +87 -0
  42. package/dist/lint/post-synth/fixtures/load.js +94 -0
  43. package/dist/lint/post-synth/flat-attributes.js +135 -0
  44. package/dist/lint/post-synth/index.js +90 -0
  45. package/dist/lint/post-synth/module-source.js +153 -0
  46. package/dist/lint/post-synth/scope.js +104 -0
  47. package/dist/lint/post-synth/terragrunt.js +63 -0
  48. package/dist/lint/post-synth/tf001.js +138 -0
  49. package/dist/lint/post-synth/tf002.js +143 -0
  50. package/dist/lint/post-synth/tf003.js +60 -0
  51. package/dist/lint/post-synth/tf004.js +54 -0
  52. package/dist/lint/post-synth/tf005.js +68 -0
  53. package/dist/lint/post-synth/tf006.js +43 -0
  54. package/dist/lint/post-synth/tf007.js +66 -0
  55. package/dist/lint/post-synth/tf008.js +64 -0
  56. package/dist/lint/post-synth/tf009.js +49 -0
  57. package/dist/lint/post-synth/tf010.js +40 -0
  58. package/dist/lint/post-synth/tf011.js +38 -0
  59. package/dist/lint/post-synth/tf012.js +36 -0
  60. package/dist/lint/post-synth/tf013.js +53 -0
  61. package/dist/lint/post-synth/tf014.js +61 -0
  62. package/dist/lint/post-synth/tf015.js +62 -0
  63. package/dist/lint/post-synth/tf016.js +66 -0
  64. package/dist/lint/post-synth/tf017.js +39 -0
  65. package/dist/lint/post-synth/tf018.js +63 -0
  66. package/dist/lint/post-synth/tf019.js +60 -0
  67. package/dist/lint/post-synth/tf020.js +103 -0
  68. package/dist/lint/post-synth/tf021.js +117 -0
  69. package/dist/lint/post-synth/tf022.js +51 -0
  70. package/dist/lint/post-synth/tf024.js +62 -0
  71. package/dist/lint/post-synth/tf025.js +83 -0
  72. package/dist/lint/post-synth/tf026.js +74 -0
  73. package/dist/lint/post-synth/tf027.js +66 -0
  74. package/dist/lint/post-synth/tf028.js +83 -0
  75. package/dist/lint/post-synth/tf029.js +73 -0
  76. package/dist/lint/post-synth/tf030.js +253 -0
  77. package/dist/lint/post-synth/tf031.js +332 -0
  78. package/dist/lint/post-synth/tf032.js +201 -0
  79. package/dist/lint/post-synth/tf033.js +126 -0
  80. package/dist/lint/post-synth/tf034.js +45 -0
  81. package/dist/lint/post-synth/tf035.js +63 -0
  82. package/dist/lint/post-synth/tf036.js +97 -0
  83. package/dist/lint/post-synth/tf037.js +80 -0
  84. package/dist/lint/post-synth/tf038.js +63 -0
  85. package/dist/lint/post-synth/tf039.js +55 -0
  86. package/dist/lint/post-synth/tf040.js +67 -0
  87. package/dist/lint/post-synth/tf041.js +53 -0
  88. package/dist/lint/post-synth/tf042.js +38 -0
  89. package/dist/lint/post-synth/tf043.js +45 -0
  90. package/dist/lint/post-synth/tf044.js +45 -0
  91. package/dist/lint/post-synth/tf045.js +44 -0
  92. package/dist/lint/rules/fixtures/load.js +50 -0
  93. package/dist/lint/rules/index.js +4 -0
  94. package/dist/lint/rules/plan-before-apply.js +136 -0
  95. package/dist/lint/secret-shape.js +149 -0
  96. package/dist/live-ownership.js +57 -0
  97. package/dist/lsp/completions.js +95 -0
  98. package/dist/lsp/context.js +358 -0
  99. package/dist/lsp/hover.js +45 -0
  100. package/dist/lsp/option-keys.js +93 -0
  101. package/dist/manifest.json +1 -1
  102. package/dist/op/activities/index.js +30 -0
  103. package/dist/op/activities/live-detect.js +143 -0
  104. package/dist/op/activities/pin-rollout.js +45 -0
  105. package/dist/op/activities/terraform.js +1270 -0
  106. package/dist/op/activity-contracts.js +162 -0
  107. package/dist/op/adoption.js +272 -0
  108. package/dist/op/builders.js +125 -0
  109. package/dist/op/resolve-root-mode.js +77 -0
  110. package/dist/package-cli.js +16 -0
  111. package/dist/pin/edit-ts.js +76 -0
  112. package/dist/pin/edit.js +118 -0
  113. package/dist/pin/forge.js +65 -0
  114. package/dist/pin/index.js +16 -0
  115. package/dist/pin/rollout.d.ts.map +1 -1
  116. package/dist/pin/rollout.js +382 -0
  117. package/dist/pin/scan.js +287 -0
  118. package/dist/pin/source.js +171 -0
  119. package/dist/pin/waves.js +109 -0
  120. package/dist/plan-digest.js +67 -0
  121. package/dist/plugin.js +323 -0
  122. package/dist/serializer.js +23 -0
  123. package/dist/state-ownership.js +29 -0
  124. package/dist/terragrunt/affected.d.ts.map +1 -1
  125. package/dist/terragrunt/affected.js +290 -0
  126. package/dist/terragrunt/index.js +10 -0
  127. package/dist/terragrunt/mocks.js +270 -0
  128. package/dist/terragrunt/run.js +264 -0
  129. package/dist/terragrunt/units.js +195 -0
  130. package/dist/terragrunt/wave.js +125 -0
  131. package/dist/validate-cli.js +4 -0
  132. package/dist/validate.js +31 -0
  133. package/package.json +10 -10
  134. package/src/codegen/package.ts +2 -2
  135. package/src/components/capability-plugin.test.ts +97 -2
  136. package/src/components/capability-plugin.ts +117 -12
  137. package/src/components/pr-loop.tofu.test.ts +51 -11
  138. package/src/index.ts +1 -0
  139. package/src/pin/rollout.ts +2 -1
  140. package/src/terragrunt/affected.test.ts +11 -0
  141. package/src/terragrunt/affected.ts +2 -1
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Where a module call keeps its pin, and how the pin moves (#3189).
3
+ *
4
+ * A module call names a module and, if it is pinned, one exact version of it.
5
+ * The version sits in one of two places:
6
+ *
7
+ * - the `version` argument, beside a registry `source`
8
+ * (`source = "app.terraform.io/acme/vpc/aws"`, `version = "1.3.0"`);
9
+ * - a query argument of the `source` itself: OpenTofu's `oci://` sources take
10
+ * `?tag=` or `?digest=`, a git source takes `?ref=`, and Terragrunt's
11
+ * `tfr://` source takes `?version=`.
12
+ *
13
+ * The module's identity is the source with the pin taken out, so
14
+ * `oci://r.example.com/m/vpc?tag=1.3.0` and `oci://r.example.com/m/vpc?tag=1.4.0`
15
+ * call the same module.
16
+ *
17
+ * A `version` constraint (`~> 1.4`, `>= 1.4, < 2`) is not a pin. An upstream
18
+ * release changes what such a root runs with no diff to review, and there is
19
+ * no single version to move, so the call is refused with that reason. The
20
+ * same holds for a call with no pin at all.
21
+ *
22
+ * OpenTofu writes an OCI pin as a query argument
23
+ * (https://opentofu.org/docs/language/modules/sources/#oci-distribution-registries),
24
+ * not as a `:tag` or `@sha256:` suffix on the repository, so the query form is
25
+ * the one read here.
26
+ *
27
+ * This module is pure string work over values the HCL reader has already
28
+ * parsed. Finding those values in a file, and writing the new ones back, is
29
+ * `./edit.ts`. #3190 adds an `oci` kind to the lint classifier
30
+ * (`../lint/post-synth/module-source.ts`) with the same parse; the two can
31
+ * share one once both have landed.
32
+ */
33
+ /** `1.4.0`, `v1.4.0`, `1.4`, `1.4.0-rc.1+build.5`: one exact version. */
34
+ const EXACT_VERSION = /^v?\d+(\.\d+){0,2}(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$/;
35
+ /** An OCI digest, `sha256:<hex>`. */
36
+ const DIGEST = /^[a-z0-9]+(?:[.+_-][a-z0-9]+)*:[0-9a-fA-F]{32,}$/;
37
+ /**
38
+ * The exact version a `version` argument pins, or null when it is a
39
+ * constraint. `1.4.0` and `= 1.4.0` are exact; `~> 1.4`, `>= 1.4` and
40
+ * `1.4.0, < 2` are not.
41
+ */
42
+ export function exactVersion(constraint) {
43
+ const trimmed = constraint.trim().replace(/^=\s*/, "");
44
+ return EXACT_VERSION.test(trimmed) ? trimmed : null;
45
+ }
46
+ /** Whether a value can be a pin's new value: an exact version, a tag, a ref or a digest, never a constraint. */
47
+ export function isPinValue(value) {
48
+ return value.length > 0 && !/[\s,<>~!=*^]/.test(value);
49
+ }
50
+ function splitSource(source) {
51
+ const q = source.indexOf("?");
52
+ if (q < 0)
53
+ return { base: source, params: [] };
54
+ const params = source
55
+ .slice(q + 1)
56
+ .split("&")
57
+ .filter((raw) => raw.length > 0)
58
+ .map((raw) => {
59
+ const eq = raw.indexOf("=");
60
+ return eq < 0 ? { key: raw, value: undefined, raw } : { key: raw.slice(0, eq), value: decode(raw.slice(eq + 1)), raw };
61
+ });
62
+ return { base: source.slice(0, q), params };
63
+ }
64
+ function decode(value) {
65
+ try {
66
+ return decodeURIComponent(value);
67
+ }
68
+ catch {
69
+ return value;
70
+ }
71
+ }
72
+ function joinSource(base, raws) {
73
+ return raws.length === 0 ? base : `${base}?${raws.join("&")}`;
74
+ }
75
+ function isLocal(source) {
76
+ return source.startsWith("./") || source.startsWith("../");
77
+ }
78
+ function isOci(source) {
79
+ return source.startsWith("oci://");
80
+ }
81
+ function isGit(source) {
82
+ return source.startsWith("git::") || source.startsWith("git@") || /^(github\.com|bitbucket\.org)\//.test(source) || /\.git(\/\/|\?|$)/.test(source);
83
+ }
84
+ /** Read the pin a module call carries. `version` is the call's `version` argument, when it has one. */
85
+ export function readModulePin(source, version) {
86
+ const call = readPin(source, version);
87
+ const written = call.at === "version" ? version : call.pin;
88
+ if (written != null && /[$%]\{/.test(written)) {
89
+ return { ...call, pin: null, unpinned: `the pin "${written}" is an expression, so there is no literal version to move` };
90
+ }
91
+ return call;
92
+ }
93
+ function readPin(source, version) {
94
+ if (version !== undefined) {
95
+ const exact = exactVersion(version);
96
+ return exact
97
+ ? { module: source, at: "version", pin: exact }
98
+ : { module: source, at: "version", pin: null, unpinned: `version "${version}" is a constraint, not a pin: there is no one version to move` };
99
+ }
100
+ const { base, params } = splitSource(source);
101
+ const keep = (taken) => joinSource(base, params.filter((p) => p.key !== taken).map((p) => p.raw));
102
+ const find = (key) => params.find((p) => p.key === key);
103
+ if (isOci(source)) {
104
+ const tag = find("tag");
105
+ const digest = find("digest");
106
+ if (tag && digest)
107
+ return { module: joinSource(base, params.filter((p) => p.key !== "tag" && p.key !== "digest").map((p) => p.raw)), at: "source", pin: null, unpinned: "the oci source names both a tag and a digest" };
108
+ const p = digest ?? tag;
109
+ if (!p || !p.value)
110
+ return { module: source, at: "source", pin: null, unpinned: "the oci source names no tag or digest, so it resolves whatever the registry calls latest" };
111
+ return { module: keep(p.key), at: "source", param: p.key, pin: p.value };
112
+ }
113
+ const ref = find("ref");
114
+ if (ref?.value)
115
+ return { module: keep("ref"), at: "source", param: "ref", pin: ref.value };
116
+ const ver = find("version");
117
+ if (ver?.value) {
118
+ const exact = exactVersion(ver.value);
119
+ return exact
120
+ ? { module: keep("version"), at: "source", param: "version", pin: exact }
121
+ : { module: keep("version"), at: "source", param: "version", pin: null, unpinned: `version "${ver.value}" is a constraint, not a pin: there is no one version to move` };
122
+ }
123
+ if (isLocal(source))
124
+ return { module: source, at: "source", pin: null, unpinned: "a local path has no version to pin" };
125
+ if (isGit(source))
126
+ return { module: source, at: "source", pin: null, unpinned: "the git source names no ref, so it follows the default branch" };
127
+ return { module: source, at: "source", pin: null, unpinned: "the source has no version argument and no pin in its address, so it resolves the newest release" };
128
+ }
129
+ /** Replace one query argument's value, keeping every other byte of the source. */
130
+ function withParam(source, from, to, value) {
131
+ const { base, params } = splitSource(source);
132
+ return joinSource(base, params.map((p) => (p.key === from ? `${to}=${value}` : p.raw)));
133
+ }
134
+ /**
135
+ * Move a call's pin from `from` to `to`, when the call names `module`.
136
+ * Returns null when it names another module. `version` keeps any `=` it was
137
+ * written with, so `= 1.3.0` becomes `= 1.4.0`.
138
+ */
139
+ export function movePin(source, version, request) {
140
+ const call = readModulePin(source, version);
141
+ if (call.module !== request.module)
142
+ return null;
143
+ if (call.pin === null)
144
+ return { outcome: "refused", reason: call.unpinned ?? "no pin" };
145
+ if (call.pin === request.to)
146
+ return { outcome: "already", pin: call.pin };
147
+ if (call.pin !== request.from)
148
+ return { outcome: "elsewhere", pin: call.pin };
149
+ if (call.at === "version") {
150
+ const at = version.lastIndexOf(call.pin);
151
+ return { outcome: "moved", from: request.from, to: request.to, source, version: version.slice(0, at) + request.to + version.slice(at + call.pin.length) };
152
+ }
153
+ // An oci pin moving between a tag and a digest changes which argument holds it.
154
+ const param = call.param === "tag" || call.param === "digest" ? (DIGEST.test(request.to) ? "digest" : "tag") : call.param;
155
+ return { outcome: "moved", from: request.from, to: request.to, source: withParam(source, call.param, param, request.to) };
156
+ }
157
+ /** The module a source names, with any pin taken out, so a caller may name the module by a pinned source. */
158
+ export function moduleOf(source) {
159
+ return readModulePin(source).module;
160
+ }
161
+ /** Refuse a request whose `to` is not something a pin can hold. */
162
+ export function checkPinRequest(request) {
163
+ for (const [name, value] of [["from", request.from], ["to", request.to]]) {
164
+ if (!isPinValue(value))
165
+ throw new Error(`pin ${name} "${value}" is not an exact version, tag, ref or digest`);
166
+ }
167
+ if (request.from === request.to)
168
+ throw new Error(`pin from and to are both "${request.to}": there is nothing to move`);
169
+ if (request.module.length === 0)
170
+ throw new Error("pin module is empty: name the module's source without its pin");
171
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * The wave plan for a pin-bump rollout (#3189): which roots move in which PR.
3
+ *
4
+ * Two sources give the same shape, an ordered list of root lists:
5
+ *
6
+ * - **Declared canaries, then dependency order.** The canaries form wave 1.
7
+ * Every other root lands in the earliest wave after every root it depends
8
+ * on. That is the Kahn layering `chant components fan-out` uses
9
+ * (`resolveComponentGraph`, `@intentius/chant/components/layers`), and the
10
+ * order #3049 gives its gated waves. A canary that depends on a root outside
11
+ * the canaries is refused, since wave 1 would apply before what it reads.
12
+ * - **choudoufu's wave planner** (`choudoufu live-waves -json`, choudoufu#1754).
13
+ * It reads cross-estate references from the estates' own configuration, puts
14
+ * `-canary` roots in wave 1, and emits each wave's roots on their own, so
15
+ * {@link wavesFromChoudoufu} takes its document as it is.
16
+ *
17
+ * A root's dependencies come from the caller (`dependsOn`) and, for a
18
+ * Terragrunt unit, from its `dependency` and `dependencies` blocks
19
+ * ({@link terragruntDependencies}). A dependency on a root outside the
20
+ * rollout orders nothing: it is not moving.
21
+ */
22
+ import { posix } from "node:path";
23
+ import { kahnLayers } from "@intentius/chant/components/layers";
24
+ /** Thrown when the roots cannot be split into waves. The message names the roots. */
25
+ export class PinWavePlanError extends Error {
26
+ constructor(message) {
27
+ super(message);
28
+ this.name = "PinWavePlanError";
29
+ }
30
+ }
31
+ /** Canaries first, then dependency order. */
32
+ export function planPinWaves(roots, canaries = []) {
33
+ const names = new Set(roots.map((r) => r.root));
34
+ for (const c of canaries) {
35
+ if (!names.has(c))
36
+ throw new PinWavePlanError(`canary ${c} is not a root of this rollout (roots: ${[...names].sort().join(", ") || "none"})`);
37
+ }
38
+ const canarySet = new Set(canaries);
39
+ const deps = new Map();
40
+ for (const r of roots)
41
+ deps.set(r.root, new Set((r.dependsOn ?? []).filter((d) => names.has(d) && d !== r.root)));
42
+ for (const c of canarySet) {
43
+ const outside = [...deps.get(c)].filter((d) => !canarySet.has(d));
44
+ if (outside.length > 0)
45
+ throw new PinWavePlanError(`canary ${c} depends on ${outside.join(", ")}, which is not a canary, so wave 1 would move it first`);
46
+ }
47
+ const waves = [];
48
+ if (canarySet.size > 0)
49
+ waves.push({ wave: 1, canary: true, roots: [...canarySet].sort() });
50
+ const rest = new Map([...deps].filter(([n]) => !canarySet.has(n)).map(([n, d]) => [n, new Set([...d].filter((x) => !canarySet.has(x)))]));
51
+ const layered = kahnLayers(rest);
52
+ if (layered.cycle)
53
+ throw new PinWavePlanError(`dependency cycle among roots: ${layered.cycle.join(", ")}`);
54
+ for (const roots of layered.waves)
55
+ waves.push({ wave: waves.length + 1, canary: false, roots });
56
+ return waves;
57
+ }
58
+ /**
59
+ * The waves of a `choudoufu live-waves -json` document (format version 1).
60
+ * `prefix` is the directory choudoufu ran in, relative to the repository,
61
+ * when its roots are named relative to that rather than to the repository.
62
+ */
63
+ export function wavesFromChoudoufu(doc, prefix = "") {
64
+ const d = doc;
65
+ if (d?.format_version !== "1")
66
+ throw new PinWavePlanError(`not a choudoufu live-waves document of format version 1 (format_version: ${JSON.stringify(d?.format_version)})`);
67
+ if (!Array.isArray(d.waves))
68
+ throw new PinWavePlanError("the live-waves document has no waves");
69
+ return d.waves.map((w, i) => {
70
+ if (w.wave !== i + 1 || !Array.isArray(w.roots) || w.roots.some((r) => typeof r !== "string")) {
71
+ throw new PinWavePlanError(`live-waves wave ${i + 1} is malformed`);
72
+ }
73
+ return { wave: i + 1, canary: w.canary === true, roots: w.roots.map((r) => (prefix ? posix.join(prefix, r) : posix.normalize(r))).sort() };
74
+ });
75
+ }
76
+ /**
77
+ * Keep only the roots in `keep`, renumbering the waves and dropping any left
78
+ * empty. A wave plan from choudoufu covers every root it was given; the
79
+ * rollout moves only the roots that call the module at the old or new pin.
80
+ */
81
+ export function restrictWaves(waves, keep) {
82
+ const out = [];
83
+ for (const w of waves) {
84
+ const roots = w.roots.filter((r) => keep.has(r));
85
+ if (roots.length > 0)
86
+ out.push({ wave: out.length + 1, canary: w.canary, roots });
87
+ }
88
+ return out;
89
+ }
90
+ /**
91
+ * The roots a Terragrunt unit depends on: each `dependency` block's
92
+ * `config_path` and the `dependencies` block's `paths`, resolved against the
93
+ * unit's directory. A path that is an expression is skipped, since it names
94
+ * no directory until Terragrunt evaluates it.
95
+ */
96
+ export async function terragruntDependencies(root, text, parser) {
97
+ const tree = (await parser.parse(posix.join(root, "terragrunt.hcl"), text));
98
+ const paths = [];
99
+ for (const blocks of Object.values(tree.dependency ?? {}))
100
+ for (const b of blocks)
101
+ if (typeof b.config_path === "string")
102
+ paths.push(b.config_path);
103
+ for (const b of tree.dependencies ?? [])
104
+ if (Array.isArray(b.paths))
105
+ for (const p of b.paths)
106
+ if (typeof p === "string")
107
+ paths.push(p);
108
+ return [...new Set(paths.filter((p) => !p.includes("${")).map((p) => posix.normalize(posix.join(root, p))))].sort();
109
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * A terraform plan's identity (#2300), kept apart from the activities so the
3
+ * change-set adapter (`../change-set.ts`, #3181) can import it without the
4
+ * config loader, the HCL parse or anything else the activities reach. It
5
+ * imports chant's `lifecycle/plan-digest` subpath and nothing more, which is
6
+ * what lets terragucci bundle it into one file (#3421).
7
+ */
8
+ import { computePlanDigest } from "@intentius/chant/lifecycle/plan-digest";
9
+ /**
10
+ * The change set a `terraform show -json` plan describes, canonicalised
11
+ * (#2300): every proposed change, at its address, with the values that would
12
+ * be written, sorted so two renderings of the same plan project identically.
13
+ *
14
+ * Everything else in the document is dropped, and the drops are the point —
15
+ * see `packages/core/src/lifecycle/plan-digest.ts` for the general rule. Here
16
+ * that means `timestamp` and `terraform_version` (when the plan was taken and
17
+ * by which binary), `format_version` (how it was rendered), `prior_state` and
18
+ * `planned_values` (restatements of the same change set from another angle),
19
+ * `configuration` (the source that produced it, which can be refactored
20
+ * without changing a single proposed action) and `checks` / `relevant_
21
+ * attributes` (diagnostics about the plan, not the plan). What is left is
22
+ * what applying it would do.
23
+ */
24
+ export function terraformPlanChangeSet(planJson) {
25
+ const doc = planJson;
26
+ const raw = Array.isArray(doc?.resource_changes) ? doc.resource_changes : [];
27
+ const resourceChanges = raw.map((entry) => {
28
+ const e = (entry ?? {});
29
+ const change = (e.change ?? {});
30
+ return {
31
+ address: e.address,
32
+ mode: e.mode,
33
+ type: e.type,
34
+ name: e.name,
35
+ index: e.index,
36
+ provider_name: e.provider_name,
37
+ deposed: e.deposed,
38
+ actions: change.actions,
39
+ before: change.before,
40
+ after: change.after,
41
+ after_unknown: change.after_unknown,
42
+ replace_paths: change.replace_paths,
43
+ importing: change.importing,
44
+ };
45
+ });
46
+ // terraform already emits these in address order, but nothing in the format
47
+ // promises it, and a digest that depends on the emitter's iteration order
48
+ // would refuse a plan that is identical in every way that matters.
49
+ resourceChanges.sort((a, b) => `${String(a.address)}\u0000${String(a.deposed ?? "")}`.localeCompare(`${String(b.address)}\u0000${String(b.deposed ?? "")}`));
50
+ // Terraform 1.14 `action` blocks: side effects an apply runs. Present only
51
+ // when the plan has some, so the digest of a plan without them is unchanged.
52
+ const invocations = Array.isArray(doc?.action_invocations) ? [...doc.action_invocations].sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))) : [];
53
+ return { resourceChanges, outputChanges: doc?.output_changes ?? {}, ...(invocations.length > 0 ? { actionInvocations: invocations } : {}) };
54
+ }
55
+ /**
56
+ * The identity of a terraform plan (#2300): {@link terraformPlanChangeSet}
57
+ * hashed. This is what a `TerraformApplyOp` gate binds its approval to, and
58
+ * what a later run recomputes and compares.
59
+ *
60
+ * Taken over the `show -json` rendering rather than the plan file's bytes on
61
+ * purpose: the file is an opaque, version-stamped archive, and two runs of
62
+ * `plan -out` over an unchanged root produce different bytes. The rendering
63
+ * is the same plan both times.
64
+ */
65
+ export function terraformPlanDigest(planJson) {
66
+ return computePlanDigest("terraform-plan", terraformPlanChangeSet(planJson));
67
+ }
package/dist/plugin.js ADDED
@@ -0,0 +1,323 @@
1
+ import { terraformBehaviourKinds } from "./behaviour/kinds.js";
2
+ import { createSkillsLoader } from "@intentius/chant/lexicon-plugin-helpers";
3
+ import { terraformSerializer } from "./serializer.js";
4
+ import { rules } from "./lint/rules/index.js";
5
+ import { postSynthChecks } from "./lint/post-synth/index.js";
6
+ import { withCallersChain } from "./lint/post-synth/scope.js";
7
+ import { terraformAuditCatalog } from "./lint/audit-catalog.js";
8
+ import { completions } from "./lsp/completions.js";
9
+ import { hover } from "./lsp/hover.js";
10
+ import { terraformConfigSchema } from "./config.js";
11
+ import { renderTerraformRoots } from "./hcl/roots.js";
12
+ import { existsSync } from "node:fs";
13
+ import { join } from "node:path";
14
+ import { auditRootName, LOCK_FILENAME, parseTerraformRootContent, RESOURCE_TYPE } from "./hcl/parse.js";
15
+ import { descendModules } from "./hcl/descend.js";
16
+ import { TERRAFORM_STATE_OWNERSHIP_KEYS } from "./state-ownership.js";
17
+ import { terraformCommands } from "./commands.js";
18
+ const loadSkills = createSkillsLoader(import.meta.url, [
19
+ {
20
+ file: "chant-terraform.md",
21
+ name: "chant-terraform",
22
+ description: "Read an existing Terraform root module into chant's build and audit, and drive it with the init/plan/apply Ops",
23
+ },
24
+ ]);
25
+ /**
26
+ * The only top-level names Terraform's JSON syntax admits
27
+ * (developer.hashicorp.com/terraform/language/syntax/json). Used as a
28
+ * whole-document test in `detectTemplate`: a `.tf.json` file has these keys
29
+ * and nothing else.
30
+ */
31
+ const TF_JSON_BLOCK_TYPES = new Set([
32
+ "terraform",
33
+ "provider",
34
+ "variable",
35
+ "output",
36
+ "locals",
37
+ "module",
38
+ "resource",
39
+ "data",
40
+ "moved",
41
+ "import",
42
+ "check",
43
+ "removed",
44
+ ]);
45
+ /**
46
+ * Whether raw text is HCL a Terraform root or module is written in. One
47
+ * top-level block header is enough, and every one of these headers is
48
+ * Terraform's own: `terraform {`, and the labelled `resource`/`data`
49
+ * (two labels), `provider`/`module`/`variable`/`output` (one label). `locals`
50
+ * is left out on purpose, since it is the one block a non-Terraform HCL
51
+ * dialect is also likely to carry.
52
+ */
53
+ function looksLikeHcl(text) {
54
+ return (/^[ \t]*terraform[ \t]*\{/m.test(text) ||
55
+ /^[ \t]*(resource|data)[ \t]+"[^"]+"[ \t]+"[^"]+"[ \t]*\{/m.test(text) ||
56
+ /^[ \t]*(provider|module|variable|output)[ \t]+"[^"]+"[ \t]*\{/m.test(text));
57
+ }
58
+ /**
59
+ * terraform lexicon plugin.
60
+ *
61
+ * There is no upstream schema to pin here: Terraform's resource surface lives
62
+ * in provider registries, one schema per provider, and this lexicon reads the
63
+ * HCL an estate already has rather than generating types for it. So `spec/`
64
+ * and `codegen/` stay near-empty and there is no `upstreamPin`.
65
+ */
66
+ export const terraformPlugin = {
67
+ behaviourKinds: terraformBehaviourKinds,
68
+ name: "terraform",
69
+ serializer: terraformSerializer,
70
+ configSchema: terraformConfigSchema,
71
+ // ── Required lifecycle methods ────────────────────────────────
72
+ async generate(options) {
73
+ const { generate } = await import("./codegen/generate.js");
74
+ await generate(options);
75
+ },
76
+ async validate(_options) {
77
+ const { validate } = await import("./validate.js");
78
+ const { printValidationResult } = await import("@intentius/chant/codegen/validate");
79
+ const result = await validate();
80
+ printValidationResult(result);
81
+ },
82
+ async coverage(_options) {
83
+ // No upstream spec to measure against. See the note on the plugin.
84
+ console.error("Coverage analysis not applicable: this lexicon generates no types from an upstream spec");
85
+ },
86
+ async package(options) {
87
+ const { packageLexicon } = await import("./codegen/package.js");
88
+ const { writeBundleSpec } = await import("@intentius/chant/codegen/package");
89
+ const { join, dirname } = await import("path");
90
+ const { fileURLToPath } = await import("url");
91
+ const { spec, stats } = await packageLexicon(options);
92
+ const pkgDir = dirname(dirname(fileURLToPath(import.meta.url)));
93
+ writeBundleSpec(spec, join(pkgDir, "dist"));
94
+ console.error(`Packaged ${stats.resources} resources, ${stats.ruleCount} rules, ${stats.skillCount} skills`);
95
+ },
96
+ // ── Optional extensions ────────────────────────────────────
97
+ /**
98
+ * The ownership channel is per mode, and the channel declared here names
99
+ * the stock one, because a project's roots are stock unless it opts into
100
+ * choudoufu.
101
+ *
102
+ * On a stock root the channel is the state file, not a tag or a label
103
+ * (#2087): every address `terraform show -json` returns is `owned` and
104
+ * everything else is `unknown`. On a live root (#2104) it is choudoufu's
105
+ * two marker tags, `./live-ownership.ts`'s `TERRAFORM_LIVE_MARKER_KEYS`,
106
+ * and the verdicts come from `live-plan -json`'s own sections. Both
107
+ * readings resolve a real verdict on this path, which is what declaring
108
+ * `describeResources` here claims and what the conformance suite checks,
109
+ * for both modes. See `./describe-resources.ts` and
110
+ * `docs/pages/observation.mdx`.
111
+ */
112
+ ownershipChannel: {
113
+ keys: TERRAFORM_STATE_OWNERSHIP_KEYS,
114
+ reads: ["describeResources"],
115
+ },
116
+ async describeResources(options) {
117
+ const { describeResources } = await import("./describe-resources.js");
118
+ return describeResources(options);
119
+ },
120
+ /**
121
+ * The prediction, for whichever estate `options.from` names (#2495). The
122
+ * producer is `predictTerraformBehaviour` and this is its second caller, not
123
+ * a second producer: the contract's options are read as its own and handed
124
+ * over, and it runs with the real reads and core's engine front. What that
125
+ * reading has to put back, for a declared graph and for a live one, is on
126
+ * `terraformPredictOptionsFrom`.
127
+ */
128
+ async predictBehaviour(options) {
129
+ const { predictTerraformBehaviour, terraformPredictOptionsFrom } = await import("./behaviour/index.js");
130
+ return predictTerraformBehaviour(await terraformPredictOptionsFrom(options));
131
+ },
132
+ /**
133
+ * The kinds a live root can enumerate beyond its declaration (#1278,
134
+ * #2104). Empty on a project with no live root, because `live-ls` is the
135
+ * only estate-wide read this lexicon has and a stock root has none: a state
136
+ * file knows what it created and nothing else.
137
+ */
138
+ ambientKinds() {
139
+ return [RESOURCE_TYPE];
140
+ },
141
+ async observeAmbient(options) {
142
+ const { observeAmbient } = await import("./describe-resources.js");
143
+ return observeAmbient(options);
144
+ },
145
+ async teardownOwned(options) {
146
+ const { teardownOwned } = await import("./describe-resources.js");
147
+ return teardownOwned(options);
148
+ },
149
+ /**
150
+ * Each entry in `terraform.roots` is a root module directory that parses at
151
+ * build time into one entity per HCL block. The render lives in
152
+ * `./hcl/roots.ts`; this member only reads the namespace and hands over the
153
+ * project root, which is what relative `dir` paths resolve against.
154
+ */
155
+ async buildRoots(ctx) {
156
+ const namespace = ctx.config.terraform;
157
+ const roots = namespace?.roots ?? {};
158
+ if (Object.keys(roots).length === 0)
159
+ return { entities: new Map(), warnings: [] };
160
+ return renderTerraformRoots({
161
+ projectRoot: ctx.projectRoot,
162
+ roots,
163
+ binary: namespace?.binary,
164
+ callModuleType: namespace?.callModuleType,
165
+ moduleRoot: namespace?.moduleRoot,
166
+ });
167
+ },
168
+ /** `chant terraform pin-rollout` (#3189). */
169
+ commands() {
170
+ return terraformCommands;
171
+ },
172
+ /**
173
+ * The `terraform.roots` a change touched (#3183): a changed file in the
174
+ * root's directory, in a local module it calls, or one of its var files.
175
+ * See `./changed-roots.ts`.
176
+ */
177
+ async changedUnits(ctx) {
178
+ const roots = ctx.config.terraform?.roots ?? {};
179
+ if (Object.keys(roots).length === 0)
180
+ return [];
181
+ const { changedRoots } = await import("./changed-roots.js");
182
+ return changedRoots(ctx.projectRoot, roots, ctx.changedFiles);
183
+ },
184
+ lintRules() {
185
+ return rules;
186
+ },
187
+ /**
188
+ * The generated barrel of checks, each wrapped so a finding inside a
189
+ * descended child module names the call chain that reached it (#2112). See
190
+ * `./lint/post-synth/scope.ts`.
191
+ */
192
+ postSynthChecks() {
193
+ return withCallersChain(postSynthChecks);
194
+ },
195
+ auditCatalog() {
196
+ return terraformAuditCatalog;
197
+ },
198
+ /**
199
+ * `recommended`/`all` (chant #2113), the tflint-ruleset-terraform shape:
200
+ * `recommended` is every `merge-worthy` TF rule (a correctness/security
201
+ * finding worth reporting by default), `all` adds the `report-only` ones
202
+ * (hygiene the family grows into as #2109/#2110/#2112 land). Derived from
203
+ * `terraformAuditCatalog` rather than a hand-kept id list, so a rule that
204
+ * lands in a later, parallel issue is picked up automatically the moment
205
+ * its catalog entry ships, with no second list to fall out of sync.
206
+ */
207
+ lintPresets() {
208
+ const ids = Object.keys(terraformAuditCatalog);
209
+ const recommended = ids.filter((id) => terraformAuditCatalog[id].tier === "merge-worthy");
210
+ return { recommended, all: ids };
211
+ },
212
+ /**
213
+ * Parse-to-graph for `chant audit` (#1567, #2085, #2217). `content` is the
214
+ * `# file: <name>`-joined bundle discovery builds for one discovered root
215
+ * module (`classifyTerraform`, `packages/core/src/audit/discover.ts`), and
216
+ * `input` says where that bundle came from.
217
+ *
218
+ * The scope is named after the input's path (`auditRootName`), so two roots
219
+ * in one repository are two roots here as well: they no longer collide in
220
+ * the merged entity map the all-files pass reads, which is what used to
221
+ * produce a finding keyed `<root>/<address>#2` against the file
222
+ * `(cross-file)` that neither directory deserved.
223
+ *
224
+ * When the audit walked a local filesystem, `input.dir` is the root
225
+ * module's directory and the parse descends its local `module` calls
226
+ * exactly as `buildRoots()` does, through the same `descendModules`
227
+ * (`../hcl/descend.ts`): a child module's entities are keyed
228
+ * `<root>/module.<name>/<address>` and carry the caller chain, so TF014 and
229
+ * TF015 fire here with the same `Callers:` line they print on a build, and
230
+ * the root-only rules (TF001 to TF003) still see only the root's own
231
+ * blocks. `input.baseDir` is the audited directory, which bounds the
232
+ * descent the way the project root bounds it on a build. Discovery drops a
233
+ * directory another one calls as a local module, so no module is audited
234
+ * twice.
235
+ *
236
+ * With no `input.dir` (a fetched repository; a caller parsing a bare
237
+ * string) the bundle is parsed alone, and the two child-module rules have
238
+ * no child to report on, since nothing named one.
239
+ *
240
+ * The descent's own refusals (a registry source, a source outside the
241
+ * audited tree, a cycle) are warnings on a build; this hook returns
242
+ * entities only, so on the audit path they are dropped rather than printed.
243
+ *
244
+ * Never throws: malformed HCL yields an empty map.
245
+ */
246
+ async auditEntities(content, input) {
247
+ try {
248
+ const root = auditRootName(input?.path);
249
+ // A local audit knows the root's directory, so it can say whether the
250
+ // lock file is beside the `.tf` files (TF040); a fetched one cannot.
251
+ const lockFile = input?.dir !== undefined ? existsSync(join(input.dir, LOCK_FILENAME)) : undefined;
252
+ const entities = await parseTerraformRootContent(content, root, undefined, lockFile === undefined ? undefined : { lockFile });
253
+ if (input?.dir === undefined)
254
+ return entities;
255
+ const { entities: children } = await descendModules(entities, {
256
+ dir: input.dir,
257
+ root,
258
+ projectRoot: input.baseDir ?? input.dir,
259
+ });
260
+ for (const [key, entity] of children)
261
+ entities.set(key, entity);
262
+ return entities;
263
+ }
264
+ catch {
265
+ return new Map();
266
+ }
267
+ },
268
+ /**
269
+ * No `mcpTools()` and no `mcpResources()`, deliberately (#2220). Both were
270
+ * declared returning `[]` behind a TODO, which passed the two tier-2
271
+ * "registers <method>" checks on a member that contributed nothing. Neither
272
+ * of the two shapes every other lexicon uses has anything to serve here:
273
+ * `createDiffTool` diffs one build's serializer output against the last,
274
+ * and this serializer writes nothing (`src/serializer.ts` is a deliberate
275
+ * no-op, the `.tf` files are the artifact); `createCatalogResource` serves
276
+ * the generated resource registry, and this lexicon generates none
277
+ * (`src/codegen/generate.ts` writes `{}` on purpose). So the members are
278
+ * removed rather than faked, and `chant dev check-lexicon` reports two
279
+ * tier-2 gaps that are real.
280
+ */
281
+ skills: loadSkills,
282
+ /**
283
+ * `.tf` (and Terraform's own JSON syntax, `.tf.json`) recognised by content
284
+ * rather than by which directory a file sits in (#2220).
285
+ *
286
+ * Two input shapes, because the two callers hand over different things.
287
+ * `chant import` (`packages/core/src/cli/commands/import.ts`) `JSON.parse`s
288
+ * the file first, so it arrives as an object; the audit's content detection
289
+ * (`packages/core/src/audit/discover.ts`) hands azure its raw string, and a
290
+ * `.tf` file is not JSON at all, so the raw string form is the one that
291
+ * matters for HCL. Anything else reads as false.
292
+ *
293
+ * The object form is exact rather than heuristic: Terraform's JSON syntax
294
+ * admits only the top-level names in {@link TF_JSON_BLOCK_TYPES}, so a
295
+ * document is `.tf.json` when every key is one of them and at least one is
296
+ * present. That refuses a Kubernetes manifest, a CloudFormation template and
297
+ * a Compose file without having to name any of them.
298
+ *
299
+ * Discovery still classifies Terraform by the directory bundle
300
+ * (`classifyTerraform`), because a root module is a directory of files that
301
+ * has to be parsed together and one `.tf` on its own is not an audit input.
302
+ * This makes the per-file question answerable for the callers that ask it
303
+ * one file at a time.
304
+ */
305
+ detectTemplate(data) {
306
+ if (typeof data === "string")
307
+ return looksLikeHcl(data);
308
+ if (typeof data !== "object" || data === null || Array.isArray(data))
309
+ return false;
310
+ const keys = Object.keys(data);
311
+ return keys.length > 0 && keys.every((k) => TF_JSON_BLOCK_TYPES.has(k));
312
+ },
313
+ completionProvider(ctx) {
314
+ return completions(ctx);
315
+ },
316
+ hoverProvider(ctx) {
317
+ return hover(ctx);
318
+ },
319
+ async docs(options) {
320
+ const { generateDocs } = await import("./codegen/docs.js");
321
+ return generateDocs(options);
322
+ },
323
+ };