@intentius/chant-lexicon-terraform 0.124.45 → 0.124.48

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 (93) hide show
  1. package/dist/__fixtures__/capabilities/chant.config.d.ts +26 -0
  2. package/dist/__fixtures__/capabilities/chant.config.d.ts.map +1 -0
  3. package/dist/__fixtures__/capabilities/chant.config.js +13 -0
  4. package/dist/__fixtures__/effective-topology/chant.config.d.ts +13 -0
  5. package/dist/__fixtures__/effective-topology/chant.config.d.ts.map +1 -0
  6. package/dist/__fixtures__/effective-topology/chant.config.js +9 -0
  7. package/dist/__fixtures__/kind-type/chant.config.d.ts +13 -0
  8. package/dist/__fixtures__/kind-type/chant.config.d.ts.map +1 -0
  9. package/dist/__fixtures__/kind-type/chant.config.js +9 -0
  10. package/dist/__fixtures__/search-roots/chant.config.d.ts +13 -0
  11. package/dist/__fixtures__/search-roots/chant.config.d.ts.map +1 -0
  12. package/dist/__fixtures__/search-roots/chant.config.js +9 -0
  13. package/dist/__fixtures__/sensitive-default/chant.config.d.ts +13 -0
  14. package/dist/__fixtures__/sensitive-default/chant.config.d.ts.map +1 -0
  15. package/dist/__fixtures__/sensitive-default/chant.config.js +9 -0
  16. package/dist/capabilities/adapter.d.ts +9 -0
  17. package/dist/capabilities/adapter.d.ts.map +1 -0
  18. package/dist/capabilities/adapter.js +25 -0
  19. package/dist/capabilities/mapping.json +166 -0
  20. package/dist/config.d.ts +52 -0
  21. package/dist/config.d.ts.map +1 -1
  22. package/dist/config.js +23 -0
  23. package/dist/describe-resources.d.ts +101 -8
  24. package/dist/describe-resources.d.ts.map +1 -1
  25. package/dist/describe-resources.js +395 -56
  26. package/dist/hcl/parse.d.ts +10 -0
  27. package/dist/hcl/parse.d.ts.map +1 -1
  28. package/dist/hcl/parse.js +20 -2
  29. package/dist/hcl/redact.d.ts +26 -0
  30. package/dist/hcl/redact.d.ts.map +1 -0
  31. package/dist/hcl/redact.js +228 -0
  32. package/dist/integrity.json +2 -2
  33. package/dist/manifest.json +1 -1
  34. package/dist/op/activities/terraform.d.ts +109 -4
  35. package/dist/op/activities/terraform.d.ts.map +1 -1
  36. package/dist/op/activities/terraform.js +148 -7
  37. package/dist/plan-observation.d.ts +153 -0
  38. package/dist/plan-observation.d.ts.map +1 -0
  39. package/dist/plan-observation.js +354 -0
  40. package/dist/plugin.d.ts.map +1 -1
  41. package/dist/plugin.js +2 -0
  42. package/package.json +2 -2
  43. package/src/__fixtures__/ACCEPTANCE.md +53 -1
  44. package/src/__fixtures__/capabilities/catalog/main.tf +105 -0
  45. package/src/__fixtures__/capabilities/chant.config.ts +15 -0
  46. package/src/__fixtures__/capabilities/findings/main.tf +225 -0
  47. package/src/__fixtures__/capabilities/ledger/main.tf +265 -0
  48. package/src/__fixtures__/capabilities/package.json +1 -0
  49. package/src/__fixtures__/effective-topology/chant.config.ts +11 -0
  50. package/src/__fixtures__/effective-topology/net/main.tf +176 -0
  51. package/src/__fixtures__/effective-topology/package.json +6 -0
  52. package/src/__fixtures__/kind-type/app/main.tf +35 -0
  53. package/src/__fixtures__/kind-type/chant.config.ts +11 -0
  54. package/src/__fixtures__/kind-type/package.json +6 -0
  55. package/src/__fixtures__/live-plan-depth/README.md +59 -0
  56. package/src/__fixtures__/live-plan-depth/live-plan.json +110 -0
  57. package/src/__fixtures__/live-plan-depth/show-plan-sensitive.json +1 -0
  58. package/src/__fixtures__/live-plan-depth/show-plan.json +1 -0
  59. package/src/__fixtures__/plan-read/main.tf +46 -0
  60. package/src/__fixtures__/plan-read/next.tfvars +1 -0
  61. package/src/__fixtures__/plan-read-drift/README.md +33 -0
  62. package/src/__fixtures__/plan-read-drift/main.tf +63 -0
  63. package/src/__fixtures__/plan-read-drift/next.tfvars +1 -0
  64. package/src/__fixtures__/search-roots/chant.config.ts +11 -0
  65. package/src/__fixtures__/search-roots/net/main.tf +73 -0
  66. package/src/__fixtures__/search-roots/package.json +6 -0
  67. package/src/__fixtures__/sensitive-default/chant.config.ts +11 -0
  68. package/src/__fixtures__/sensitive-default/net/main.tf +38 -0
  69. package/src/__fixtures__/sensitive-default/package.json +6 -0
  70. package/src/__fixtures__/show-plan.json +663 -0
  71. package/src/capabilities/adapter.ts +32 -0
  72. package/src/capabilities/mapping.json +166 -0
  73. package/src/capabilities-workspace.e2e.test.ts +251 -0
  74. package/src/capabilities.test.ts +264 -0
  75. package/src/config.ts +26 -0
  76. package/src/describe-resources.live-plan.test.ts +302 -0
  77. package/src/describe-resources.live-root-unchanged.test.ts +126 -0
  78. package/src/describe-resources.plan.test.ts +437 -0
  79. package/src/describe-resources.ts +445 -52
  80. package/src/effective-topology.test.ts +134 -0
  81. package/src/hcl/parse.ts +30 -2
  82. package/src/hcl/redact.ts +181 -0
  83. package/src/live-plan-depth.acceptance.e2e.test.ts +193 -0
  84. package/src/op/activities/terraform.test.ts +118 -1
  85. package/src/op/activities/terraform.ts +213 -9
  86. package/src/plan-observation.ts +428 -0
  87. package/src/plan-read.acceptance.test.ts +184 -0
  88. package/src/plugin.ts +2 -0
  89. package/src/redact-ir.e2e.test.ts +79 -0
  90. package/src/redact.test.ts +111 -0
  91. package/src/search-build-roots.test.ts +125 -0
  92. package/src/search-count.test.ts +144 -0
  93. package/src/search-kind-type.test.ts +136 -0
@@ -0,0 +1,354 @@
1
+ /**
2
+ * The plan depth's projection (#4358): a saved plan's `show -json` document,
3
+ * turned into the attributes an observed terraform block carries.
4
+ *
5
+ * The state read (`terraform show -json` over state) says what the last apply
6
+ * recorded. A plan says what the next apply would do, and four facts exist
7
+ * only there:
8
+ *
9
+ * | plan document | IR attribute |
10
+ * |---|---|
11
+ * | `resource_changes[].change.actions` | `planAction`, `planActions` |
12
+ * | `resource_changes[].change.replace_paths`, `resource_changes[].action_reason` | `planReplacePaths`, `planActionReason` |
13
+ * | `resource_changes[].change.after_unknown` | `planAfterUnknown` |
14
+ * | `resource_drift[]`, `relevant_attributes[]` | `planDrift`, `planDriftAction`, `planRelevantAttributes` |
15
+ *
16
+ * plus `planChanges`, the attribute-level before and after of each planned
17
+ * update or replace. `PLAN_ATTRIBUTE_NAMES` lists them for a consumer.
18
+ *
19
+ * ## Paths
20
+ *
21
+ * Every attribute path is one string: object keys joined with `.`, list
22
+ * indexes and keys that are not identifiers in brackets, so
23
+ * `lifecycle_commands[0].read`, `tags["kubernetes.io/role"]`. A `chant search
24
+ * attr:planReplacePaths=triggers_replace` term matches by substring over that.
25
+ *
26
+ * ## Sensitive values never leave this module
27
+ *
28
+ * `before_sensitive` and `after_sensitive` are trees parallel to `before` and
29
+ * `after`: `true` at a node marks that node and everything under it. A value
30
+ * under a `true` node is replaced with `(sensitive)` before it is put on an
31
+ * attribute, at any depth, so a sensitive value nested in a map is caught
32
+ * exactly like a sensitive top-level attribute. Only leaves are ever emitted,
33
+ * never a whole object, so no unwalked subtree can carry a secret past the
34
+ * check.
35
+ *
36
+ * A flag on either side redacts both sides of that path. On a stock root the
37
+ * two sides are flagged alike, because state records which paths were
38
+ * sensitive. On a choudoufu live root (#4371) they are not: prior state is
39
+ * rebuilt from the account on every run and carries no marks from the
40
+ * configuration, so an attribute made sensitive by a `sensitive` variable is
41
+ * flagged on `after` only, while `before` holds the live value, which is the
42
+ * previous secret. Recorded in `__fixtures__/live-plan-depth/`.
43
+ *
44
+ * Flags alone are not enough, as a real plan shows: `terraform_data` copies
45
+ * `input` to `output`, and the plan flags `input.token` sensitive while
46
+ * leaving `output.token` unflagged with the same value in it. So every string
47
+ * found under a sensitive flag anywhere in the document is collected first,
48
+ * and any emitted value equal to one of them is redacted too. That catches a
49
+ * copy; it cannot catch a secret a provider transformed (hashed, embedded in a
50
+ * longer string), and the observation page says so.
51
+ *
52
+ * Nothing from `prior_state` is surfaced but each row's `id`, which is the
53
+ * state read's rule too.
54
+ */
55
+ /** What stands in for a value flagged sensitive, or equal to one that was. */
56
+ export const SENSITIVE_VALUE = "(sensitive)";
57
+ /** OpenTofu's and Terraform's own phrase for a value the plan cannot know yet. */
58
+ export const KNOWN_AFTER_APPLY = "(known after apply)";
59
+ /**
60
+ * Every attribute the plan depth adds to an observed block, beside the state
61
+ * read's `address`, `root`, `resourceType`, `mode` and `provider`. A block the
62
+ * plan does not touch carries `planAction: "no-op"` and none of the optional
63
+ * ones.
64
+ */
65
+ export const PLAN_ATTRIBUTE_NAMES = [
66
+ "planAction",
67
+ "planActions",
68
+ "planActionReason",
69
+ "planReplacePaths",
70
+ "planAfterUnknown",
71
+ "planChanges",
72
+ "planDrift",
73
+ "planDriftAction",
74
+ "planRelevantAttributes",
75
+ ];
76
+ function isRecord(value) {
77
+ return typeof value === "object" && value !== null && !Array.isArray(value);
78
+ }
79
+ function asString(value) {
80
+ return typeof value === "string" && value.length > 0 ? value : undefined;
81
+ }
82
+ /** Render one path segment list as a single string. */
83
+ export function renderPath(segments) {
84
+ let out = "";
85
+ for (const segment of segments) {
86
+ if (typeof segment === "number")
87
+ out += `[${segment}]`;
88
+ else if (/^[A-Za-z_][A-Za-z0-9_-]*$/.test(segment))
89
+ out += out ? `.${segment}` : segment;
90
+ else
91
+ out += `[${JSON.stringify(segment)}]`;
92
+ }
93
+ return out;
94
+ }
95
+ /** A path from the document (`replace_paths[]`, `relevant_attributes[].attribute`), rendered. */
96
+ function documentPath(value) {
97
+ if (!Array.isArray(value))
98
+ return undefined;
99
+ const segments = value.filter((s) => typeof s === "string" || typeof s === "number");
100
+ return segments.length > 0 ? renderPath(segments) : undefined;
101
+ }
102
+ /** The child of a parallel boolean tree at `key`. `true` is inherited by every descendant. */
103
+ function child(tree, key) {
104
+ if (tree === true)
105
+ return true;
106
+ if (Array.isArray(tree))
107
+ return typeof key === "number" ? tree[key] : undefined;
108
+ if (isRecord(tree))
109
+ return tree[String(key)];
110
+ return undefined;
111
+ }
112
+ function same(a, b) {
113
+ if (a === b)
114
+ return true;
115
+ if (Array.isArray(a) && Array.isArray(b))
116
+ return a.length === b.length && a.every((x, i) => same(x, b[i]));
117
+ if (isRecord(a) && isRecord(b)) {
118
+ const ka = Object.keys(a);
119
+ return ka.length === Object.keys(b).length && ka.every((k) => Object.prototype.hasOwnProperty.call(b, k) && same(a[k], b[k]));
120
+ }
121
+ return false;
122
+ }
123
+ /** Every string leaf under a `true` node of `flags`, into `out`. */
124
+ function collectSensitive(value, flags, out) {
125
+ if (flags === true) {
126
+ const walk = (v) => {
127
+ if (typeof v === "string" && v.length > 0)
128
+ out.add(v);
129
+ else if (Array.isArray(v))
130
+ v.forEach(walk);
131
+ else if (isRecord(v))
132
+ Object.values(v).forEach(walk);
133
+ };
134
+ walk(value);
135
+ return;
136
+ }
137
+ if (Array.isArray(value))
138
+ value.forEach((v, i) => collectSensitive(v, child(flags, i), out));
139
+ else if (isRecord(value))
140
+ for (const [k, v] of Object.entries(value))
141
+ collectSensitive(v, child(flags, k), out);
142
+ }
143
+ /** Every path where `tree` is `true`, as rendered paths. */
144
+ function truePaths(tree, at = []) {
145
+ if (tree === true)
146
+ return at.length > 0 ? [renderPath(at)] : ["*"];
147
+ if (Array.isArray(tree))
148
+ return tree.flatMap((v, i) => truePaths(v, [...at, i]));
149
+ if (isRecord(tree))
150
+ return Object.entries(tree).flatMap(([k, v]) => truePaths(v, [...at, k]));
151
+ return [];
152
+ }
153
+ /** A scalar for an emitted change: redacted when flagged or equal to a known sensitive string. */
154
+ function shown(value, flagged, secrets) {
155
+ if (flagged)
156
+ return SENSITIVE_VALUE;
157
+ if (value === undefined)
158
+ return null;
159
+ if (typeof value === "string" && secrets.has(value))
160
+ return SENSITIVE_VALUE;
161
+ return value;
162
+ }
163
+ const isContainer = (v) => Array.isArray(v) || isRecord(v);
164
+ /**
165
+ * The leaf-level differences between two sides of one change. Exported for
166
+ * the redaction tests; see the module doc for the rules.
167
+ */
168
+ export function diffChange(change, secrets) {
169
+ const out = [];
170
+ const walk = (before, after, bs, as, au, at) => {
171
+ const path = renderPath(at) || "*";
172
+ // Unknown until apply. The before side is still walked to its leaves when
173
+ // it is a container, so each leaf keeps its own sensitivity, and the
174
+ // after side of every leaf is the phrase.
175
+ if (au === true) {
176
+ const flagged = bs === true || as === true;
177
+ if (!flagged && isContainer(before) && Object.keys(before).length > 0) {
178
+ const keys = Array.isArray(before) ? before.map((_, i) => i) : Object.keys(before).sort();
179
+ for (const key of keys) {
180
+ walk(before[key], undefined, child(bs, key), child(as, key), true, [...at, key]);
181
+ }
182
+ return;
183
+ }
184
+ out.push({ path, before: isContainer(before) && !flagged ? before : shown(before, flagged, secrets), after: KNOWN_AFTER_APPLY });
185
+ return;
186
+ }
187
+ if (same(before, after))
188
+ return;
189
+ // A flagged node is emitted whole, as `(sensitive)`, and never walked:
190
+ // there is nothing under it to show. A container beside a flag on the
191
+ // other side is redacted too rather than walked without its flags.
192
+ // Both sides are redacted when either is flagged; see the module doc for
193
+ // the live root whose `before` is never flagged.
194
+ if (bs === true || as === true) {
195
+ const side = (v) => (v === undefined || v === null ? null : SENSITIVE_VALUE);
196
+ out.push({ path, before: side(before), after: side(after) });
197
+ return;
198
+ }
199
+ if (Array.isArray(before) || Array.isArray(after)) {
200
+ const b = Array.isArray(before) ? before : [];
201
+ const a = Array.isArray(after) ? after : [];
202
+ for (let i = 0; i < Math.max(b.length, a.length); i++) {
203
+ walk(b[i], a[i], child(bs, i), child(as, i), child(au, i), [...at, i]);
204
+ }
205
+ return;
206
+ }
207
+ if (isRecord(before) || isRecord(after)) {
208
+ const b = isRecord(before) ? before : {};
209
+ const a = isRecord(after) ? after : {};
210
+ for (const key of [...new Set([...Object.keys(b), ...Object.keys(a)])].sort()) {
211
+ walk(b[key], a[key], child(bs, key), child(as, key), child(au, key), [...at, key]);
212
+ }
213
+ return;
214
+ }
215
+ out.push({ path, before: shown(before, false, secrets), after: shown(after, false, secrets) });
216
+ };
217
+ walk(change.before, change.after, change.before_sensitive, change.after_sensitive, change.after_unknown, []);
218
+ return out;
219
+ }
220
+ function actionsOf(change) {
221
+ return Array.isArray(change.actions) ? change.actions.filter((a) => typeof a === "string") : [];
222
+ }
223
+ /**
224
+ * Index a `show -json <planfile>` document. Unknown fields are ignored and a
225
+ * missing section reads as empty, the same tolerance the state read has.
226
+ */
227
+ export function indexPlan(document) {
228
+ const doc = isRecord(document) ? document : {};
229
+ const rawChanges = Array.isArray(doc.resource_changes) ? doc.resource_changes.filter(isRecord) : [];
230
+ const rawDrift = Array.isArray(doc.resource_drift) ? doc.resource_drift.filter(isRecord) : [];
231
+ // Pass one: every string under a sensitive flag, anywhere in the document.
232
+ // Each side is read under both sides' flags, so a live root's unflagged
233
+ // `before` value at a path `after` flags is a secret too (#4371).
234
+ const secrets = new Set();
235
+ for (const entry of [...rawChanges, ...rawDrift]) {
236
+ const change = isRecord(entry.change) ? entry.change : {};
237
+ for (const flags of [change.before_sensitive, change.after_sensitive]) {
238
+ collectSensitive(change.before, flags, secrets);
239
+ collectSensitive(change.after, flags, secrets);
240
+ }
241
+ }
242
+ const relevant = new Map();
243
+ for (const entry of Array.isArray(doc.relevant_attributes) ? doc.relevant_attributes.filter(isRecord) : []) {
244
+ const address = asString(entry.resource);
245
+ const path = documentPath(entry.attribute);
246
+ if (!address || !path)
247
+ continue;
248
+ const list = relevant.get(address) ?? [];
249
+ if (!list.includes(path))
250
+ list.push(path);
251
+ relevant.set(address, list);
252
+ }
253
+ const changes = new Map();
254
+ for (const entry of rawChanges) {
255
+ const address = asString(entry.address);
256
+ if (!address || !isRecord(entry.change))
257
+ continue;
258
+ const change = entry.change;
259
+ const replacePaths = (Array.isArray(change.replace_paths) ? change.replace_paths : [])
260
+ .map(documentPath)
261
+ .filter((p) => p !== undefined);
262
+ const actions = actionsOf(change);
263
+ const updates = actions.includes("update") || (actions.includes("create") && actions.includes("delete"));
264
+ changes.set(address, {
265
+ address,
266
+ actions,
267
+ ...(asString(entry.action_reason) ? { actionReason: asString(entry.action_reason) } : {}),
268
+ replacePaths,
269
+ afterUnknown: truePaths(change.after_unknown),
270
+ // Only an update or a replace compares two states of one object. A
271
+ // create has no before and a delete no after: neither is an attribute
272
+ // that moved, and both are already the action itself.
273
+ changes: updates ? diffChange(change, secrets) : [],
274
+ });
275
+ }
276
+ const drift = new Map();
277
+ for (const entry of rawDrift) {
278
+ const address = asString(entry.address);
279
+ if (!address || !isRecord(entry.change))
280
+ continue;
281
+ const reads = relevant.get(address) ?? [];
282
+ drift.set(address, {
283
+ address,
284
+ actions: actionsOf(entry.change),
285
+ changes: diffChange(entry.change, secrets).map((c) => reads.some((r) => c.path === r || c.path.startsWith(`${r}.`) || c.path.startsWith(`${r}[`))
286
+ ? { ...c, relevant: true }
287
+ : c),
288
+ });
289
+ }
290
+ return { priorState: doc.prior_state, changes, drift, relevant };
291
+ }
292
+ /**
293
+ * The block address an instance address belongs to: every instance key
294
+ * removed, on the resource and on each module call, so `module.a[0].x.y["k"]`
295
+ * belongs to `module.a.x.y`.
296
+ */
297
+ export function blockAddressOf(instance) {
298
+ return instance.replace(/\[(?:"(?:[^"\\]|\\.)*"|\d+)\]/g, "");
299
+ }
300
+ /** One action list as one word: `replace` for either order of delete and create. */
301
+ export function actionWord(actions) {
302
+ if (actions.includes("create") && actions.includes("delete"))
303
+ return "replace";
304
+ if (actions.length === 0)
305
+ return "no-op";
306
+ return actions.join(",");
307
+ }
308
+ /**
309
+ * The plan attributes of one declared block, aggregated over its instances.
310
+ * `instances` are the block's instance addresses in `prior_state`; a block in
311
+ * the plan that has none there is a planned create, which the reader reports
312
+ * absent and annotates with {@link plannedAttributesFor} instead.
313
+ */
314
+ export function planAttributesFor(address, index) {
315
+ const mine = (map) => [...map].filter(([instance]) => blockAddressOf(instance) === address).sort(([a], [b]) => a.localeCompare(b));
316
+ const changes = mine(index.changes);
317
+ const drift = mine(index.drift);
318
+ const relevant = mine(index.relevant);
319
+ const many = new Set([...changes, ...drift].map(([instance]) => instance)).size > 1;
320
+ const tag = (instance, c) => many || instance !== address ? { ...c, instance } : c;
321
+ const words = [...new Set(changes.map(([, c]) => actionWord(c.actions)))].sort();
322
+ const reasons = [...new Set(changes.map(([, c]) => c.actionReason).filter((r) => !!r))].sort();
323
+ const replacePaths = [...new Set(changes.flatMap(([, c]) => c.replacePaths))].sort();
324
+ const afterUnknown = [...new Set(changes.flatMap(([, c]) => c.afterUnknown))].sort();
325
+ const valueChanges = changes.flatMap(([instance, c]) => c.changes.map((v) => tag(instance, v)));
326
+ const driftChanges = drift.flatMap(([instance, d]) => d.changes.map((v) => tag(instance, v)));
327
+ const driftWords = [...new Set(drift.map(([, d]) => actionWord(d.actions)))].sort();
328
+ const relevantPaths = [...new Set(relevant.flatMap(([, paths]) => paths))].sort();
329
+ return {
330
+ planAction: words.length > 0 ? words.join(",") : "no-op",
331
+ ...(changes.length > 0 ? { planActions: Object.fromEntries(changes.map(([i, c]) => [i, c.actions])) } : {}),
332
+ ...(reasons.length > 0 ? { planActionReason: reasons.join(",") } : {}),
333
+ ...(replacePaths.length > 0 ? { planReplacePaths: replacePaths } : {}),
334
+ ...(afterUnknown.length > 0 ? { planAfterUnknown: afterUnknown } : {}),
335
+ ...(valueChanges.length > 0 ? { planChanges: valueChanges } : {}),
336
+ ...(drift.length > 0 ? { planDriftAction: driftWords.join(","), planDrift: driftChanges } : {}),
337
+ ...(relevantPaths.length > 0 ? { planRelevantAttributes: relevantPaths } : {}),
338
+ };
339
+ }
340
+ /**
341
+ * The plan attributes of a block that has no row in `prior_state` (#4372),
342
+ * or undefined when the plan has no change for it either. The usual case is
343
+ * a planned create: `planAction: "create"`, `planActions`, and
344
+ * `planAfterUnknown` naming what stays unknown until apply (its `id` among
345
+ * them). A data block read during apply comes back as `planAction: "read"`.
346
+ *
347
+ * The reader puts these on the observation's `annotations`, not on a
348
+ * `resources` row, so the block stays absent and `lifecycle plan` still
349
+ * proposes the create.
350
+ */
351
+ export function plannedAttributesFor(address, index) {
352
+ const planned = [...index.changes.keys()].some((instance) => blockAddressOf(instance) === address);
353
+ return planned ? planAttributesFor(address, index) : undefined;
354
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../src/plugin.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAsB,aAAa,EAAE,MAAM,0BAA0B,CAAC;AAmElF;;;;;;;GAOG;AACH,eAAO,MAAM,eAAe,EAAE,aAqR7B,CAAC"}
1
+ {"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../src/plugin.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAsB,aAAa,EAAE,MAAM,0BAA0B,CAAC;AAoElF;;;;;;;GAOG;AACH,eAAO,MAAM,eAAe,EAAE,aAsR7B,CAAC"}
package/dist/plugin.js CHANGED
@@ -15,6 +15,7 @@ import { auditRootName, LOCK_FILENAME, parseTerraformRootContent, RESOURCE_TYPE
15
15
  import { descendModules } from "./hcl/descend.js";
16
16
  import { TERRAFORM_STATE_OWNERSHIP_KEYS } from "./state-ownership.js";
17
17
  import { terraformCommands } from "./commands.js";
18
+ import { terraformCapabilityPack } from "./capabilities/adapter.js";
18
19
  const loadSkills = createSkillsLoader(import.meta.url, [
19
20
  {
20
21
  file: "chant-terraform.md",
@@ -66,6 +67,7 @@ function looksLikeHcl(text) {
66
67
  export const terraformPlugin = {
67
68
  behaviourKinds: terraformBehaviourKinds,
68
69
  name: "terraform",
70
+ capabilityPack: terraformCapabilityPack,
69
71
  serializer: terraformSerializer,
70
72
  configSchema: terraformConfigSchema,
71
73
  // ── Required lifecycle methods ────────────────────────────────
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant-lexicon-terraform",
3
- "version": "0.124.45",
3
+ "version": "0.124.48",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src/",
@@ -67,7 +67,7 @@
67
67
  "access": "public"
68
68
  },
69
69
  "peerDependencies": {
70
- "@intentius/chant": "^0.124.45",
70
+ "@intentius/chant": "^0.124.48",
71
71
  "zod": "^4.3.6"
72
72
  },
73
73
  "description": "terraform lexicon for chant, existing Terraform root modules as declarable entities",
@@ -1,6 +1,6 @@
1
1
  # What the gated acceptance suites have actually run against
2
2
 
3
- Five `describe` blocks across three files run this lexicon against a real
3
+ Eight `describe` blocks across five files run this lexicon against a real
4
4
  binary rather than a fixture, and every one of them skips rather than fails
5
5
  when its dependency is absent. A skip is silent by design, so without this
6
6
  file nothing in the tree says which of them has ever passed, against what, or
@@ -211,6 +211,58 @@ With the Init phase back, the same run reaches its gate:
211
211
  its gate
212
212
  ```
213
213
 
214
+ ### 6. `the plan depth against a real terraform` / `tofu`
215
+
216
+ `../plan-read.acceptance.test.ts`, one block per binary, three tests each, over
217
+ the `plan-read/` fixture (built-in `terraform_data`, `local` backend, so no
218
+ registry, no network and no credentials). The first reads the plan depth
219
+ through `describeResources` and checks the IR attributes, `sources` reading
220
+ `"plan"`, no secret in the result, nothing new or changed in the root, and no
221
+ `chant-terraform-plan-*` directory left in the temp dir. The second holds the
222
+ state lock with an `apply` waiting at its prompt, shows a plan without
223
+ `-lock=false` failing on `Error acquiring the state lock`, and reads through
224
+ it. The third refuses a never-initialised root with the init line and writes
225
+ nothing in it.
226
+
227
+ Gated on `terraform` / `tofu` on PATH, each block on its own binary.
228
+
229
+ ```
230
+ npx vitest run lexicons/terraform/src/plan-read.acceptance.test.ts
231
+ ```
232
+
233
+ Last passed: 2026-10-23, `Terraform v1.15.8` and `OpenTofu v1.12.5` on
234
+ `darwin_arm64`, 6 passed, by chant #4358. Proved red the same day: with
235
+ `-lock=false` taken out of `terraformPlanReadCommand`, both lock tests failed
236
+ on `Error acquiring the state lock`.
237
+
238
+ ### 7. `the plan depth on a live root against a real choudoufu`
239
+
240
+ `../live-plan-depth.acceptance.e2e.test.ts` (#4371), one test, in the e2e
241
+ project because it takes about a minute. It copies `live-estate/` under a
242
+ fresh estate name, applies it with three files held back, creates the two
243
+ unowned log groups, drifts a log group's retention and a VPC tag out of band,
244
+ and changes the value of a `sensitive` variable the VPC carries as a tag. Then
245
+ one `describeResources` at the plan depth with the real activities: bound
246
+ blocks carry the plan's attributes (the drift as a planned `update`),
247
+ ownership and the marker still come from `live-plan`, the foreign and
248
+ adoptable blocks carry none, `sources` reads `live+plan`, neither secret
249
+ appears anywhere in the result, and the read adds nothing to the root (the
250
+ plan file and the `live-plan` document included, #4379) or the temp directory.
251
+
252
+ Gated on a `choudoufu` and an `aws` CLI on PATH and on
253
+ `CHOUDOUFU_EMULATOR_ENDPOINT`.
254
+
255
+ ```
256
+ npx vitest run --project e2e lexicons/terraform/src/live-plan-depth.acceptance.e2e.test.ts
257
+ ```
258
+
259
+ Last passed: 2026-10-23, choudoufu v0.30.0 (release binary, verified against
260
+ `SHA256SUMS`) with floci
261
+ `ghcr.io/lex00/floci@sha256:5ec92ae8046445d62c0009590dd09f04560886e5a5ef31248cb3fcc09b2961ec`,
262
+ by chant #4371. Proved red the same day: with `plan-observation.ts`'s
263
+ redaction put back to flagging each side by its own marks, the old secret
264
+ came through as `tags.token`'s `before`.
265
+
214
266
  ## Running everything that can run on a machine with no choudoufu
215
267
 
216
268
  ```
@@ -0,0 +1,105 @@
1
+ # The capabilities the ledger doesn't exercise, each with a case that holds
2
+ # and one that doesn't (#4375).
3
+
4
+ # in-default-vpc: an instance in a subnet of the default VPC, and one outside it.
5
+ resource "aws_default_vpc" "default" {}
6
+ resource "aws_subnet" "legacy" {
7
+ vpc_id = aws_default_vpc.default.id
8
+ cidr_block = "172.31.200.0/24"
9
+ }
10
+ resource "aws_vpc" "main" { cidr_block = "10.9.0.0/16" }
11
+ resource "aws_subnet" "main" {
12
+ vpc_id = aws_vpc.main.id
13
+ cidr_block = "10.9.0.0/24"
14
+ }
15
+ resource "aws_instance" "legacy" {
16
+ ami = "ami-12345678"
17
+ instance_type = "t3.micro"
18
+ subnet_id = aws_subnet.legacy.id
19
+ vpc_security_group_ids = [aws_security_group.used.id]
20
+ }
21
+ resource "aws_instance" "current" {
22
+ ami = "ami-12345678"
23
+ instance_type = "t3.micro"
24
+ subnet_id = aws_subnet.main.id
25
+ vpc_security_group_ids = [aws_security_group.used.id]
26
+ }
27
+
28
+ # attached-to-nothing: a group no instance uses, a policy no role holds, and
29
+ # a subnet nothing sits in.
30
+ resource "aws_security_group" "used" { vpc_id = aws_vpc.main.id }
31
+ resource "aws_security_group" "unused" { vpc_id = aws_vpc.main.id }
32
+ resource "aws_subnet" "empty" {
33
+ vpc_id = aws_vpc.main.id
34
+ cidr_block = "10.9.1.0/24"
35
+ }
36
+ resource "aws_iam_policy" "orphan" {
37
+ policy = jsonencode({
38
+ Version = "2012-10-17"
39
+ Statement = [{ Effect = "Allow", Action = "s3:ListBucket", Resource = aws_s3_bucket.open.arn }]
40
+ })
41
+ }
42
+
43
+ # publicly-readable: a bucket policy for principal "*", once with nothing in
44
+ # its way and once behind a public-access block.
45
+ resource "aws_s3_bucket" "open" { bucket = "catalog-open" }
46
+ resource "aws_s3_bucket_policy" "open" {
47
+ bucket = aws_s3_bucket.open.id
48
+ policy = jsonencode({
49
+ Version = "2012-10-17"
50
+ Statement = [{ Effect = "Allow", Principal = "*", Action = "s3:GetObject", Resource = "${aws_s3_bucket.open.arn}/*" }]
51
+ })
52
+ }
53
+ resource "aws_s3_bucket" "blocked" { bucket = "catalog-blocked" }
54
+ resource "aws_s3_bucket_policy" "blocked" {
55
+ bucket = aws_s3_bucket.blocked.id
56
+ policy = jsonencode({
57
+ Version = "2012-10-17"
58
+ Statement = [{ Effect = "Allow", Principal = "*", Action = "s3:GetObject", Resource = "${aws_s3_bucket.blocked.arn}/*" }]
59
+ })
60
+ }
61
+ resource "aws_s3_bucket_public_access_block" "blocked" {
62
+ bucket = aws_s3_bucket.blocked.id
63
+ block_public_policy = true
64
+ }
65
+
66
+ # holds-secret and encrypted-at-rest: a SecureString and a plain parameter,
67
+ # an encrypted file system and an unencrypted database.
68
+ resource "aws_ssm_parameter" "token" {
69
+ name = "/catalog/token"
70
+ type = "SecureString"
71
+ value = "not-a-real-token"
72
+ }
73
+ resource "aws_ssm_parameter" "plain" {
74
+ name = "/catalog/plain"
75
+ type = "String"
76
+ value = "hello"
77
+ }
78
+ resource "aws_efs_file_system" "shared" { encrypted = true }
79
+ resource "aws_db_instance" "plain" {
80
+ engine = "postgres"
81
+ instance_class = "db.t3.micro"
82
+ allocated_storage = 20
83
+ username = "catalog"
84
+ password = "not-a-real-password"
85
+ storage_encrypted = false
86
+ }
87
+
88
+ # grants: an explicit Deny on the same pair cancels the Allow.
89
+ resource "aws_iam_role" "denied" {
90
+ assume_role_policy = jsonencode({
91
+ Version = "2012-10-17"
92
+ Statement = [{ Effect = "Allow", Principal = { Service = "ec2.amazonaws.com" }, Action = "sts:AssumeRole" }]
93
+ })
94
+ }
95
+ resource "aws_iam_role_policy" "denied" {
96
+ role = aws_iam_role.denied.id
97
+ policy = jsonencode({
98
+ Version = "2012-10-17"
99
+ Statement = [
100
+ { Effect = "Allow", Action = "s3:*", Resource = aws_s3_bucket.open.arn },
101
+ { Effect = "Deny", Action = "s3:*", Resource = aws_s3_bucket.open.arn },
102
+ { Effect = "Allow", Action = "s3:GetObject", Resource = aws_s3_bucket.blocked.arn },
103
+ ]
104
+ })
105
+ }
@@ -0,0 +1,15 @@
1
+ import type { ChantConfig } from "@intentius/chant/config";
2
+ import "@intentius/chant-lexicon-terraform";
3
+
4
+ export default {
5
+ lexicons: ["terraform"],
6
+ // The estates hold the findings these rules report on purpose.
7
+ lint: { rules: { TF022: "off", TF030: "off", TF033: "off" } },
8
+ terraform: {
9
+ roots: {
10
+ ledger: { dir: "./ledger" },
11
+ catalog: { dir: "./catalog" },
12
+ findings: { dir: "./findings" },
13
+ },
14
+ },
15
+ } satisfies ChantConfig;