@intentius/chant 0.61.0 → 0.63.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.
- package/dist/cli/handlers/operator.d.ts +13 -18
- package/dist/cli/handlers/operator.d.ts.map +1 -1
- package/dist/cli/handlers/run.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +2 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/components/cli-support.d.ts +3 -0
- package/dist/components/cli-support.d.ts.map +1 -1
- package/dist/components/driver-output.d.ts.map +1 -1
- package/dist/components/driver.d.ts +12 -0
- package/dist/components/driver.d.ts.map +1 -1
- package/dist/fold/fold.d.ts.map +1 -1
- package/dist/fold/subset.d.ts +10 -0
- package/dist/fold/subset.d.ts.map +1 -1
- package/dist/lexicon.d.ts +51 -0
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/gate-ledger.d.ts +61 -0
- package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
- package/dist/lifecycle/git.d.ts +117 -0
- package/dist/lifecycle/git.d.ts.map +1 -1
- package/dist/lifecycle/index.d.ts +1 -0
- package/dist/lifecycle/index.d.ts.map +1 -1
- package/dist/lifecycle/plan-digest.d.ts +33 -0
- package/dist/lifecycle/plan-digest.d.ts.map +1 -0
- package/dist/lifecycle/run-ledger.d.ts.map +1 -1
- package/dist/op/activities/lexicon-upgrade.d.ts +33 -3
- package/dist/op/activities/lexicon-upgrade.d.ts.map +1 -1
- package/dist/op/activities/lifecycle.d.ts +27 -0
- package/dist/op/activities/lifecycle.d.ts.map +1 -1
- package/dist/op/activities/reconcile.d.ts +394 -14
- package/dist/op/activities/reconcile.d.ts.map +1 -1
- package/dist/op/builders.d.ts +6 -0
- package/dist/op/builders.d.ts.map +1 -1
- package/dist/op/composites/apply-op.d.ts +6 -0
- package/dist/op/composites/apply-op.d.ts.map +1 -1
- package/dist/op/composites/reconcile-op.d.ts.map +1 -1
- package/dist/op/gate-summary.d.ts +16 -0
- package/dist/op/gate-summary.d.ts.map +1 -1
- package/dist/op/gate.d.ts +104 -13
- package/dist/op/gate.d.ts.map +1 -1
- package/dist/op/index.d.ts +3 -2
- package/dist/op/index.d.ts.map +1 -1
- package/dist/op/local-executor.d.ts +34 -2
- package/dist/op/local-executor.d.ts.map +1 -1
- package/dist/op/local-output.d.ts.map +1 -1
- package/dist/op/op-ir.d.ts +8 -1
- package/dist/op/op-ir.d.ts.map +1 -1
- package/dist/op/operator.d.ts.map +1 -1
- package/dist/op/runtime.d.ts +2 -0
- package/dist/op/runtime.d.ts.map +1 -1
- package/dist/op/runtimes/local.d.ts.map +1 -1
- package/dist/op/types.d.ts +19 -0
- package/dist/op/types.d.ts.map +1 -1
- package/dist/runtime-adapter.d.ts +8 -0
- package/dist/runtime-adapter.d.ts.map +1 -1
- package/dist/terraform/__fixtures__/build-graph.d.ts +8 -0
- package/dist/terraform/__fixtures__/build-graph.d.ts.map +1 -1
- package/dist/terraform/graph.d.ts +18 -2
- package/dist/terraform/graph.d.ts.map +1 -1
- package/dist/terraform/parse.d.ts.map +1 -1
- package/dist/terraform/types.d.ts +7 -0
- package/dist/terraform/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/handlers/operator.test.ts +232 -1
- package/src/cli/handlers/operator.ts +130 -6
- package/src/cli/handlers/run.ts +19 -0
- package/src/cli/main.ts +2 -0
- package/src/cli/registry.ts +2 -0
- package/src/components/cli-support.ts +29 -4
- package/src/components/driver-output.ts +10 -0
- package/src/components/driver.test.ts +31 -0
- package/src/components/driver.ts +54 -8
- package/src/discovery/fold-import.test.ts +55 -0
- package/src/fold/fold.test.ts +152 -0
- package/src/fold/fold.ts +102 -2
- package/src/fold/subset-doc-parity.test.ts +35 -1
- package/src/fold/subset.ts +10 -0
- package/src/lexicon.ts +51 -0
- package/src/lifecycle/gate-ledger.test.ts +133 -1
- package/src/lifecycle/gate-ledger.ts +108 -0
- package/src/lifecycle/git.test.ts +49 -5
- package/src/lifecycle/git.ts +312 -11
- package/src/lifecycle/index.ts +1 -0
- package/src/lifecycle/plan-digest.test.ts +49 -0
- package/src/lifecycle/plan-digest.ts +86 -0
- package/src/lifecycle/run-ledger.ts +1 -0
- package/src/op/activities/lexicon-upgrade.test.ts +134 -39
- package/src/op/activities/lexicon-upgrade.ts +74 -12
- package/src/op/activities/lifecycle.ts +51 -2
- package/src/op/activities/reconcile.test.ts +1013 -1
- package/src/op/activities/reconcile.ts +721 -25
- package/src/op/builders.ts +7 -1
- package/src/op/composites/apply-op.ts +16 -0
- package/src/op/composites/composites.test.ts +15 -2
- package/src/op/composites/reconcile-op.test.ts +18 -0
- package/src/op/composites/reconcile-op.ts +7 -1
- package/src/op/gate-summary.test.ts +33 -0
- package/src/op/gate-summary.ts +31 -0
- package/src/op/gate.test.ts +614 -0
- package/src/op/gate.ts +190 -24
- package/src/op/index.ts +5 -2
- package/src/op/local-executor.test.ts +340 -2
- package/src/op/local-executor.ts +190 -32
- package/src/op/local-output.test.ts +38 -0
- package/src/op/local-output.ts +24 -1
- package/src/op/op-ir.test.ts +22 -0
- package/src/op/op-ir.ts +9 -0
- package/src/op/operator.test.ts +20 -0
- package/src/op/operator.ts +39 -1
- package/src/op/runtime.ts +2 -0
- package/src/op/runtimes/local.ts +11 -0
- package/src/op/types.ts +19 -0
- package/src/runtime-adapter.ts +17 -3
- package/src/terraform/__fixtures__/build-graph.ts +42 -0
- package/src/terraform/__fixtures__/carve-locals-data.test.ts +138 -0
- package/src/terraform/__fixtures__/depth-estate/main.tf +141 -0
- package/src/terraform/__fixtures__/depth-estate/terraform.tfstate +17 -0
- package/src/terraform/__fixtures__/depth-estate.test.ts +162 -0
- package/src/terraform/graph.test.ts +148 -1
- package/src/terraform/graph.ts +144 -6
- package/src/terraform/parse.ts +4 -1
- package/src/terraform/types.ts +7 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, test, expect } from "vitest";
|
|
2
|
-
import { inboundEdges, outboundEdges, refFromAccessor } from "./graph";
|
|
2
|
+
import { collectExpressions, inboundEdges, outboundEdges, refFromAccessor } from "./graph";
|
|
3
3
|
import { buildFixtureGraph } from "./__fixtures__/build-graph";
|
|
4
4
|
import type { Hcl2JsonTree } from "./types";
|
|
5
5
|
|
|
@@ -214,6 +214,153 @@ describe("buildGraph", () => {
|
|
|
214
214
|
});
|
|
215
215
|
});
|
|
216
216
|
|
|
217
|
+
/**
|
|
218
|
+
* References that reach a resource through a `locals` or `data` block (#2324).
|
|
219
|
+
* Neither is a node, both are load-bearing for the plan: the estate below is
|
|
220
|
+
* the one the blast-radius spike measured, where one bucket has three
|
|
221
|
+
* dependents by three routes and the advisor used to report one.
|
|
222
|
+
*/
|
|
223
|
+
describe("references through locals and data blocks", () => {
|
|
224
|
+
const threeRoutes: Hcl2JsonTree = {
|
|
225
|
+
resource: {
|
|
226
|
+
aws_s3_bucket: { assets: [{ bucket: "app-assets" }] },
|
|
227
|
+
aws_lambda_function: {
|
|
228
|
+
via_local: [{ environment: { variables: { B: "${local.assets_id}" } } }],
|
|
229
|
+
via_data: [{ environment: { variables: { B: "${data.aws_s3_bucket.lookup.arn}" } } }],
|
|
230
|
+
direct: [{ environment: { variables: { B: "${aws_s3_bucket.assets.arn}" } } }],
|
|
231
|
+
},
|
|
232
|
+
},
|
|
233
|
+
locals: [{ assets_id: "${aws_s3_bucket.assets.id}" }],
|
|
234
|
+
data: { aws_s3_bucket: { lookup: [{ bucket: "${aws_s3_bucket.assets.bucket}" }] } },
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
test("all three routes to one bucket are inbound edges", () => {
|
|
238
|
+
const g = buildFixtureGraph(threeRoutes);
|
|
239
|
+
expect(inboundEdges(g, "aws_s3_bucket.assets")).toEqual([
|
|
240
|
+
{ from: "aws_lambda_function.direct", to: "aws_s3_bucket.assets", attrs: ["arn"], via: ["environment"] },
|
|
241
|
+
{ from: "aws_lambda_function.via_data", to: "aws_s3_bucket.assets", attrs: ["bucket"], via: ["environment"] },
|
|
242
|
+
{ from: "aws_lambda_function.via_local", to: "aws_s3_bucket.assets", attrs: ["id"], via: ["environment"] },
|
|
243
|
+
]);
|
|
244
|
+
// The referrers stay off the node list: nothing carves a local or a data source.
|
|
245
|
+
expect(g.nodes.map((n) => n.address).filter((a) => a.startsWith("local.") || a.startsWith("data."))).toEqual([]);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test("each dependent gets the outbound edge to match", () => {
|
|
249
|
+
const g = buildFixtureGraph(threeRoutes);
|
|
250
|
+
for (const from of ["aws_lambda_function.direct", "aws_lambda_function.via_data", "aws_lambda_function.via_local"]) {
|
|
251
|
+
expect(outboundEdges(g, from).map((e) => e.to)).toEqual(["aws_s3_bucket.assets"]);
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test("reading a data source still marks the reader dynamic", () => {
|
|
256
|
+
const g = buildFixtureGraph(threeRoutes);
|
|
257
|
+
const byAddress = Object.fromEntries(g.nodes.map((n) => [n.address, n]));
|
|
258
|
+
expect(byAddress["aws_lambda_function.via_data"].hasDynamic).toBe(true);
|
|
259
|
+
expect(byAddress["aws_lambda_function.via_local"].hasDynamic).toBe(false);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
test("a chain of locals resolves to the resource the chain ultimately names", () => {
|
|
263
|
+
const g = buildFixtureGraph({
|
|
264
|
+
resource: {
|
|
265
|
+
aws_s3_bucket: { assets: [{ bucket: "app-assets" }] },
|
|
266
|
+
aws_lambda_function: { api: [{ environment: { variables: { B: "${local.c}" } } }] },
|
|
267
|
+
},
|
|
268
|
+
locals: [{ a: "${aws_s3_bucket.assets.id}" }, { b: "${local.a}", c: "${local.b}" }],
|
|
269
|
+
});
|
|
270
|
+
expect(inboundEdges(g, "aws_s3_bucket.assets")).toEqual([
|
|
271
|
+
{ from: "aws_lambda_function.api", to: "aws_s3_bucket.assets", attrs: ["id"], via: ["environment"] },
|
|
272
|
+
]);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test("a data source reading a local reading a data source resolves through both", () => {
|
|
276
|
+
const g = buildFixtureGraph({
|
|
277
|
+
resource: {
|
|
278
|
+
aws_s3_bucket: { assets: [{ bucket: "app-assets" }] },
|
|
279
|
+
aws_lambda_function: { api: [{ environment: { variables: { B: "${data.aws_s3_bucket.outer.arn}" } } }] },
|
|
280
|
+
},
|
|
281
|
+
locals: [{ inner_name: "${data.aws_s3_bucket.inner.id}" }],
|
|
282
|
+
data: {
|
|
283
|
+
aws_s3_bucket: {
|
|
284
|
+
inner: [{ bucket: "${aws_s3_bucket.assets.bucket}" }],
|
|
285
|
+
outer: [{ bucket: "${local.inner_name}" }],
|
|
286
|
+
},
|
|
287
|
+
},
|
|
288
|
+
});
|
|
289
|
+
expect(inboundEdges(g, "aws_s3_bucket.assets").map((e) => e.from)).toEqual(["aws_lambda_function.api"]);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test("a self-referential or mutually recursive local resolves without hanging", () => {
|
|
293
|
+
// Terraform rejects both of these; the advisor only reads an estate, so it
|
|
294
|
+
// has to reach a fixpoint on one rather than recurse forever.
|
|
295
|
+
const g = buildFixtureGraph({
|
|
296
|
+
resource: {
|
|
297
|
+
aws_s3_bucket: { assets: [{ bucket: "app-assets" }] },
|
|
298
|
+
aws_lambda_function: {
|
|
299
|
+
selfish: [{ environment: { variables: { B: "${local.me}" } } }],
|
|
300
|
+
looper: [{ environment: { variables: { B: "${local.x}" } } }],
|
|
301
|
+
},
|
|
302
|
+
},
|
|
303
|
+
locals: [
|
|
304
|
+
{ me: "${local.me}" },
|
|
305
|
+
{ x: "${local.y}", y: "${local.x}" },
|
|
306
|
+
{ grounded: "${aws_s3_bucket.assets.id}" },
|
|
307
|
+
],
|
|
308
|
+
});
|
|
309
|
+
expect(g.edges).toEqual([]);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
test("a cycle that also names a resource still yields the resource edge", () => {
|
|
313
|
+
const g = buildFixtureGraph({
|
|
314
|
+
resource: {
|
|
315
|
+
aws_s3_bucket: { assets: [{ bucket: "app-assets" }] },
|
|
316
|
+
aws_lambda_function: { api: [{ environment: { variables: { B: "${local.x}" } } }] },
|
|
317
|
+
},
|
|
318
|
+
locals: [{ x: ["${local.y}", "${aws_s3_bucket.assets.arn}"], y: "${local.x}" }],
|
|
319
|
+
});
|
|
320
|
+
expect(inboundEdges(g, "aws_s3_bucket.assets")).toEqual([
|
|
321
|
+
{ from: "aws_lambda_function.api", to: "aws_s3_bucket.assets", attrs: ["arn"], via: ["environment"] },
|
|
322
|
+
]);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
test("an output reading a local is still an output-tagged edge", () => {
|
|
326
|
+
const g = buildFixtureGraph({
|
|
327
|
+
resource: { aws_s3_bucket: { assets: [{ bucket: "app-assets" }] } },
|
|
328
|
+
locals: [{ assets_id: "${aws_s3_bucket.assets.id}" }],
|
|
329
|
+
output: { bucket_id: [{ value: "${local.assets_id}" }] },
|
|
330
|
+
});
|
|
331
|
+
expect(inboundEdges(g, "aws_s3_bucket.assets")).toEqual([
|
|
332
|
+
{ from: "output.bucket_id", to: "aws_s3_bucket.assets", attrs: ["id"], via: ["value"], fromKind: "output" },
|
|
333
|
+
]);
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
test("locals and data bodies reach the expression AST at all", () => {
|
|
337
|
+
expect(collectExpressions(threeRoutes)).toEqual([
|
|
338
|
+
"${aws_s3_bucket.assets.arn}",
|
|
339
|
+
"${aws_s3_bucket.assets.bucket}",
|
|
340
|
+
"${aws_s3_bucket.assets.id}",
|
|
341
|
+
"${data.aws_s3_bucket.lookup.arn}",
|
|
342
|
+
"${local.assets_id}",
|
|
343
|
+
]);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
test("a referrer is never an edge endpoint of its own", () => {
|
|
347
|
+
// The edge belongs to the surviving node, which is the thing a reader can
|
|
348
|
+
// carve or leave behind; the referrer is where the rewrite lands, not a
|
|
349
|
+
// participant. A local that names no resource contributes nothing at all.
|
|
350
|
+
const g = buildFixtureGraph(threeRoutes);
|
|
351
|
+
for (const referrer of ["local.assets_id", "data.aws_s3_bucket.lookup"]) {
|
|
352
|
+
expect([referrer, inboundEdges(g, referrer)]).toEqual([referrer, []]);
|
|
353
|
+
expect([referrer, outboundEdges(g, referrer)]).toEqual([referrer, []]);
|
|
354
|
+
}
|
|
355
|
+
expect(
|
|
356
|
+
buildFixtureGraph({
|
|
357
|
+
resource: { aws_lambda_function: { api: [{ environment: { variables: { B: "${local.env}" } } }] } },
|
|
358
|
+
locals: [{ env: "${var.stage}" }],
|
|
359
|
+
}).edges,
|
|
360
|
+
).toEqual([]);
|
|
361
|
+
});
|
|
362
|
+
});
|
|
363
|
+
|
|
217
364
|
describe("refFromAccessor", () => {
|
|
218
365
|
test("classifies resource, module, and data accessors", () => {
|
|
219
366
|
expect(refFromAccessor("aws_s3_bucket.assets.bucket")).toEqual({
|
package/src/terraform/graph.ts
CHANGED
|
@@ -81,10 +81,32 @@ export function refFromAccessor(accessor: string): RawRef | null {
|
|
|
81
81
|
return { address: `${parts[0]}.${parts[1]}`, attr: attrAfter(2) };
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
/**
|
|
85
|
+
* The referrer key a `local.<name>` accessor names, or null for anything else.
|
|
86
|
+
*
|
|
87
|
+
* `refFromAccessor` is right to refuse `local.*`: a local is a substitution,
|
|
88
|
+
* has no address in the plan graph, and nothing carves one. But the resource a
|
|
89
|
+
* local ultimately names is a dependency all the same, so the accessor is
|
|
90
|
+
* carried this far to be resolved through the locals table (#2324) rather than
|
|
91
|
+
* dropped at the resource-head guard.
|
|
92
|
+
*/
|
|
93
|
+
function localKeyFromAccessor(accessor: string): string | null {
|
|
94
|
+
const parts = accessorSegments(accessor);
|
|
95
|
+
const name = parts[1];
|
|
96
|
+
return parts[0] === "local" && name !== undefined && NAME.test(name) ? `local.${name}` : null;
|
|
97
|
+
}
|
|
98
|
+
|
|
84
99
|
/**
|
|
85
100
|
* Every string value carrying an interpolation across the tree's resource,
|
|
86
|
-
* module and
|
|
87
|
-
* through the AST before `buildGraph` can classify
|
|
101
|
+
* module, output, `locals` and `data` blocks — exactly the expressions
|
|
102
|
+
* `parse.ts` must resolve through the AST before `buildGraph` can classify
|
|
103
|
+
* them.
|
|
104
|
+
*
|
|
105
|
+
* `locals` and `data` are here because a reference can reach a resource
|
|
106
|
+
* through them (#2324): a survivor reading `local.assets_id`, or a `data`
|
|
107
|
+
* source keyed off the carved resource, is a dependency the plan breaks on
|
|
108
|
+
* just as hard as a direct one. Skipping their bodies meant the AST never saw
|
|
109
|
+
* those expressions at all.
|
|
88
110
|
*/
|
|
89
111
|
export function collectExpressions(tree: Hcl2JsonTree): string[] {
|
|
90
112
|
const exprs = new Set<string>();
|
|
@@ -100,6 +122,8 @@ export function collectExpressions(tree: Hcl2JsonTree): string[] {
|
|
|
100
122
|
for (const named of Object.values(tree.resource ?? {})) for (const blocks of Object.values(named)) visit(blocks);
|
|
101
123
|
for (const blocks of Object.values(tree.module ?? {})) visit(blocks);
|
|
102
124
|
for (const blocks of Object.values(tree.output ?? {})) visit(blocks);
|
|
125
|
+
visit(tree.locals);
|
|
126
|
+
for (const named of Object.values(tree.data ?? {})) for (const blocks of Object.values(named)) visit(blocks);
|
|
103
127
|
return [...exprs].sort();
|
|
104
128
|
}
|
|
105
129
|
|
|
@@ -111,7 +135,12 @@ function refsInValue(value: unknown, exprRefs: ExpressionRefs): RawRef[] {
|
|
|
111
135
|
if (!v.includes("${")) return;
|
|
112
136
|
for (const accessor of exprRefs.get(v) ?? []) {
|
|
113
137
|
const ref = refFromAccessor(accessor);
|
|
114
|
-
if (ref)
|
|
138
|
+
if (ref) {
|
|
139
|
+
refs.push(ref);
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
const localKey = localKeyFromAccessor(accessor);
|
|
143
|
+
if (localKey) refs.push({ address: localKey });
|
|
115
144
|
}
|
|
116
145
|
} else if (Array.isArray(v)) {
|
|
117
146
|
v.forEach(visit);
|
|
@@ -137,6 +166,105 @@ function refsInBlock(block: unknown, exprRefs: ExpressionRefs): RawRef[] {
|
|
|
137
166
|
return refs;
|
|
138
167
|
}
|
|
139
168
|
|
|
169
|
+
/**
|
|
170
|
+
* Non-node referrers: `local.<name>` and `data.<type>.<name>` → the references
|
|
171
|
+
* in the body each one stands for (#2324).
|
|
172
|
+
*
|
|
173
|
+
* Neither is a graph node — a local is a substitution, and a data source is
|
|
174
|
+
* not carvable infrastructure — but both sit on a path between a survivor and
|
|
175
|
+
* a resource, which is the shape an `output` block already has here. A
|
|
176
|
+
* `locals` block arrives from hcl2json as an array of its assignments, one
|
|
177
|
+
* element per `locals` block in the estate.
|
|
178
|
+
*/
|
|
179
|
+
function referrerTable(tree: Hcl2JsonTree, exprRefs: ExpressionRefs): Map<string, RawRef[]> {
|
|
180
|
+
const table = new Map<string, RawRef[]>();
|
|
181
|
+
const add = (key: string, refs: RawRef[]): void => {
|
|
182
|
+
const existing = table.get(key);
|
|
183
|
+
if (existing) existing.push(...refs);
|
|
184
|
+
else table.set(key, refs);
|
|
185
|
+
};
|
|
186
|
+
const localsBlocks = Array.isArray(tree.locals) ? tree.locals : tree.locals ? [tree.locals] : [];
|
|
187
|
+
for (const block of localsBlocks) {
|
|
188
|
+
if (!block || typeof block !== "object") continue;
|
|
189
|
+
for (const [name, value] of Object.entries(block as Record<string, unknown>)) {
|
|
190
|
+
add(`local.${name}`, refsInValue(value, exprRefs));
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
for (const [type, named] of Object.entries(tree.data ?? {})) {
|
|
194
|
+
for (const [name, blocks] of Object.entries(named)) {
|
|
195
|
+
add(`data.${type}.${name}`, refsInBlock(Array.isArray(blocks) ? blocks[0] : blocks, exprRefs));
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return table;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Identity of a reference for de-duplication: the address plus the attribute read off it. */
|
|
202
|
+
function refKey(ref: RawRef): string {
|
|
203
|
+
return `${ref.address}\u0000${ref.attr ?? ""}`;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Resolve every referrer entry to the node references it ultimately names.
|
|
208
|
+
*
|
|
209
|
+
* One substitution pass is not enough: a local can read another local, and a
|
|
210
|
+
* `data` source can read a local that reads another data source. So this
|
|
211
|
+
* iterates to a fixpoint — each round replaces a referrer key appearing in an
|
|
212
|
+
* entry with that key's own resolved references, and the loop stops when a
|
|
213
|
+
* round adds nothing new.
|
|
214
|
+
*
|
|
215
|
+
* Growth is monotone and bounded by (referrers x distinct references), so a
|
|
216
|
+
* self-referential local (`a = local.a`, skipped outright) or a cycle
|
|
217
|
+
* (`a = local.b`, `b = local.a`) converges instead of recursing. Terraform
|
|
218
|
+
* rejects both, but the advisor only reads an estate and must not hang on one
|
|
219
|
+
* it disagrees with.
|
|
220
|
+
*/
|
|
221
|
+
function resolveReferrers(table: ReadonlyMap<string, RawRef[]>): Map<string, RawRef[]> {
|
|
222
|
+
// Seed each entry with the references that already name something outside
|
|
223
|
+
// the table; the loop then folds in what the referrer keys resolve to.
|
|
224
|
+
const resolved = new Map<string, Map<string, RawRef>>();
|
|
225
|
+
for (const [key, refs] of table) {
|
|
226
|
+
resolved.set(key, new Map(refs.filter((r) => !table.has(r.address)).map((r) => [refKey(r), r])));
|
|
227
|
+
}
|
|
228
|
+
let changed = true;
|
|
229
|
+
while (changed) {
|
|
230
|
+
changed = false;
|
|
231
|
+
for (const [key, refs] of table) {
|
|
232
|
+
const into = resolved.get(key)!;
|
|
233
|
+
for (const ref of refs) {
|
|
234
|
+
if (ref.address === key) continue; // `a = local.a` — nothing to fold in but itself
|
|
235
|
+
const from = resolved.get(ref.address);
|
|
236
|
+
if (!from) continue;
|
|
237
|
+
for (const [id, inner] of from) {
|
|
238
|
+
if (into.has(id)) continue;
|
|
239
|
+
into.set(id, inner);
|
|
240
|
+
changed = true;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return new Map([...resolved].map(([key, refs]) => [key, [...refs.values()]]));
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* A block's references as the graph should see them: one that reaches a
|
|
250
|
+
* `local` or `data` referrer additionally yields the node reference it
|
|
251
|
+
* resolves to.
|
|
252
|
+
*
|
|
253
|
+
* The original reference is kept — a `data.*` address is what marks the
|
|
254
|
+
* referring node dynamic, and an address that is not a node is dropped at edge
|
|
255
|
+
* time anyway. The resolved reference keeps the referring block's own `via`
|
|
256
|
+
* attribute, since that is what a deferred input is named after, and takes the
|
|
257
|
+
* carried resource attribute from the referrer's body.
|
|
258
|
+
*/
|
|
259
|
+
function throughReferrers(refs: readonly RawRef[], resolved: ReadonlyMap<string, RawRef[]>): RawRef[] {
|
|
260
|
+
const out: RawRef[] = [];
|
|
261
|
+
for (const ref of refs) {
|
|
262
|
+
out.push(ref);
|
|
263
|
+
for (const inner of resolved.get(ref.address) ?? []) out.push({ ...inner, via: ref.via });
|
|
264
|
+
}
|
|
265
|
+
return out;
|
|
266
|
+
}
|
|
267
|
+
|
|
140
268
|
/** A block carries `count`/`for_each` → dynamic, single instance until state resolves it. */
|
|
141
269
|
function blockHasMeta(block: unknown, key: string): boolean {
|
|
142
270
|
return !!block && typeof block === "object" && key in (block as Record<string, unknown>);
|
|
@@ -201,10 +329,20 @@ function dataSourceValues(block: unknown, type: string): Record<string, string>
|
|
|
201
329
|
* exactly like a resource's does. So an output contributes an edge tagged
|
|
202
330
|
* `fromKind: "output"` from the pseudo-address `output.<name>` (#1638),
|
|
203
331
|
* which the scorer weights lower and `carve bridge` patches.
|
|
332
|
+
*
|
|
333
|
+
* `locals` and `data` blocks are non-node referrers of a third kind (#2324):
|
|
334
|
+
* they carry a reference between two nodes rather than terminating it. A
|
|
335
|
+
* survivor reading `local.assets_id`, or reading a `data` source keyed off the
|
|
336
|
+
* carved resource, depends on that resource, so the reference is resolved
|
|
337
|
+
* through the referrer to the resource it ultimately names and the edge is
|
|
338
|
+
* recorded against the surviving node — the thing a reader can actually carve
|
|
339
|
+
* or leave behind. The referrer's own body is where `carve bridge` then lands
|
|
340
|
+
* the rewrite.
|
|
204
341
|
*/
|
|
205
342
|
export function buildGraph(tree: Hcl2JsonTree, exprRefs: ExpressionRefs): TfGraph {
|
|
206
343
|
const nodes: TfNode[] = [];
|
|
207
344
|
const dataAddresses = new Set<string>();
|
|
345
|
+
const referrers = resolveReferrers(referrerTable(tree, exprRefs));
|
|
208
346
|
|
|
209
347
|
// First pass: register data-source addresses so refs to them can be spotted.
|
|
210
348
|
for (const [type, named] of Object.entries(tree.data ?? {})) {
|
|
@@ -218,7 +356,7 @@ export function buildGraph(tree: Hcl2JsonTree, exprRefs: ExpressionRefs): TfGrap
|
|
|
218
356
|
const address = `${type}.${name}`;
|
|
219
357
|
const block = Array.isArray(blocks) ? blocks[0] : blocks;
|
|
220
358
|
const dynamic = blockHasMeta(block, "count") || blockHasMeta(block, "for_each");
|
|
221
|
-
const refs = refsInBlock(block, exprRefs);
|
|
359
|
+
const refs = throughReferrers(refsInBlock(block, exprRefs), referrers);
|
|
222
360
|
const touchesData = refs.some((r) => dataAddresses.has(r.address));
|
|
223
361
|
rawRefsByNode.set(address, refs);
|
|
224
362
|
nodes.push({
|
|
@@ -239,7 +377,7 @@ export function buildGraph(tree: Hcl2JsonTree, exprRefs: ExpressionRefs): TfGrap
|
|
|
239
377
|
const address = `module.${name}`;
|
|
240
378
|
const block = Array.isArray(blocks) ? blocks[0] : blocks;
|
|
241
379
|
const dynamic = blockHasMeta(block, "count") || blockHasMeta(block, "for_each");
|
|
242
|
-
const refs = refsInBlock(block, exprRefs);
|
|
380
|
+
const refs = throughReferrers(refsInBlock(block, exprRefs), referrers);
|
|
243
381
|
const touchesData = refs.some((r) => dataAddresses.has(r.address));
|
|
244
382
|
rawRefsByNode.set(address, refs);
|
|
245
383
|
nodes.push({
|
|
@@ -256,7 +394,7 @@ export function buildGraph(tree: Hcl2JsonTree, exprRefs: ExpressionRefs): TfGrap
|
|
|
256
394
|
const outputRefs = new Map<string, RawRef[]>();
|
|
257
395
|
for (const [name, blocks] of Object.entries(tree.output ?? {})) {
|
|
258
396
|
const block = Array.isArray(blocks) ? blocks[0] : blocks;
|
|
259
|
-
outputRefs.set(`output.${name}`, refsInBlock(block, exprRefs));
|
|
397
|
+
outputRefs.set(`output.${name}`, throughReferrers(refsInBlock(block, exprRefs), referrers));
|
|
260
398
|
}
|
|
261
399
|
|
|
262
400
|
// Edges: keep only references that resolve to a known node.
|
package/src/terraform/parse.ts
CHANGED
|
@@ -68,7 +68,7 @@ async function resolveExpressionRefs(hcl2json: Hcl2Json, tree: Hcl2JsonTree): Pr
|
|
|
68
68
|
return refs;
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
/** Deep-merge hcl2json trees across files (resource/module/data/output namespaces). */
|
|
71
|
+
/** Deep-merge hcl2json trees across files (resource/module/data/output/locals namespaces). */
|
|
72
72
|
function mergeTrees(into: Hcl2JsonTree, next: Hcl2JsonTree): void {
|
|
73
73
|
for (const section of ["resource", "data"] as const) {
|
|
74
74
|
const src = next[section];
|
|
@@ -82,6 +82,9 @@ function mergeTrees(into: Hcl2JsonTree, next: Hcl2JsonTree): void {
|
|
|
82
82
|
// Outputs usually live in their own file (#1638) — merge them, or the graph
|
|
83
83
|
// never sees the estate's outputs.tf at all.
|
|
84
84
|
if (next.output) into.output = { ...(into.output ?? {}), ...next.output };
|
|
85
|
+
// `locals` is an array of blocks, not a keyed namespace, so files concatenate
|
|
86
|
+
// rather than overwrite — a `locals` block in every .tf is idiomatic (#2324).
|
|
87
|
+
if (next.locals) into.locals = [...(into.locals ?? []), ...next.locals];
|
|
85
88
|
}
|
|
86
89
|
|
|
87
90
|
/** List every `.tf` file directly under `dir` (non-recursive; matches Terraform's own module scoping). */
|
package/src/terraform/types.ts
CHANGED
|
@@ -97,5 +97,12 @@ export interface Hcl2JsonTree {
|
|
|
97
97
|
data?: Record<string, Record<string, unknown[]>>;
|
|
98
98
|
/** Root-module `output` blocks. Not nodes — they reference, they are not carvable (#1638). */
|
|
99
99
|
output?: Record<string, unknown[]>;
|
|
100
|
+
/**
|
|
101
|
+
* Root-module `locals` blocks, as hcl2json renders them: one array element
|
|
102
|
+
* per `locals` block, each an object of its assignments. Not nodes either —
|
|
103
|
+
* a local is a substitution — but a reference can reach a resource through
|
|
104
|
+
* one, so the graph resolves `local.x` to what it names (#2324).
|
|
105
|
+
*/
|
|
106
|
+
locals?: unknown[];
|
|
100
107
|
[k: string]: unknown;
|
|
101
108
|
}
|