@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.
- package/dist/__fixtures__/terragrunt/affected-cases.js +111 -0
- package/dist/__fixtures__/terragrunt/record.js +57 -0
- package/dist/behaviour/index.js +6 -0
- package/dist/behaviour/kinds.js +194 -0
- package/dist/behaviour/predict.js +297 -0
- package/dist/behaviour/reference-catalog.js +178 -0
- package/dist/behaviour/request.js +482 -0
- package/dist/change-set.js +234 -0
- package/dist/changed-roots.js +97 -0
- package/dist/codegen/docs-cli.js +3 -0
- package/dist/codegen/docs.js +44 -0
- package/dist/codegen/generate-cli.js +9 -0
- package/dist/codegen/generate.js +59 -0
- package/dist/codegen/package.js +43 -0
- package/dist/commands.js +106 -0
- package/dist/components/capability-plugin.d.ts +38 -2
- package/dist/components/capability-plugin.d.ts.map +1 -1
- package/dist/components/capability-plugin.js +260 -0
- package/dist/composites/terraform-adopt-op.js +152 -0
- package/dist/composites/terraform-apply-op.js +183 -0
- package/dist/composites/terraform-pin-rollout-op.js +45 -0
- package/dist/composites/terraform-watch-op.js +219 -0
- package/dist/config.js +118 -0
- package/dist/describe-resources.js +984 -0
- package/dist/generated/index.js +1 -0
- package/dist/hcl/descend.js +208 -0
- package/dist/hcl/edges.js +287 -0
- package/dist/hcl/jsonencode.js +442 -0
- package/dist/hcl/marker.js +212 -0
- package/dist/hcl/parse.js +372 -0
- package/dist/hcl/references.js +156 -0
- package/dist/hcl/roots.js +98 -0
- package/dist/hcl/suppressions.js +189 -0
- package/dist/hcl/value.js +72 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +46 -0
- package/dist/integrity.json +2 -2
- package/dist/lint/audit-catalog.js +501 -0
- package/dist/lint/audit-lineage.js +442 -0
- package/dist/lint/post-synth/blocks.js +87 -0
- package/dist/lint/post-synth/fixtures/load.js +94 -0
- package/dist/lint/post-synth/flat-attributes.js +135 -0
- package/dist/lint/post-synth/index.js +90 -0
- package/dist/lint/post-synth/module-source.js +153 -0
- package/dist/lint/post-synth/scope.js +104 -0
- package/dist/lint/post-synth/terragrunt.js +63 -0
- package/dist/lint/post-synth/tf001.js +138 -0
- package/dist/lint/post-synth/tf002.js +143 -0
- package/dist/lint/post-synth/tf003.js +60 -0
- package/dist/lint/post-synth/tf004.js +54 -0
- package/dist/lint/post-synth/tf005.js +68 -0
- package/dist/lint/post-synth/tf006.js +43 -0
- package/dist/lint/post-synth/tf007.js +66 -0
- package/dist/lint/post-synth/tf008.js +64 -0
- package/dist/lint/post-synth/tf009.js +49 -0
- package/dist/lint/post-synth/tf010.js +40 -0
- package/dist/lint/post-synth/tf011.js +38 -0
- package/dist/lint/post-synth/tf012.js +36 -0
- package/dist/lint/post-synth/tf013.js +53 -0
- package/dist/lint/post-synth/tf014.js +61 -0
- package/dist/lint/post-synth/tf015.js +62 -0
- package/dist/lint/post-synth/tf016.js +66 -0
- package/dist/lint/post-synth/tf017.js +39 -0
- package/dist/lint/post-synth/tf018.js +63 -0
- package/dist/lint/post-synth/tf019.js +60 -0
- package/dist/lint/post-synth/tf020.js +103 -0
- package/dist/lint/post-synth/tf021.js +117 -0
- package/dist/lint/post-synth/tf022.js +51 -0
- package/dist/lint/post-synth/tf024.js +62 -0
- package/dist/lint/post-synth/tf025.js +83 -0
- package/dist/lint/post-synth/tf026.js +74 -0
- package/dist/lint/post-synth/tf027.js +66 -0
- package/dist/lint/post-synth/tf028.js +83 -0
- package/dist/lint/post-synth/tf029.js +73 -0
- package/dist/lint/post-synth/tf030.js +253 -0
- package/dist/lint/post-synth/tf031.js +332 -0
- package/dist/lint/post-synth/tf032.js +201 -0
- package/dist/lint/post-synth/tf033.js +126 -0
- package/dist/lint/post-synth/tf034.js +45 -0
- package/dist/lint/post-synth/tf035.js +63 -0
- package/dist/lint/post-synth/tf036.js +97 -0
- package/dist/lint/post-synth/tf037.js +80 -0
- package/dist/lint/post-synth/tf038.js +63 -0
- package/dist/lint/post-synth/tf039.js +55 -0
- package/dist/lint/post-synth/tf040.js +67 -0
- package/dist/lint/post-synth/tf041.js +53 -0
- package/dist/lint/post-synth/tf042.js +38 -0
- package/dist/lint/post-synth/tf043.js +45 -0
- package/dist/lint/post-synth/tf044.js +45 -0
- package/dist/lint/post-synth/tf045.js +44 -0
- package/dist/lint/rules/fixtures/load.js +50 -0
- package/dist/lint/rules/index.js +4 -0
- package/dist/lint/rules/plan-before-apply.js +136 -0
- package/dist/lint/secret-shape.js +149 -0
- package/dist/live-ownership.js +57 -0
- package/dist/lsp/completions.js +95 -0
- package/dist/lsp/context.js +358 -0
- package/dist/lsp/hover.js +45 -0
- package/dist/lsp/option-keys.js +93 -0
- package/dist/manifest.json +1 -1
- package/dist/op/activities/index.js +30 -0
- package/dist/op/activities/live-detect.js +143 -0
- package/dist/op/activities/pin-rollout.js +45 -0
- package/dist/op/activities/terraform.js +1270 -0
- package/dist/op/activity-contracts.js +162 -0
- package/dist/op/adoption.js +272 -0
- package/dist/op/builders.js +125 -0
- package/dist/op/resolve-root-mode.js +77 -0
- package/dist/package-cli.js +16 -0
- package/dist/pin/edit-ts.js +76 -0
- package/dist/pin/edit.js +118 -0
- package/dist/pin/forge.js +65 -0
- package/dist/pin/index.js +16 -0
- package/dist/pin/rollout.d.ts.map +1 -1
- package/dist/pin/rollout.js +382 -0
- package/dist/pin/scan.js +287 -0
- package/dist/pin/source.js +171 -0
- package/dist/pin/waves.js +109 -0
- package/dist/plan-digest.js +67 -0
- package/dist/plugin.js +323 -0
- package/dist/serializer.js +23 -0
- package/dist/state-ownership.js +29 -0
- package/dist/terragrunt/affected.d.ts.map +1 -1
- package/dist/terragrunt/affected.js +290 -0
- package/dist/terragrunt/index.js +10 -0
- package/dist/terragrunt/mocks.js +270 -0
- package/dist/terragrunt/run.js +264 -0
- package/dist/terragrunt/units.js +195 -0
- package/dist/terragrunt/wave.js +125 -0
- package/dist/validate-cli.js +4 -0
- package/dist/validate.js +31 -0
- package/package.json +10 -10
- package/src/codegen/package.ts +2 -2
- package/src/components/capability-plugin.test.ts +97 -2
- package/src/components/capability-plugin.ts +117 -12
- package/src/components/pr-loop.tofu.test.ts +51 -11
- package/src/index.ts +1 -0
- package/src/pin/rollout.ts +2 -1
- package/src/terragrunt/affected.test.ts +11 -0
- package/src/terragrunt/affected.ts +2 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Descend into local child modules (chant #2112).
|
|
3
|
+
*
|
|
4
|
+
* `parseTerraformRootDir` reads one directory, non-recursively, which is
|
|
5
|
+
* Terraform's own root-module scoping and the right unit for a root-scoped
|
|
6
|
+
* check like TF001. Four rules need more than that: TF014 and TF015 are about
|
|
7
|
+
* what a CHILD module may contain, and TF020 needs every reference in a scope
|
|
8
|
+
* before it can call a declaration unused. This module supplies the missing
|
|
9
|
+
* half: a `module` block whose `source` is a local path names a directory,
|
|
10
|
+
* and that directory parses with the same `blocksToEntities` into entities
|
|
11
|
+
* keyed `<root>/module.<name>/<address>` carrying `props.callers`.
|
|
12
|
+
*
|
|
13
|
+
* ## What is followed, and what is not
|
|
14
|
+
*
|
|
15
|
+
* tflint's `--call-module-type` (three states, defaulting to `local`,
|
|
16
|
+
* https://github.com/terraform-linters/tflint/blob/master/docs/user-guide/calling-modules.md)
|
|
17
|
+
* is the model:
|
|
18
|
+
*
|
|
19
|
+
* - `local` (the default): only `./` and `../` sources are read. A registry
|
|
20
|
+
* or git source is NOT fetched, so its contents are never linted and every
|
|
21
|
+
* finding about it is reported against the call site instead, which is
|
|
22
|
+
* where TF004 and TF005 already report. The skipped call sites are named in
|
|
23
|
+
* a warning rather than passed over in silence.
|
|
24
|
+
* - `none`: no descent at all. A root then parses exactly as it did before
|
|
25
|
+
* this module existed.
|
|
26
|
+
* - `all`: reserved. tflint means "fetch registry and git modules too", which
|
|
27
|
+
* needs a `terraform init`/`terraform get` first so the modules are on disk
|
|
28
|
+
* under `.terraform`. chant fetches nothing, so this is refused with a
|
|
29
|
+
* message rather than quietly behaving like `local`.
|
|
30
|
+
*
|
|
31
|
+
* Two more refusals, both about staying inside the repository being read:
|
|
32
|
+
*
|
|
33
|
+
* - A source that resolves outside the project root is refused. `../` is a
|
|
34
|
+
* legal Terraform source and a repository may legitimately share modules
|
|
35
|
+
* between sibling roots, but reading above the project root means linting
|
|
36
|
+
* files the project does not own, so the boundary is the project root and
|
|
37
|
+
* crossing it is a warning, not a parse.
|
|
38
|
+
* - A source already on the current call chain is a cycle (`a` calls `b`
|
|
39
|
+
* calls `a`) and stops there. The visited set is per chain, on RESOLVED
|
|
40
|
+
* paths, so one module called from two different places is still read once
|
|
41
|
+
* per call site, each under its own `module.<name>` key, the way
|
|
42
|
+
* `terraform show -json` reports two `child_modules[]` entries for it.
|
|
43
|
+
*
|
|
44
|
+
* Everything here is best effort, like `./roots.ts`: an unreadable or
|
|
45
|
+
* unparseable child module is a warning and no entities, never a throw, since
|
|
46
|
+
* half a root parsed is more useful than none of it.
|
|
47
|
+
*/
|
|
48
|
+
import { existsSync, statSync } from "node:fs";
|
|
49
|
+
import { isAbsolute, relative, resolve } from "node:path";
|
|
50
|
+
import { isResourceDeclarable } from "@intentius/chant/declarable";
|
|
51
|
+
import { classifyModuleSource } from "../lint/post-synth/module-source.js";
|
|
52
|
+
import { MODULE_TYPE, parseTerraformRootDir, } from "./parse.js";
|
|
53
|
+
/**
|
|
54
|
+
* Resolve the configured `callModuleType` into one this lexicon implements.
|
|
55
|
+
* `"all"` is refused with a message naming what it would need; everything
|
|
56
|
+
* else passes through, and an absent value is `"local"`, tflint's default.
|
|
57
|
+
*/
|
|
58
|
+
export function resolveCallModuleType(value) {
|
|
59
|
+
if (value === "none")
|
|
60
|
+
return { effective: "none" };
|
|
61
|
+
if (value === "all") {
|
|
62
|
+
return {
|
|
63
|
+
effective: "none",
|
|
64
|
+
warning: 'terraform.callModuleType: "all" is not supported. It means "descend into registry and git ' +
|
|
65
|
+
"modules too\", which requires fetching them (`terraform init`/`terraform get`) first, and chant " +
|
|
66
|
+
'fetches nothing. Use "local" (the default, relative-path sources only) or "none". No module was descended into.',
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
return { effective: "local" };
|
|
70
|
+
}
|
|
71
|
+
const DEFAULT_MAX_DEPTH = 8;
|
|
72
|
+
/** Is `path` inside `boundary` (or the boundary itself)? */
|
|
73
|
+
function isInside(boundary, path) {
|
|
74
|
+
const rel = relative(boundary, path);
|
|
75
|
+
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
|
|
76
|
+
}
|
|
77
|
+
/** The `module` blocks of one scope, in `ctx.entities` order, with their sources read. */
|
|
78
|
+
function moduleCallsOf(entities) {
|
|
79
|
+
const calls = [];
|
|
80
|
+
for (const entity of entities.values()) {
|
|
81
|
+
if (entity.entityType !== MODULE_TYPE || !isResourceDeclarable(entity))
|
|
82
|
+
continue;
|
|
83
|
+
const props = entity.props;
|
|
84
|
+
const body = (typeof props.body === "object" && props.body !== null ? props.body : {});
|
|
85
|
+
calls.push({
|
|
86
|
+
address: typeof props.address === "string" ? props.address : "module.?",
|
|
87
|
+
source: typeof body.source === "string" ? body.source : undefined,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
return calls;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* The mode and estate the scope's own entities carry.
|
|
94
|
+
*
|
|
95
|
+
* A child module directory holds no `live` block and no `estate.chdf.hcl`
|
|
96
|
+
* sidecar (choudoufu reads the estate declaration from the root's directory,
|
|
97
|
+
* and only there), so parsing it alone always lands on `mode: "state"`. The
|
|
98
|
+
* root's verdict is the root's, one root one mode, so it is copied down onto
|
|
99
|
+
* every descended entity rather than re-derived from files that cannot carry
|
|
100
|
+
* it.
|
|
101
|
+
*/
|
|
102
|
+
function scopeMode(entities) {
|
|
103
|
+
for (const entity of entities.values()) {
|
|
104
|
+
if (!isResourceDeclarable(entity))
|
|
105
|
+
continue;
|
|
106
|
+
const props = entity.props;
|
|
107
|
+
if (typeof props.mode !== "string")
|
|
108
|
+
continue;
|
|
109
|
+
return { mode: props.mode, ...(typeof props.estate === "string" ? { estate: props.estate } : {}) };
|
|
110
|
+
}
|
|
111
|
+
return {};
|
|
112
|
+
}
|
|
113
|
+
/** Rebuild `entity` with the parent scope's mode and estate on its props. */
|
|
114
|
+
function withMode(entity, mode) {
|
|
115
|
+
if (mode.mode === undefined || !isResourceDeclarable(entity))
|
|
116
|
+
return entity;
|
|
117
|
+
const te = entity;
|
|
118
|
+
return {
|
|
119
|
+
...te,
|
|
120
|
+
props: { ...te.props, mode: mode.mode, ...(mode.estate !== undefined ? { estate: mode.estate } : {}) },
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Parse every local child module reachable from `scope`, recursively.
|
|
125
|
+
*
|
|
126
|
+
* `scope` is one already-parsed module scope: the root's own entities on the
|
|
127
|
+
* first call, a child's on a recursive one. Returned entities are the
|
|
128
|
+
* children's only, never the scope's own, so a caller merges them into the
|
|
129
|
+
* map it already has.
|
|
130
|
+
*/
|
|
131
|
+
export async function descendModules(scope, opts) {
|
|
132
|
+
const entities = new Map();
|
|
133
|
+
const warnings = [];
|
|
134
|
+
const mode = opts.callModuleType ?? "local";
|
|
135
|
+
if (mode === "none")
|
|
136
|
+
return { entities, warnings };
|
|
137
|
+
const maxDepth = opts.maxDepth ?? DEFAULT_MAX_DEPTH;
|
|
138
|
+
const where = `terraform.roots.${opts.root}`;
|
|
139
|
+
// The project root is the boundary, except for a root module configured
|
|
140
|
+
// with a `dir` outside the project entirely (an absolute path to an estate
|
|
141
|
+
// kept elsewhere): there the root's own directory is the boundary, since
|
|
142
|
+
// the project root would refuse every module the root has. A declared
|
|
143
|
+
// `moduleRoot` (#2874) replaces both.
|
|
144
|
+
const boundary = opts.moduleRoot !== undefined
|
|
145
|
+
? resolve(opts.moduleRoot)
|
|
146
|
+
: isInside(opts.projectRoot, opts.dir)
|
|
147
|
+
? resolve(opts.projectRoot)
|
|
148
|
+
: resolve(opts.dir);
|
|
149
|
+
/** One scope's calls, with the chain that reached it and the paths already on that chain. */
|
|
150
|
+
const walk = async (scopeEntities, dir, callers, onChain) => {
|
|
151
|
+
const notFollowed = [];
|
|
152
|
+
for (const call of moduleCallsOf(scopeEntities)) {
|
|
153
|
+
if (call.source === undefined)
|
|
154
|
+
continue;
|
|
155
|
+
const classified = classifyModuleSource(call.source);
|
|
156
|
+
if (classified.kind !== "local") {
|
|
157
|
+
notFollowed.push(`${call.address} (${call.source})`);
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
const target = resolve(dir, call.source);
|
|
161
|
+
if (!isInside(boundary, target)) {
|
|
162
|
+
warnings.push(`${where}: ${call.address} sources ${JSON.stringify(call.source)}, which resolves outside the project ` +
|
|
163
|
+
`root (${target}). chant does not read modules from outside the project it was pointed at, so that ` +
|
|
164
|
+
"module is not parsed and any finding in it is not reported.");
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
if (onChain.includes(target)) {
|
|
168
|
+
warnings.push(`${where}: ${call.address} sources ${JSON.stringify(call.source)}, which is already on this call chain ` +
|
|
169
|
+
`(${[...callers, call.address].join(" -> ")}). That is a cycle, so the descent stops here.`);
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
if (!existsSync(target) || !statSync(target).isDirectory()) {
|
|
173
|
+
warnings.push(`${where}: ${call.address} sources ${JSON.stringify(call.source)}, but no directory exists at ${target}, ` +
|
|
174
|
+
"so the module is not parsed.");
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
if (callers.length >= maxDepth) {
|
|
178
|
+
warnings.push(`${where}: ${call.address} is more than ${maxDepth} module calls deep (${callers.join(" -> ")}), ` +
|
|
179
|
+
"so the descent stops here.");
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
const chain = [...callers, call.address];
|
|
183
|
+
let child;
|
|
184
|
+
try {
|
|
185
|
+
child = await parseTerraformRootDir(target, opts.root, opts.hcl2json, opts.modeOptions, chain);
|
|
186
|
+
}
|
|
187
|
+
catch (err) {
|
|
188
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
189
|
+
warnings.push(`${where}: could not parse ${call.address} at ${target}, ${message}`);
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
const mode = scopeMode(scopeEntities);
|
|
193
|
+
for (const [key, entity] of child) {
|
|
194
|
+
const stamped = withMode(entity, mode);
|
|
195
|
+
child.set(key, stamped);
|
|
196
|
+
entities.set(key, stamped);
|
|
197
|
+
}
|
|
198
|
+
await walk(child, target, chain, [...onChain, target]);
|
|
199
|
+
}
|
|
200
|
+
if (notFollowed.length > 0) {
|
|
201
|
+
warnings.push(`${where}: not descending into ${notFollowed.join(", ")}. The source is a registry or git module and chant ` +
|
|
202
|
+
"fetches nothing, so the module's own contents are unchecked and every finding about it is reported " +
|
|
203
|
+
"against the module block at the call site.");
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
await walk(scope, opts.dir, [], [resolve(opts.dir)]);
|
|
207
|
+
return { entities, warnings };
|
|
208
|
+
}
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve each block's references into graph edges (chant #2265).
|
|
3
|
+
*
|
|
4
|
+
* `chant graph --format ir` used to emit a complete, module-descended,
|
|
5
|
+
* fully-attributed node set for a Terraform estate and zero edges. Core's
|
|
6
|
+
* `collectEdges` walks a node's config bag for `AttrRef` objects and `Ref`
|
|
7
|
+
* intrinsics; a block body out of hcl2json holds `"${aws_vpc.main.id}"`, a
|
|
8
|
+
* string, so the walk found nothing and said so. This module supplies what
|
|
9
|
+
* that walk cannot see: the references, already resolved to the entity keys
|
|
10
|
+
* `blocksToEntities` minted, published on each entity as core's lexicon-neutral
|
|
11
|
+
* `EntityReference` (see `@intentius/chant/graph-ir`).
|
|
12
|
+
*
|
|
13
|
+
* ## Where the reference forms come from
|
|
14
|
+
*
|
|
15
|
+
* The `${...}` body is tokenized by hcl2json's own expression AST
|
|
16
|
+
* (`getReferencesInExpression`), never by a regex over the string. That is the
|
|
17
|
+
* same instrument `packages/core/src/terraform/hcl2json.ts` uses for the carve-out
|
|
18
|
+
* advisor, and for the same reason: a quoted address used as a map key
|
|
19
|
+
* (`var.m["aws_s3_bucket.assets.arn"]`) and an escaped `$${...}` literal are
|
|
20
|
+
* not references, and no regex over the raw string can tell. `./references.ts`
|
|
21
|
+
* IS such a regex scan and stays one: it answers "is this declaration used
|
|
22
|
+
* anywhere", deliberately generously, for a report-only rule. An edge a
|
|
23
|
+
* renderer draws is a stricter claim and gets the stricter instrument.
|
|
24
|
+
*
|
|
25
|
+
* Each accessor the AST returns is classified by core's own `refFromAccessor`
|
|
26
|
+
* for the three forms it already knows (`<type>.<name>`, `module.<name>`,
|
|
27
|
+
* `data.<type>.<name>`), and here for the two it deliberately excludes as
|
|
28
|
+
* non-resources, `var.<name>` and `local.<name>`.
|
|
29
|
+
*
|
|
30
|
+
* ## Four decisions a renderer will draw
|
|
31
|
+
*
|
|
32
|
+
* **Which forms become edges.** All five: `resource`, `data`, `module`, `var`
|
|
33
|
+
* and `local`. The first three are uncontroversial. The last two are edges
|
|
34
|
+
* because this lexicon emits a NODE for every `variable` and every `locals`
|
|
35
|
+
* block, and on a real estate those are the majority of them (108 variables
|
|
36
|
+
* and 10 locals out of 247 nodes, on the estate the issue was filed against).
|
|
37
|
+
* Dropping their edges would leave nearly half the graph as unconnected cards,
|
|
38
|
+
* which is the same picture the zero-edge bug drew. "Which resources consume
|
|
39
|
+
* `var.region`" is also a question a reader of a root module actually asks,
|
|
40
|
+
* and `terraform graph` itself answers it.
|
|
41
|
+
*
|
|
42
|
+
* A `local.<name>` resolves to the `locals` BLOCK that declares that name,
|
|
43
|
+
* found by looking `name` up in each block's body, so two `locals` blocks in
|
|
44
|
+
* one scope resolve independently rather than both matching.
|
|
45
|
+
*
|
|
46
|
+
* `provider = aws.west` is NOT an edge. The reference names a `provider`
|
|
47
|
+
* block by type AND alias, and this lexicon's entity key for one is
|
|
48
|
+
* `provider.<type>` with the alias inside the body, so two aliased providers
|
|
49
|
+
* of one type key as `provider.aws` and `provider.aws~2` and the reference
|
|
50
|
+
* cannot be resolved to either without guessing. `./references.ts` still
|
|
51
|
+
* records it for TF020, where "is it used at all" is answerable without
|
|
52
|
+
* knowing which block.
|
|
53
|
+
*
|
|
54
|
+
* **`depends_on` is an edge.** It arrives from hcl2json as
|
|
55
|
+
* `["${aws_vpc.main}"]` under the key `depends_on`, so it resolves through the
|
|
56
|
+
* same path as any other reference and lands as `viaAttr: "depends_on"` with
|
|
57
|
+
* no `toAttr`, which is exactly what it is: an ordering edge with no attribute
|
|
58
|
+
* flowing along it.
|
|
59
|
+
*
|
|
60
|
+
* **`count` / `for_each` are block-to-block.** chant's entity is the block, so
|
|
61
|
+
* `count = 3` is one node and one edge, not three. The expansion itself is
|
|
62
|
+
* recorded on the entity (`props.expansion`, `./parse.ts`) rather than left to
|
|
63
|
+
* be inferred from a node count that never grows. A `count = length(var.x)`
|
|
64
|
+
* also yields a real edge to `var.x` through `viaAttr: "count"`, since the
|
|
65
|
+
* meta-argument is an expression like any other.
|
|
66
|
+
*
|
|
67
|
+
* **Attribute naming.** `viaAttr` is the consumer-side TOP-LEVEL attribute the
|
|
68
|
+
* reference sits under and `toAttr` the producer-side attribute it read, which
|
|
69
|
+
* is the same pair carve's `TfEdge` carries as `via` and `attrs` and the pair
|
|
70
|
+
* behold already renders. One edge per (consumer, producer, `viaAttr`), and
|
|
71
|
+
* `toAttr` is set only when that edge read exactly one producer attribute:
|
|
72
|
+
* `subnet_id = aws_subnet.a.id` gives `id`, while
|
|
73
|
+
* `tags = merge(aws_vpc.main.tags, { id = aws_vpc.main.id })` names two and
|
|
74
|
+
* gets none rather than an arbitrary one.
|
|
75
|
+
*
|
|
76
|
+
* ## Scope, and what is deliberately not resolved
|
|
77
|
+
*
|
|
78
|
+
* Resolution is bounded to the referring block's own module scope
|
|
79
|
+
* (`scopeOfKey`), which is Terraform's own namespace: a `var.region` inside
|
|
80
|
+
* `module.cdn` is the child module's variable and never the root's. That also
|
|
81
|
+
* means no edge ever crosses roots, which is correct and intended (#2265):
|
|
82
|
+
* separate roots read each other by NAME through a data source, never by
|
|
83
|
+
* reference, and a value-match pass over those is a consumer's job, not this
|
|
84
|
+
* one's.
|
|
85
|
+
*/
|
|
86
|
+
import { isResourceDeclarable } from "@intentius/chant/declarable";
|
|
87
|
+
import { loadHcl2json } from "@intentius/chant/terraform/hcl2json";
|
|
88
|
+
import { refFromAccessor } from "@intentius/chant/terraform/graph";
|
|
89
|
+
import { LOCALS_TYPE, scopeOfKey } from "./parse.js";
|
|
90
|
+
/** An identifier as Terraform's grammar allows it. Same as `./references.ts`. */
|
|
91
|
+
const NAME = "[A-Za-z_][A-Za-z0-9_-]*";
|
|
92
|
+
/** `var.<name>` / `local.<name>` at the head of a traversal accessor. */
|
|
93
|
+
const SCALAR_HEAD_RE = new RegExp(`^(var|local)\\.(${NAME})(?:\\.|$)`);
|
|
94
|
+
/**
|
|
95
|
+
* Classify one AST traversal accessor. `var`/`local` are handled here because
|
|
96
|
+
* core's `refFromAccessor` excludes them by design (they are not carvable
|
|
97
|
+
* resources); everything else defers to it, quoted map keys and numeric
|
|
98
|
+
* indexes included.
|
|
99
|
+
*/
|
|
100
|
+
export function referenceFromAccessor(accessor) {
|
|
101
|
+
const scalar = SCALAR_HEAD_RE.exec(accessor);
|
|
102
|
+
if (scalar)
|
|
103
|
+
return { address: `${scalar[1]}.${scalar[2]}` };
|
|
104
|
+
return refFromAccessor(accessor) ?? undefined;
|
|
105
|
+
}
|
|
106
|
+
/** Every `${...}` string in a body, paired with the top-level attribute it sits under. */
|
|
107
|
+
function expressionsInBody(body) {
|
|
108
|
+
const out = [];
|
|
109
|
+
const visit = (value, via, depth) => {
|
|
110
|
+
if (depth > 8)
|
|
111
|
+
return;
|
|
112
|
+
if (typeof value === "string") {
|
|
113
|
+
if (value.includes("${"))
|
|
114
|
+
out.push({ via, expression: value });
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
if (Array.isArray(value)) {
|
|
118
|
+
for (const item of value)
|
|
119
|
+
visit(item, via, depth + 1);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
if (typeof value === "object" && value !== null) {
|
|
123
|
+
for (const [key, inner] of Object.entries(value)) {
|
|
124
|
+
visit(inner, depth === 0 ? key : via, depth + 1);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
visit(body, "", 0);
|
|
129
|
+
return out;
|
|
130
|
+
}
|
|
131
|
+
/** The `${...}` strings of every entity in the map, deduplicated. */
|
|
132
|
+
function allExpressions(entities) {
|
|
133
|
+
const seen = new Set();
|
|
134
|
+
for (const entity of entities.values()) {
|
|
135
|
+
if (!isResourceDeclarable(entity))
|
|
136
|
+
continue;
|
|
137
|
+
const body = bodyOf(entity);
|
|
138
|
+
for (const { expression } of expressionsInBody(body))
|
|
139
|
+
seen.add(expression);
|
|
140
|
+
}
|
|
141
|
+
return [...seen];
|
|
142
|
+
}
|
|
143
|
+
function bodyOf(entity) {
|
|
144
|
+
const props = entity.props;
|
|
145
|
+
return (typeof props.body === "object" && props.body !== null ? props.body : {});
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Resolve every expression once through the AST. An expression the parser
|
|
149
|
+
* refuses in isolation resolves to no references: fewer edges, never a phantom
|
|
150
|
+
* one, which is the same trade `resolveExpressionRefs` makes in core.
|
|
151
|
+
*/
|
|
152
|
+
async function resolveAccessors(parser, expressions) {
|
|
153
|
+
const out = new Map();
|
|
154
|
+
for (const expression of expressions) {
|
|
155
|
+
try {
|
|
156
|
+
const found = await parser.getReferencesInExpression("expression.tf", expression);
|
|
157
|
+
out.set(expression, found.map((r) => r.value));
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
out.set(expression, []);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return out;
|
|
164
|
+
}
|
|
165
|
+
function buildScopeIndex(entities) {
|
|
166
|
+
const scopes = new Map();
|
|
167
|
+
for (const [key, entity] of entities) {
|
|
168
|
+
if (!isResourceDeclarable(entity))
|
|
169
|
+
continue;
|
|
170
|
+
const te = entity;
|
|
171
|
+
const address = te.props.address;
|
|
172
|
+
if (typeof address !== "string")
|
|
173
|
+
continue;
|
|
174
|
+
const scope = scopeOfKey(key);
|
|
175
|
+
let index = scopes.get(scope);
|
|
176
|
+
if (!index) {
|
|
177
|
+
index = { byAddress: new Map(), byLocal: new Map() };
|
|
178
|
+
scopes.set(scope, index);
|
|
179
|
+
}
|
|
180
|
+
// First key wins: two blocks that genuinely share an address are keyed
|
|
181
|
+
// `~2`, `~3` (`./parse.ts`), and a reference cannot say which it meant.
|
|
182
|
+
if (!index.byAddress.has(address))
|
|
183
|
+
index.byAddress.set(address, key);
|
|
184
|
+
if (te.entityType === LOCALS_TYPE) {
|
|
185
|
+
for (const name of Object.keys(bodyOf(te))) {
|
|
186
|
+
if (!index.byLocal.has(name))
|
|
187
|
+
index.byLocal.set(name, key);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return scopes;
|
|
192
|
+
}
|
|
193
|
+
/** The entity key an address names within one scope, or undefined. */
|
|
194
|
+
function resolveAddress(address, index) {
|
|
195
|
+
if (address.startsWith("local."))
|
|
196
|
+
return index.byLocal.get(address.slice("local.".length));
|
|
197
|
+
return index.byAddress.get(address);
|
|
198
|
+
}
|
|
199
|
+
/** Every reference in one body, as raw addresses paired with the attribute they sit under. */
|
|
200
|
+
function rawReferences(body, accessors) {
|
|
201
|
+
const out = [];
|
|
202
|
+
for (const { via, expression } of expressionsInBody(body)) {
|
|
203
|
+
for (const accessor of accessors.get(expression) ?? []) {
|
|
204
|
+
const ref = referenceFromAccessor(accessor);
|
|
205
|
+
if (ref)
|
|
206
|
+
out.push({ address: ref.address, ...(ref.attr ? { attr: ref.attr } : {}), via });
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return out;
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* The references one block declares, resolved to entity keys and collapsed to
|
|
213
|
+
* one entry per (producer, `viaAttr`). Sorted, so the IR a build emits is
|
|
214
|
+
* byte-stable across runs.
|
|
215
|
+
*/
|
|
216
|
+
export function referencesOfEntity(key, entity, scopes, accessors) {
|
|
217
|
+
const index = scopes.get(scopeOfKey(key));
|
|
218
|
+
if (!index)
|
|
219
|
+
return [];
|
|
220
|
+
const grouped = new Map();
|
|
221
|
+
for (const raw of rawReferences(bodyOf(entity), accessors)) {
|
|
222
|
+
const to = resolveAddress(raw.address, index);
|
|
223
|
+
if (!to || to === key)
|
|
224
|
+
continue;
|
|
225
|
+
const groupKey = `${to}${raw.via}`;
|
|
226
|
+
let group = grouped.get(groupKey);
|
|
227
|
+
if (!group) {
|
|
228
|
+
group = { to, viaAttr: raw.via, attrs: new Set() };
|
|
229
|
+
grouped.set(groupKey, group);
|
|
230
|
+
}
|
|
231
|
+
if (raw.attr)
|
|
232
|
+
group.attrs.add(raw.attr);
|
|
233
|
+
}
|
|
234
|
+
const out = [];
|
|
235
|
+
for (const { to, viaAttr, attrs } of grouped.values()) {
|
|
236
|
+
out.push({
|
|
237
|
+
to,
|
|
238
|
+
...(viaAttr ? { viaAttr } : {}),
|
|
239
|
+
// Exactly one, or none: an edge carrying an arbitrary pick out of two
|
|
240
|
+
// would be a label a reader cannot trust.
|
|
241
|
+
...(attrs.size === 1 ? { toAttr: [...attrs][0] } : {}),
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
return out.sort((a, b) => cmp(a.to, b.to) || cmp(a.viaAttr ?? "", b.viaAttr ?? ""));
|
|
245
|
+
}
|
|
246
|
+
/** Code-point ordering, so output does not depend on the machine's locale. */
|
|
247
|
+
function cmp(a, b) {
|
|
248
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Stamp `references` onto every terraform entity in `entities`, in place.
|
|
252
|
+
*
|
|
253
|
+
* Takes the whole build's entity map rather than one root's, because one
|
|
254
|
+
* shared expression cache across every root is what keeps this to one wasm
|
|
255
|
+
* call per DISTINCT expression instead of one per occurrence. Resolution is
|
|
256
|
+
* still per module scope, so pooling the parse pools no references.
|
|
257
|
+
*
|
|
258
|
+
* Best effort, like everything else on this path: a parser that cannot be
|
|
259
|
+
* loaded, or one that does not expose the expression AST, leaves every entity
|
|
260
|
+
* unreferenced rather than failing a build over a diagram.
|
|
261
|
+
*/
|
|
262
|
+
export async function resolveEntityReferences(entities, hcl2json) {
|
|
263
|
+
const expressions = allExpressions(entities);
|
|
264
|
+
if (expressions.length === 0)
|
|
265
|
+
return;
|
|
266
|
+
let parser;
|
|
267
|
+
try {
|
|
268
|
+
parser = hcl2json ?? (await loadHcl2json());
|
|
269
|
+
}
|
|
270
|
+
catch {
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
if (typeof parser.getReferencesInExpression !== "function")
|
|
274
|
+
return;
|
|
275
|
+
const accessors = await resolveAccessors(parser, expressions);
|
|
276
|
+
const scopes = buildScopeIndex(entities);
|
|
277
|
+
for (const [key, entity] of entities) {
|
|
278
|
+
if (!isResourceDeclarable(entity))
|
|
279
|
+
continue;
|
|
280
|
+
const te = entity;
|
|
281
|
+
const references = referencesOfEntity(key, te, scopes, accessors);
|
|
282
|
+
if (references.length === 0)
|
|
283
|
+
continue;
|
|
284
|
+
const stamped = { ...te, references };
|
|
285
|
+
entities.set(key, stamped);
|
|
286
|
+
}
|
|
287
|
+
}
|