@intentius/chant-lexicon-terraform 0.57.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 (131) hide show
  1. package/README.md +53 -0
  2. package/dist/codegen/docs-cli.d.ts +3 -0
  3. package/dist/codegen/docs-cli.d.ts.map +1 -0
  4. package/dist/codegen/docs.d.ts +11 -0
  5. package/dist/codegen/docs.d.ts.map +1 -0
  6. package/dist/codegen/generate-cli.d.ts +3 -0
  7. package/dist/codegen/generate-cli.d.ts.map +1 -0
  8. package/dist/codegen/generate.d.ts +28 -0
  9. package/dist/codegen/generate.d.ts.map +1 -0
  10. package/dist/codegen/package.d.ts +17 -0
  11. package/dist/codegen/package.d.ts.map +1 -0
  12. package/dist/composites/terraform-apply-op.d.ts +88 -0
  13. package/dist/composites/terraform-apply-op.d.ts.map +1 -0
  14. package/dist/composites/terraform-watch-op.d.ts +124 -0
  15. package/dist/composites/terraform-watch-op.d.ts.map +1 -0
  16. package/dist/config.d.ts +83 -0
  17. package/dist/config.d.ts.map +1 -0
  18. package/dist/describe-resources.d.ts +127 -0
  19. package/dist/describe-resources.d.ts.map +1 -0
  20. package/dist/generated/index.d.ts +2 -0
  21. package/dist/generated/index.d.ts.map +1 -0
  22. package/dist/hcl/parse.d.ts +87 -0
  23. package/dist/hcl/parse.d.ts.map +1 -0
  24. package/dist/hcl/roots.d.ts +37 -0
  25. package/dist/hcl/roots.d.ts.map +1 -0
  26. package/dist/index.d.ts +12 -0
  27. package/dist/index.d.ts.map +1 -0
  28. package/dist/integrity.json +12 -0
  29. package/dist/lint/audit-catalog.d.ts +12 -0
  30. package/dist/lint/audit-catalog.d.ts.map +1 -0
  31. package/dist/lint/audit-lineage.d.ts +21 -0
  32. package/dist/lint/audit-lineage.d.ts.map +1 -0
  33. package/dist/lint/post-synth/index.d.ts +3 -0
  34. package/dist/lint/post-synth/index.d.ts.map +1 -0
  35. package/dist/lint/post-synth/tf001.d.ts +17 -0
  36. package/dist/lint/post-synth/tf001.d.ts.map +1 -0
  37. package/dist/lint/rules/index.d.ts +5 -0
  38. package/dist/lint/rules/index.d.ts.map +1 -0
  39. package/dist/lint/rules/plan-before-apply.d.ts +30 -0
  40. package/dist/lint/rules/plan-before-apply.d.ts.map +1 -0
  41. package/dist/lsp/completions.d.ts +26 -0
  42. package/dist/lsp/completions.d.ts.map +1 -0
  43. package/dist/lsp/context.d.ts +70 -0
  44. package/dist/lsp/context.d.ts.map +1 -0
  45. package/dist/lsp/hover.d.ts +12 -0
  46. package/dist/lsp/hover.d.ts.map +1 -0
  47. package/dist/lsp/option-keys.d.ts +34 -0
  48. package/dist/lsp/option-keys.d.ts.map +1 -0
  49. package/dist/manifest.json +6 -0
  50. package/dist/meta.json +1 -0
  51. package/dist/okf/index.md +8 -0
  52. package/dist/okf/rules/TF001.md +11 -0
  53. package/dist/okf/rules/TF101.md +11 -0
  54. package/dist/op/activities/index.d.ts +22 -0
  55. package/dist/op/activities/index.d.ts.map +1 -0
  56. package/dist/op/activities/terraform.d.ts +213 -0
  57. package/dist/op/activities/terraform.d.ts.map +1 -0
  58. package/dist/op/builders.d.ts +56 -0
  59. package/dist/op/builders.d.ts.map +1 -0
  60. package/dist/package-cli.d.ts +3 -0
  61. package/dist/package-cli.d.ts.map +1 -0
  62. package/dist/plugin.d.ts +11 -0
  63. package/dist/plugin.d.ts.map +1 -0
  64. package/dist/rules/plan-before-apply.ts +143 -0
  65. package/dist/rules/tf001.ts +66 -0
  66. package/dist/serializer.d.ts +19 -0
  67. package/dist/serializer.d.ts.map +1 -0
  68. package/dist/skills/chant-terraform.md +92 -0
  69. package/dist/state-ownership.d.ts +27 -0
  70. package/dist/state-ownership.d.ts.map +1 -0
  71. package/dist/types/index.d.ts +2 -0
  72. package/dist/validate-cli.d.ts +3 -0
  73. package/dist/validate-cli.d.ts.map +1 -0
  74. package/dist/validate.d.ts +15 -0
  75. package/dist/validate.d.ts.map +1 -0
  76. package/package.json +75 -0
  77. package/src/__fixtures__/no-backend/main.tf +24 -0
  78. package/src/__fixtures__/show-state.json +68 -0
  79. package/src/__fixtures__/with-backend/main.tf +28 -0
  80. package/src/__fixtures__/with-module/main.tf +49 -0
  81. package/src/__fixtures__/with-module/modules/inner/main.tf +5 -0
  82. package/src/codegen/docs-cli.ts +4 -0
  83. package/src/codegen/docs.ts +50 -0
  84. package/src/codegen/generate-cli.ts +10 -0
  85. package/src/codegen/generate.ts +68 -0
  86. package/src/codegen/package.ts +50 -0
  87. package/src/composites/terraform-apply-op.acceptance.test.ts +138 -0
  88. package/src/composites/terraform-apply-op.test.ts +193 -0
  89. package/src/composites/terraform-apply-op.ts +166 -0
  90. package/src/composites/terraform-watch-op.test.ts +184 -0
  91. package/src/composites/terraform-watch-op.ts +204 -0
  92. package/src/config.ts +76 -0
  93. package/src/describe-resources.test.ts +342 -0
  94. package/src/describe-resources.ts +357 -0
  95. package/src/generated/index.d.ts +2 -0
  96. package/src/generated/index.ts +4 -0
  97. package/src/generated/lexicon-terraform.json +1 -0
  98. package/src/hcl/parse.ts +235 -0
  99. package/src/hcl/roots.ts +67 -0
  100. package/src/index.ts +65 -0
  101. package/src/lint/audit-catalog.ts +30 -0
  102. package/src/lint/audit-lineage.ts +21 -0
  103. package/src/lint/audit.test.ts +45 -0
  104. package/src/lint/post-synth/index.ts +7 -0
  105. package/src/lint/post-synth/post-synth.test.ts +95 -0
  106. package/src/lint/post-synth/tf001.ts +66 -0
  107. package/src/lint/rules/index.ts +7 -0
  108. package/src/lint/rules/plan-before-apply.test.ts +111 -0
  109. package/src/lint/rules/plan-before-apply.ts +143 -0
  110. package/src/lsp/completions.test.ts +120 -0
  111. package/src/lsp/completions.ts +101 -0
  112. package/src/lsp/context.test.ts +152 -0
  113. package/src/lsp/context.ts +349 -0
  114. package/src/lsp/hover.test.ts +82 -0
  115. package/src/lsp/hover.ts +44 -0
  116. package/src/lsp/option-keys.ts +106 -0
  117. package/src/op/activities/index.ts +48 -0
  118. package/src/op/activities/registry.test.ts +29 -0
  119. package/src/op/activities/terraform.test.ts +445 -0
  120. package/src/op/activities/terraform.ts +469 -0
  121. package/src/op/builders.test.ts +90 -0
  122. package/src/op/builders.ts +96 -0
  123. package/src/package-cli.ts +21 -0
  124. package/src/plugin.test.ts +271 -0
  125. package/src/plugin.ts +157 -0
  126. package/src/serializer.test.ts +26 -0
  127. package/src/serializer.ts +26 -0
  128. package/src/skills/chant-terraform.md +92 -0
  129. package/src/state-ownership.ts +32 -0
  130. package/src/validate-cli.ts +5 -0
  131. package/src/validate.ts +28 -0
@@ -0,0 +1,357 @@
1
+ /**
2
+ * Live observation for declared terraform entities (#2087).
3
+ *
4
+ * `buildRoots()` turns each configured root module into one entity per HCL
5
+ * block, keyed `<root>/<address>` (`./hcl/parse.ts`). This reader answers the
6
+ * lifecycle question for those entities by running `terraform show -json` over
7
+ * the root's current state and matching addresses.
8
+ *
9
+ * ## The state file is the ownership answer
10
+ *
11
+ * Every other lexicon in chant stamps a tag or a label at synthesis and reads
12
+ * it back off the live resource. Terraform stamps nothing, and there is
13
+ * nowhere to stamp: a resource's provider-side tags are the practitioner's
14
+ * own, and writing chant's marker into them would edit an estate this lexicon
15
+ * promises never to write. What terraform has instead is the thing chant
16
+ * elsewhere refuses to host, a trusted state file that already records
17
+ * exactly which addresses this configuration manages.
18
+ *
19
+ * So the answer here is state membership. An address `terraform show -json`
20
+ * returns is `owned`; anything else is `unknown`. Concretely that makes a
21
+ * declared `module` block `unknown`: the state carries the module's
22
+ * resources, never a row for the block itself, so chant can see the block is
23
+ * live without the state ever saying that block is managed. `unknown` never
24
+ * escalates to a delete, which is the correct posture for a thing chant did
25
+ * not read a verdict for.
26
+ *
27
+ * That places terraform on the trusted-state-file row of the third axis in
28
+ * docs/src/content/docs/concepts/lifecycle-models.mdx, where chant elsewhere
29
+ * sits on the live-marker row. `docs/pages/observation.mdx` is the reader's
30
+ * version of this paragraph.
31
+ *
32
+ * ## What is readable at all
33
+ *
34
+ * `values.root_module.resources[]` (and, recursively, `child_modules[]`)
35
+ * carries `resource` and `data` blocks. A `terraform`, `provider`, `variable`,
36
+ * `output` or `locals` block has no row there and reads `unsupported-kind`.
37
+ * That is honest, not a gap to close by inventing an address for something
38
+ * that has none.
39
+ *
40
+ * ## Tri-state (#1089)
41
+ *
42
+ * A root whose `init` or `show` fails reports EVERY entity declared in that
43
+ * root as not-observed with reason `read-failed` and the root named, never
44
+ * absent. A failed read must never render as a list of creates. Roots are
45
+ * read independently and merged (`mergeObservations`), so one broken backend
46
+ * does not un-observe a root that answered.
47
+ *
48
+ * ## Nothing from `values` is surfaced
49
+ *
50
+ * A state row's `values` are the resource's full attribute set, secrets
51
+ * included, and `sensitive_values` describes only what the configuration
52
+ * declared sensitive. So the attributes reported here are the row's identity
53
+ * (address, type, mode, provider, root) and nothing from `values` except the
54
+ * `id`, which is the physical id every observation carries.
55
+ */
56
+
57
+ import type { DescribeResourcesResult } from "@intentius/chant/lexicon";
58
+ import {
59
+ mergeObservations,
60
+ normalizeObservation,
61
+ observation,
62
+ observeEntities,
63
+ type DeclaredEntity,
64
+ type EntityObservation,
65
+ type ObserverAdapter,
66
+ } from "@intentius/chant/observation";
67
+ import { terraformInit, terraformShow } from "./op/activities/terraform";
68
+ import { DATA_TYPE, MODULE_TYPE, RESOURCE_TYPE } from "./hcl/parse";
69
+
70
+ // The channel keys live in their own module so `plugin.ts` can declare
71
+ // `ownershipChannel` without loading this reader. Re-exported here because
72
+ // this is where they are used.
73
+ export { TERRAFORM_STATE_OWNERSHIP_KEYS } from "./state-ownership";
74
+
75
+ /** One row out of `values.root_module.resources[]`, at any module depth. */
76
+ export interface StateResourceRow {
77
+ /** Fully qualified terraform address, `module.<name>.` prefixes included. */
78
+ address: string;
79
+ /** `managed` for a `resource` block, `data` for a `data` block. */
80
+ mode?: string;
81
+ /** Resource type, e.g. `null_resource`. */
82
+ type?: string;
83
+ /** Provider that owns the row, e.g. `registry.terraform.io/hashicorp/null`. */
84
+ providerName?: string;
85
+ /** `values.id`, when the row carries a string id. Nothing else from `values` is read. */
86
+ id?: string;
87
+ }
88
+
89
+ /** What one root's state read produced. */
90
+ export interface StateIndex {
91
+ /** Every resource/data row, keyed by fully qualified address. */
92
+ rows: Map<string, StateResourceRow>;
93
+ /** Every `module.<name>` address the state carries a child module for, at any depth. */
94
+ modules: Set<string>;
95
+ }
96
+
97
+ function asRecord(value: unknown): Record<string, unknown> {
98
+ return typeof value === "object" && value !== null && !Array.isArray(value)
99
+ ? (value as Record<string, unknown>)
100
+ : {};
101
+ }
102
+
103
+ function asArray(value: unknown): unknown[] {
104
+ return Array.isArray(value) ? value : [];
105
+ }
106
+
107
+ function asString(value: unknown): string | undefined {
108
+ return typeof value === "string" && value.length > 0 ? value : undefined;
109
+ }
110
+
111
+ /**
112
+ * Index a `terraform show -json` document by address.
113
+ *
114
+ * Terraform already writes fully qualified addresses inside `child_modules`
115
+ * (`module.cdn.null_resource.edge`, as `src/__fixtures__/show-state.json`
116
+ * records), so the module prefix is applied only when a row's own address is
117
+ * missing it. Belt and braces for an older `format_version`, never a second
118
+ * `module.cdn.` on top of the first.
119
+ */
120
+ export function indexStateResources(showJson: unknown): StateIndex {
121
+ const rows = new Map<string, StateResourceRow>();
122
+ const modules = new Set<string>();
123
+
124
+ const walk = (module: unknown, prefix: string): void => {
125
+ const node = asRecord(module);
126
+ for (const entry of asArray(node.resources)) {
127
+ const row = asRecord(entry);
128
+ const raw = asString(row.address);
129
+ if (!raw) continue;
130
+ const address = prefix && !raw.startsWith(`${prefix}.`) ? `${prefix}.${raw}` : raw;
131
+ rows.set(address, {
132
+ address,
133
+ ...(asString(row.mode) ? { mode: asString(row.mode) } : {}),
134
+ ...(asString(row.type) ? { type: asString(row.type) } : {}),
135
+ ...(asString(row.provider_name) ? { providerName: asString(row.provider_name) } : {}),
136
+ ...(asString(asRecord(row.values).id) ? { id: asString(asRecord(row.values).id) } : {}),
137
+ });
138
+ }
139
+ for (const entry of asArray(node.child_modules)) {
140
+ const child = asRecord(entry);
141
+ const address = asString(child.address) ?? prefix;
142
+ if (address) modules.add(address);
143
+ walk(child, address ?? "");
144
+ }
145
+ };
146
+
147
+ walk(asRecord(asRecord(showJson).values).root_module, "");
148
+ return { rows, modules };
149
+ }
150
+
151
+ /**
152
+ * The ownership verdict for one declared address: `owned` when the state
153
+ * carries a row for it, `unknown` otherwise. Exported because it is the whole
154
+ * of terraform's ownership channel, and a channel with one rule deserves one
155
+ * function to point at.
156
+ */
157
+ export function classifyStateOwnership(address: string, index: StateIndex): "owned" | "unknown" {
158
+ return index.rows.has(address) ? "owned" : "unknown";
159
+ }
160
+
161
+ /** True when the state carries at least one resource beneath `module.<name>`. */
162
+ function moduleIsLive(address: string, index: StateIndex): boolean {
163
+ if (index.modules.has(address)) return true;
164
+ for (const key of index.rows.keys()) if (key.startsWith(`${address}.`)) return true;
165
+ return false;
166
+ }
167
+
168
+ /** The activity pair this reader drives. Injectable so tests never run terraform. */
169
+ export interface TerraformReadDeps {
170
+ init: typeof terraformInit;
171
+ show: typeof terraformShow;
172
+ }
173
+
174
+ const REAL_DEPS: TerraformReadDeps = { init: terraformInit, show: terraformShow };
175
+
176
+ /** A declared entity, plus the root and address `buildRoots()` recorded on it. */
177
+ interface TerraformDeclared extends DeclaredEntity {
178
+ root: string;
179
+ address: string;
180
+ }
181
+
182
+ /**
183
+ * Split a `<root>/<address>` entity key. Only a fallback: `buildRoots()`
184
+ * records both on `props`, and a duplicated address is keyed `…~2` there, so
185
+ * the props are the reliable source.
186
+ */
187
+ function fromEntityName(name: string): { root: string; address: string } {
188
+ const slash = name.indexOf("/");
189
+ if (slash === -1) return { root: "", address: name };
190
+ return { root: name.slice(0, slash), address: name.slice(slash + 1).replace(/~\d+$/, "") };
191
+ }
192
+
193
+ function declaredOf(name: string, entity: { entityType: string; props: Record<string, unknown> } | undefined): TerraformDeclared {
194
+ const props = entity?.props ?? {};
195
+ const fallback = fromEntityName(name);
196
+ return {
197
+ name,
198
+ type: entity?.entityType ?? "",
199
+ props,
200
+ root: asString(props.root) ?? fallback.root,
201
+ address: asString(props.address) ?? fallback.address,
202
+ };
203
+ }
204
+
205
+ /** One root's reader: `init`, then `show` over state, then address matching. */
206
+ function adapter(root: string, cwd: string | undefined, deps: TerraformReadDeps): ObserverAdapter<StateIndex> {
207
+ const where = cwd ? { cwd } : {};
208
+ let dir: string | undefined;
209
+
210
+ return {
211
+ async bind(): Promise<StateIndex> {
212
+ // `init` first: `show` against an uninitialized root reports no state at
213
+ // all, which would read as "everything is absent", the exact failure
214
+ // the tri-state exists to prevent.
215
+ const initialized = await deps.init({ root, ...where });
216
+ dir = initialized.dir;
217
+ const shown = await deps.show({ root, ...where });
218
+ dir = shown.dir;
219
+ return indexStateResources(shown.json);
220
+ },
221
+
222
+ classifyBindFailure(err) {
223
+ const message = err instanceof Error ? err.message.split("\n")[0] : String(err);
224
+ return {
225
+ reason: "read-failed",
226
+ detail: `terraform.roots.${root}${dir ? ` (${dir})` : ""}: ${message}`,
227
+ };
228
+ },
229
+
230
+ async read(index, entity): Promise<EntityObservation> {
231
+ const { address } = entity as TerraformDeclared;
232
+ const queried = `terraform show -json (root "${root}", address "${address}")`;
233
+
234
+ if (entity.type === RESOURCE_TYPE || entity.type === DATA_TYPE) {
235
+ const row = index.rows.get(address);
236
+ if (!row) return { absent: true, queried };
237
+ return {
238
+ present: {
239
+ type: entity.type,
240
+ physicalId: row.id ?? address,
241
+ status: row.mode ?? "managed",
242
+ ownership: classifyStateOwnership(address, index),
243
+ attributes: {
244
+ address,
245
+ root,
246
+ ...(row.type ? { resourceType: row.type } : {}),
247
+ ...(row.mode ? { mode: row.mode } : {}),
248
+ ...(row.providerName ? { provider: row.providerName } : {}),
249
+ },
250
+ },
251
+ queried,
252
+ };
253
+ }
254
+
255
+ if (entity.type === MODULE_TYPE) {
256
+ if (!moduleIsLive(address, index)) return { absent: true, queried };
257
+ // The state has resources under this module but no row for the block
258
+ // itself, so state membership, which is the whole ownership channel
259
+ // here, has nothing to say about it. See the module doc.
260
+ return {
261
+ present: {
262
+ type: entity.type,
263
+ physicalId: address,
264
+ status: "module",
265
+ ownership: classifyStateOwnership(address, index),
266
+ attributes: { address, root },
267
+ },
268
+ queried,
269
+ };
270
+ }
271
+
272
+ return {
273
+ unobserved: {
274
+ reason: "unsupported-kind",
275
+ detail: `${entity.type} has no row in terraform state: only resource and data blocks do`,
276
+ },
277
+ queried,
278
+ };
279
+ },
280
+ };
281
+ }
282
+
283
+ export interface DescribeResourcesOptions {
284
+ environment: string;
285
+ buildOutput: string;
286
+ entityNames: string[];
287
+ entities: Map<string, { entityType: string; props: Record<string, unknown> }>;
288
+ /**
289
+ * Restrict to state-backed entities (#1348). A withheld entity is
290
+ * `filtered`, never a silent drop into `absent`: a live module block still
291
+ * exists, chant just has no state row saying it is managed.
292
+ */
293
+ owned?: boolean;
294
+ /**
295
+ * Directory the activities start the `chant.config.*` search from. Default:
296
+ * the running process's cwd. Same meaning as `TerraformWatchOpConfig.cwd`.
297
+ */
298
+ cwd?: string;
299
+ }
300
+
301
+ /**
302
+ * Observe every declared terraform entity, one `init` + `show` per configured
303
+ * root. Roots are read independently, so a broken backend on one never
304
+ * un-observes another (`mergeObservations`).
305
+ */
306
+ export async function describeResources(
307
+ options: DescribeResourcesOptions,
308
+ deps: TerraformReadDeps = REAL_DEPS,
309
+ ): Promise<DescribeResourcesResult> {
310
+ const byRoot = new Map<string, TerraformDeclared[]>();
311
+ const rootless: TerraformDeclared[] = [];
312
+
313
+ for (const name of options.entityNames) {
314
+ const declared = declaredOf(name, options.entities.get(name));
315
+ if (!declared.root) {
316
+ rootless.push(declared);
317
+ continue;
318
+ }
319
+ const bucket = byRoot.get(declared.root);
320
+ if (bucket) bucket.push(declared);
321
+ else byRoot.set(declared.root, [declared]);
322
+ }
323
+
324
+ const parts = [];
325
+ for (const [root, declared] of byRoot) {
326
+ parts.push(normalizeObservation(await observeEntities(declared, adapter(root, options.cwd, deps))));
327
+ }
328
+
329
+ const merged = mergeObservations(parts);
330
+ const resources = { ...merged.resources };
331
+ const unobserved = { ...merged.unobserved };
332
+
333
+ // An entity carrying no root came from somewhere other than `buildRoots()`;
334
+ // there is no state file to look it up in, and saying so is not absence.
335
+ for (const entity of rootless) {
336
+ unobserved[entity.name] = {
337
+ ...(entity.type ? { type: entity.type } : {}),
338
+ reason: "unsupported-kind",
339
+ detail: "no `terraform.roots` entry on this entity, so there is no root module state to read it from",
340
+ };
341
+ }
342
+
343
+ if (options.owned) {
344
+ for (const [name, meta] of Object.entries(merged.resources)) {
345
+ if (meta.ownership === "owned") continue;
346
+ delete resources[name];
347
+ unobserved[name] = {
348
+ type: meta.type,
349
+ reason: "filtered",
350
+ detail: "the root's state carries no row for this address and --owned was requested",
351
+ ...(merged.queried[name] ? { queried: merged.queried[name] } : {}),
352
+ };
353
+ }
354
+ }
355
+
356
+ return observation(resources, unobserved, merged.queried, merged.notes);
357
+ }
@@ -0,0 +1,2 @@
1
+ // Code generated by chant generate. DO NOT EDIT.
2
+ export {};
@@ -0,0 +1,4 @@
1
+ // Code generated by chant generate. DO NOT EDIT.
2
+ // The terraform lexicon models no upstream schema, so there are no resource
3
+ // classes to export. See src/codegen/generate.ts.
4
+ export {};
@@ -0,0 +1 @@
1
+ {}
@@ -0,0 +1,235 @@
1
+ /**
2
+ * The one shared HCL parse for the terraform lexicon.
3
+ *
4
+ * Two entry points, one entity shape. `parseTerraformRootDir` reads the `.tf`
5
+ * files of a directory (non-recursive, matching Terraform's own module
6
+ * scoping) and is what `buildRoots()` calls. `parseTerraformRootContent` takes
7
+ * the joined-file string `chant audit` hands a lexicon for a discovered root
8
+ * module: every `.tf` in filename order behind a `# file: <name>` line comment.
9
+ * Both funnel through `blocksToEntities`, so an entity the audit path sees is
10
+ * the entity the build path sees.
11
+ *
12
+ * Core owns the parser glue: `loadHcl2json()` lazy-loads `@cdktf/hcl2json`
13
+ * (a ~1.8 MB wasm blob) and raises a one-line install hint when it is absent.
14
+ * `parseTerraformDir()` next to it is NOT reused: it returns carve's `TfGraph`,
15
+ * a scoring-and-excision shape with no room for the per-block bodies a
16
+ * serializer and the post-synth checks read. The import path is the wildcard
17
+ * core's exports map already carries (`"./*"` to `./src/*.ts`), so nothing
18
+ * changed in `packages/core/package.json` for this.
19
+ */
20
+
21
+ import { readdirSync, readFileSync } from "node:fs";
22
+ import { join } from "node:path";
23
+ import { loadHcl2json, type Hcl2Json } from "@intentius/chant/terraform/parse";
24
+ import { DECLARABLE_MARKER, type Declarable } from "@intentius/chant/declarable";
25
+
26
+ /** A parsed HCL block body, as `@cdktf/hcl2json` encodes it. */
27
+ export type BlockBody = Record<string, unknown>;
28
+
29
+ /** The entity every block becomes. `props` is what post-synth checks read. */
30
+ export interface TerraformEntity extends Declarable {
31
+ readonly lexicon: "terraform";
32
+ readonly kind: "resource";
33
+ readonly props: {
34
+ /** Terraform address, e.g. `aws_s3_bucket.assets`, `var.region`, `module.cdn`. */
35
+ readonly address: string;
36
+ /** The block body, verbatim from hcl2json (interpolations survive as `"${...}"`). */
37
+ readonly body: BlockBody;
38
+ /** File the block came from, as named by the parse input. */
39
+ readonly file: string;
40
+ /** Configured root name this block belongs to. */
41
+ readonly root: string;
42
+ };
43
+ }
44
+
45
+ /** `entityType` per block kind. */
46
+ export const TERRAFORM_TYPE = "Terraform::Terraform";
47
+ export const PROVIDER_TYPE = "Terraform::Provider";
48
+ export const RESOURCE_TYPE = "Terraform::Resource";
49
+ export const DATA_TYPE = "Terraform::Data";
50
+ export const MODULE_TYPE = "Terraform::Module";
51
+ export const VARIABLE_TYPE = "Terraform::Variable";
52
+ export const OUTPUT_TYPE = "Terraform::Output";
53
+ export const LOCALS_TYPE = "Terraform::Locals";
54
+
55
+ /**
56
+ * Build one entity. Written out rather than run through `createResource`
57
+ * from `@intentius/chant/runtime`: that factory is for generated resource
58
+ * classes and hides `props` behind a non-enumerable descriptor plus an
59
+ * `attrMap` of `AttrRef`s this lexicon has nothing to put in. The literal
60
+ * carries the same marker, so `isDeclarable()` and `isResourceDeclarable()`
61
+ * both hold.
62
+ */
63
+ export function terraformEntity(
64
+ entityType: string,
65
+ address: string,
66
+ body: BlockBody,
67
+ file: string,
68
+ root: string,
69
+ ): TerraformEntity {
70
+ return {
71
+ [DECLARABLE_MARKER]: true,
72
+ lexicon: "terraform",
73
+ entityType,
74
+ kind: "resource",
75
+ props: { address, body, file, root },
76
+ };
77
+ }
78
+
79
+ /** One `.tf` file's name and source. */
80
+ export interface TerraformFile {
81
+ name: string;
82
+ source: string;
83
+ }
84
+
85
+ /** The `# file: <name>` boundary `chant audit` writes between joined `.tf` files. */
86
+ const FILE_MARKER = /^# file: (.+)$/;
87
+
88
+ /**
89
+ * Split the joined form back into files. A string with no marker at all is one
90
+ * anonymous file, so a caller that hands over a single `.tf` still parses.
91
+ */
92
+ export function splitBundleContent(content: string, fallbackName = "main.tf"): TerraformFile[] {
93
+ const files: TerraformFile[] = [];
94
+ let current: TerraformFile | undefined;
95
+ for (const line of content.split("\n")) {
96
+ const marker = FILE_MARKER.exec(line);
97
+ if (marker) {
98
+ current = { name: marker[1].trim(), source: "" };
99
+ files.push(current);
100
+ continue;
101
+ }
102
+ if (!current) {
103
+ current = { name: fallbackName, source: "" };
104
+ files.push(current);
105
+ }
106
+ current.source += current.source === "" ? line : `\n${line}`;
107
+ }
108
+ return files.filter((f) => f.source.trim() !== "");
109
+ }
110
+
111
+ /** Every `.tf` directly under `dir`, in filename order. Non-recursive. */
112
+ export function listTerraformFiles(dir: string): string[] {
113
+ return readdirSync(dir)
114
+ .filter((f) => f.endsWith(".tf"))
115
+ .sort();
116
+ }
117
+
118
+ function asArray(value: unknown): unknown[] {
119
+ return Array.isArray(value) ? value : [];
120
+ }
121
+
122
+ function asRecord(value: unknown): Record<string, unknown> {
123
+ return typeof value === "object" && value !== null && !Array.isArray(value)
124
+ ? (value as Record<string, unknown>)
125
+ : {};
126
+ }
127
+
128
+ /**
129
+ * Parse a set of files into entities keyed `<root>/<address>`. Two blocks that
130
+ * genuinely share an address (two `locals` blocks, or the same address in two
131
+ * files of one root) are numbered `~2`, `~3` rather than overwriting.
132
+ */
133
+ export async function blocksToEntities(
134
+ files: readonly TerraformFile[],
135
+ root: string,
136
+ hcl2json?: Hcl2Json,
137
+ ): Promise<Map<string, Declarable>> {
138
+ const parser = hcl2json ?? (await loadHcl2json());
139
+ const entities = new Map<string, Declarable>();
140
+
141
+ const add = (entityType: string, address: string, body: unknown, file: string): void => {
142
+ const entity = terraformEntity(
143
+ entityType,
144
+ address,
145
+ (typeof body === "object" && body !== null ? body : {}) as BlockBody,
146
+ file,
147
+ root,
148
+ );
149
+ let key = `${root}/${address}`;
150
+ for (let n = 2; entities.has(key); n++) key = `${root}/${address}~${n}`;
151
+ entities.set(key, entity);
152
+ };
153
+
154
+ /** `terraform` and `locals` carry no labels: the tree holds a bare body array. */
155
+ const unlabelled = (
156
+ tree: Record<string, unknown>,
157
+ section: string,
158
+ entityType: string,
159
+ file: string,
160
+ ): void => {
161
+ for (const body of asArray(tree[section])) add(entityType, section, body, file);
162
+ };
163
+
164
+ /** `provider`, `module`, `variable`, `output`: one label, so `Record<name, body[]>`. */
165
+ const oneLabel = (
166
+ tree: Record<string, unknown>,
167
+ section: string,
168
+ entityType: string,
169
+ address: (name: string) => string,
170
+ file: string,
171
+ ): void => {
172
+ for (const [name, bodies] of Object.entries(asRecord(tree[section]))) {
173
+ for (const body of asArray(bodies)) add(entityType, address(name), body, file);
174
+ }
175
+ };
176
+
177
+ /** `resource`, `data`: two labels, so `Record<type, Record<name, body[]>>`. */
178
+ const twoLabels = (
179
+ tree: Record<string, unknown>,
180
+ section: string,
181
+ entityType: string,
182
+ address: (type: string, name: string) => string,
183
+ file: string,
184
+ ): void => {
185
+ for (const [type, named] of Object.entries(asRecord(tree[section]))) {
186
+ for (const [name, bodies] of Object.entries(asRecord(named))) {
187
+ for (const body of asArray(bodies)) add(entityType, address(type, name), body, file);
188
+ }
189
+ }
190
+ };
191
+
192
+ for (const file of files) {
193
+ const tree = (await parser.parse(file.name, file.source)) as Record<string, unknown>;
194
+ unlabelled(tree, "terraform", TERRAFORM_TYPE, file.name);
195
+ unlabelled(tree, "locals", LOCALS_TYPE, file.name);
196
+ oneLabel(tree, "provider", PROVIDER_TYPE, (n) => `provider.${n}`, file.name);
197
+ oneLabel(tree, "module", MODULE_TYPE, (n) => `module.${n}`, file.name);
198
+ oneLabel(tree, "variable", VARIABLE_TYPE, (n) => `var.${n}`, file.name);
199
+ oneLabel(tree, "output", OUTPUT_TYPE, (n) => `output.${n}`, file.name);
200
+ twoLabels(tree, "resource", RESOURCE_TYPE, (t, n) => `${t}.${n}`, file.name);
201
+ twoLabels(tree, "data", DATA_TYPE, (t, n) => `data.${t}.${n}`, file.name);
202
+ }
203
+
204
+ return entities;
205
+ }
206
+
207
+ /**
208
+ * Parse a root module directory. Reads every `.tf` directly under `dir`.
209
+ * Throws whatever the parser throws for malformed HCL; `buildRoots()` is where
210
+ * that becomes a warning.
211
+ */
212
+ export async function parseTerraformRootDir(
213
+ dir: string,
214
+ root: string,
215
+ hcl2json?: Hcl2Json,
216
+ ): Promise<Map<string, Declarable>> {
217
+ const files = listTerraformFiles(dir).map((name) => ({
218
+ name,
219
+ source: readFileSync(join(dir, name), "utf-8"),
220
+ }));
221
+ return blocksToEntities(files, root, hcl2json);
222
+ }
223
+
224
+ /**
225
+ * Parse the joined-file string form `chant audit` produces for a discovered
226
+ * root module (`AuditInput.content`). Unwired for now: #2085's
227
+ * `auditEntities()` is what calls it.
228
+ */
229
+ export async function parseTerraformRootContent(
230
+ content: string,
231
+ root: string,
232
+ hcl2json?: Hcl2Json,
233
+ ): Promise<Map<string, Declarable>> {
234
+ return blocksToEntities(splitBundleContent(content), root, hcl2json);
235
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Terraform build roots.
3
+ *
4
+ * An estate that keeps its `.tf` tree has no typed chant source for it. What
5
+ * it can declare is the directory: each entry in `terraform.roots` names a
6
+ * root module that parses at build time into entities, so the blocks are
7
+ * serialized into the build output and seen by the post-synth checks. Same
8
+ * shape `lexicons/k8s/src/kustomize/root.ts` uses for kustomize overlays: the
9
+ * render lives here, the plugin's `buildRoots()` member stays thin.
10
+ *
11
+ * Unlike the kustomize renderer, a missing directory or a `.tf` the parser
12
+ * refuses is a warning and zero entities for that root, never a throw. Reading
13
+ * someone else's estate is the whole job here, and half of it parsing is more
14
+ * useful than none of it, especially when the audit path (#2085) walks
15
+ * repositories it did not write.
16
+ */
17
+
18
+ import { existsSync } from "node:fs";
19
+ import { isAbsolute, resolve } from "node:path";
20
+ import type { Declarable } from "@intentius/chant/declarable";
21
+ import type { Hcl2Json } from "@intentius/chant/terraform/parse";
22
+ import type { TerraformRootConfig } from "../config";
23
+ import { parseTerraformRootDir } from "./parse";
24
+
25
+ export interface TerraformRootsResult {
26
+ entities: Map<string, Declarable>;
27
+ warnings: string[];
28
+ }
29
+
30
+ export interface RenderTerraformRootsOptions {
31
+ /** Directory the project config was loaded from; relative `dir` resolves against it. */
32
+ projectRoot: string;
33
+ /** `terraform.roots`: name to root-module config. */
34
+ roots: Readonly<Record<string, TerraformRootConfig>>;
35
+ /** Injectable parser (tests); defaults to core's lazy-loaded `@cdktf/hcl2json`. */
36
+ hcl2json?: Hcl2Json;
37
+ }
38
+
39
+ /**
40
+ * Parse each configured root into entities keyed `<root>/<address>`. Roots are
41
+ * visited in declaration order, and one root's failure never stops the next.
42
+ */
43
+ export async function renderTerraformRoots(
44
+ opts: RenderTerraformRootsOptions,
45
+ ): Promise<TerraformRootsResult> {
46
+ const entities = new Map<string, Declarable>();
47
+ const warnings: string[] = [];
48
+
49
+ for (const [name, root] of Object.entries(opts.roots)) {
50
+ const dir = isAbsolute(root.dir) ? root.dir : resolve(opts.projectRoot, root.dir);
51
+
52
+ if (!existsSync(dir)) {
53
+ warnings.push(`terraform.roots.${name}: directory not found at ${dir}, no entities contributed`);
54
+ continue;
55
+ }
56
+
57
+ try {
58
+ const parsed = await parseTerraformRootDir(dir, name, opts.hcl2json);
59
+ for (const [key, entity] of parsed) entities.set(key, entity);
60
+ } catch (err) {
61
+ const message = err instanceof Error ? err.message : String(err);
62
+ warnings.push(`terraform.roots.${name}: could not parse ${dir}, ${message}`);
63
+ }
64
+ }
65
+
66
+ return { entities, warnings };
67
+ }