@intentius/chant 0.41.20 → 0.42.1
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/commands/carve-apply.d.ts +9 -0
- package/dist/cli/commands/carve-apply.d.ts.map +1 -1
- package/dist/cli/commands/carve-bridge.d.ts +7 -0
- package/dist/cli/commands/carve-bridge.d.ts.map +1 -1
- package/dist/cli/commands/carve-emit.d.ts +7 -0
- package/dist/cli/commands/carve-emit.d.ts.map +1 -1
- package/dist/cli/commands/init.d.ts +2 -0
- package/dist/cli/commands/init.d.ts.map +1 -1
- package/dist/cli/handlers/carve-apply.d.ts.map +1 -1
- package/dist/cli/handlers/components.d.ts.map +1 -1
- package/dist/cli/handlers/explain.d.ts +11 -0
- package/dist/cli/handlers/explain.d.ts.map +1 -0
- package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/mcp/tools/explain.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +2 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/codegen/docs-rule-scanning.d.ts +11 -0
- package/dist/codegen/docs-rule-scanning.d.ts.map +1 -1
- package/dist/codegen/okf-lexicon.d.ts +36 -0
- package/dist/codegen/okf-lexicon.d.ts.map +1 -0
- package/dist/codegen/package.d.ts +2 -1
- package/dist/codegen/package.d.ts.map +1 -1
- package/dist/components/verbs/sbom.d.ts +1 -1
- package/dist/components/verbs/sbom.d.ts.map +1 -1
- package/dist/components/verbs/vuln-gate.d.ts +15 -4
- package/dist/components/verbs/vuln-gate.d.ts.map +1 -1
- package/dist/components/verbs/vuln-scan.d.ts +18 -6
- package/dist/components/verbs/vuln-scan.d.ts.map +1 -1
- package/dist/config.d.ts +12 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/deep-observation.d.ts +13 -0
- package/dist/deep-observation.d.ts.map +1 -1
- package/dist/lexicon.d.ts +3 -0
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/observe.d.ts.map +1 -1
- package/dist/lifecycle/snapshot.d.ts.map +1 -1
- package/dist/okf.d.ts +57 -0
- package/dist/okf.d.ts.map +1 -0
- package/dist/terraform/__fixtures__/build-graph.d.ts +13 -0
- package/dist/terraform/__fixtures__/build-graph.d.ts.map +1 -0
- package/dist/terraform/adopt-state.d.ts +33 -1
- package/dist/terraform/adopt-state.d.ts.map +1 -1
- package/dist/terraform/bridge.d.ts +7 -1
- package/dist/terraform/bridge.d.ts.map +1 -1
- package/dist/terraform/carve.d.ts +12 -0
- package/dist/terraform/carve.d.ts.map +1 -1
- package/dist/terraform/excise.d.ts +31 -0
- package/dist/terraform/excise.d.ts.map +1 -0
- package/dist/terraform/graduate.d.ts +12 -0
- package/dist/terraform/graduate.d.ts.map +1 -1
- package/dist/terraform/graph.d.ts +35 -5
- package/dist/terraform/graph.d.ts.map +1 -1
- package/dist/terraform/manifest.d.ts +89 -0
- package/dist/terraform/manifest.d.ts.map +1 -0
- package/dist/terraform/parse.d.ts +9 -4
- 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/dist/terraform/unified-diff.d.ts +11 -0
- package/dist/terraform/unified-diff.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/__snapshots__/okf.test.ts.snap +381 -0
- package/src/cli/commands/carve-apply.test.ts +94 -2
- package/src/cli/commands/carve-apply.ts +62 -11
- package/src/cli/commands/carve-bridge.test.ts +82 -4
- package/src/cli/commands/carve-bridge.ts +79 -11
- package/src/cli/commands/carve-emit-state.test.ts +110 -1
- package/src/cli/commands/carve-emit.ts +184 -8
- package/src/cli/commands/init.ts +1 -1
- package/src/cli/handlers/carve-apply.ts +1 -0
- package/src/cli/handlers/components.ts +4 -2
- package/src/cli/handlers/explain.test.ts +93 -0
- package/src/cli/handlers/explain.ts +60 -0
- package/src/cli/handlers/lifecycle.ts +9 -7
- package/src/cli/main.ts +21 -5
- package/src/cli/mcp/server.test.ts +17 -0
- package/src/cli/mcp/tools/explain.ts +15 -3
- package/src/cli/registry.ts +2 -0
- package/src/codegen/__snapshots__/okf-lexicon.test.ts.snap +170 -0
- package/src/codegen/docs-rule-scanning.ts +35 -14
- package/src/codegen/okf-lexicon.test.ts +248 -0
- package/src/codegen/okf-lexicon.ts +303 -0
- package/src/codegen/package.ts +29 -4
- package/src/components/config-defaults.test.ts +30 -0
- package/src/components/verbs/__fixtures__/grype-with-kev-epss.json +1 -0
- package/src/components/verbs/__fixtures__/trivy-with-kev-epss.json +631 -0
- package/src/components/verbs/exploitability-roundtrip.test.ts +190 -0
- package/src/components/verbs/sbom.ts +1 -1
- package/src/components/verbs/vuln-gate.test.ts +144 -1
- package/src/components/verbs/vuln-gate.ts +61 -10
- package/src/components/verbs/vuln-scan.test.ts +94 -0
- package/src/components/verbs/vuln-scan.ts +44 -9
- package/src/config.test.ts +21 -0
- package/src/config.ts +18 -0
- package/src/deep-observation.test.ts +19 -0
- package/src/deep-observation.ts +32 -13
- package/src/lexicon.ts +3 -0
- package/src/lifecycle/observe.test.ts +18 -0
- package/src/lifecycle/observe.ts +10 -3
- package/src/lifecycle/snapshot.ts +5 -4
- package/src/okf.test.ts +169 -0
- package/src/okf.ts +308 -0
- package/src/terraform/__fixtures__/build-graph.ts +25 -0
- package/src/terraform/adopt-state.test.ts +34 -1
- package/src/terraform/adopt-state.ts +69 -2
- package/src/terraform/bridge.test.ts +38 -14
- package/src/terraform/bridge.ts +48 -14
- package/src/terraform/carve.test.ts +9 -7
- package/src/terraform/carve.ts +0 -0
- package/src/terraform/excise.test.ts +92 -0
- package/src/terraform/excise.ts +139 -0
- package/src/terraform/graduate.test.ts +73 -6
- package/src/terraform/graduate.ts +49 -0
- package/src/terraform/graph.test.ts +56 -10
- package/src/terraform/graph.ts +112 -45
- package/src/terraform/manifest.test.ts +105 -0
- package/src/terraform/manifest.ts +162 -0
- package/src/terraform/parse.test.ts +39 -1
- package/src/terraform/parse.ts +36 -9
- package/src/terraform/score.test.ts +8 -8
- package/src/terraform/state.test.ts +4 -4
- package/src/terraform/types.ts +7 -0
- package/src/terraform/unified-diff.test.ts +77 -0
- package/src/terraform/unified-diff.ts +146 -0
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, test, expect } from "vitest";
|
|
2
|
-
import {
|
|
2
|
+
import { inboundEdges, outboundEdges, refFromAccessor } from "./graph";
|
|
3
|
+
import { buildFixtureGraph } from "./__fixtures__/build-graph";
|
|
3
4
|
import type { Hcl2JsonTree } from "./types";
|
|
4
5
|
|
|
5
6
|
/**
|
|
@@ -37,7 +38,7 @@ const workedExample: Hcl2JsonTree = {
|
|
|
37
38
|
|
|
38
39
|
describe("buildGraph", () => {
|
|
39
40
|
test("extracts resource nodes with addresses", () => {
|
|
40
|
-
const g =
|
|
41
|
+
const g = buildFixtureGraph(workedExample);
|
|
41
42
|
expect(g.nodes.map((n) => n.address)).toEqual([
|
|
42
43
|
"aws_lambda_function.api",
|
|
43
44
|
"aws_s3_bucket.assets",
|
|
@@ -47,16 +48,16 @@ describe("buildGraph", () => {
|
|
|
47
48
|
});
|
|
48
49
|
|
|
49
50
|
test("records directed edges with referenced attributes, deduped per target", () => {
|
|
50
|
-
const g =
|
|
51
|
+
const g = buildFixtureGraph(workedExample);
|
|
51
52
|
// versioning → bucket (.id), lambda → bucket (.bucket, .arn)
|
|
52
53
|
expect(g.edges).toEqual([
|
|
53
|
-
{ from: "aws_lambda_function.api", to: "aws_s3_bucket.assets", attrs: ["arn", "bucket"] },
|
|
54
|
-
{ from: "aws_s3_bucket_versioning.assets", to: "aws_s3_bucket.assets", attrs: ["id"] },
|
|
54
|
+
{ from: "aws_lambda_function.api", to: "aws_s3_bucket.assets", attrs: ["arn", "bucket"], via: ["environment"] },
|
|
55
|
+
{ from: "aws_s3_bucket_versioning.assets", to: "aws_s3_bucket.assets", attrs: ["id"], via: ["bucket"] },
|
|
55
56
|
]);
|
|
56
57
|
});
|
|
57
58
|
|
|
58
59
|
test("inbound/outbound classification", () => {
|
|
59
|
-
const g =
|
|
60
|
+
const g = buildFixtureGraph(workedExample);
|
|
60
61
|
// Two survivors depend on the bucket; the bucket depends on nothing.
|
|
61
62
|
expect(inboundEdges(g, "aws_s3_bucket.assets").map((e) => e.from)).toEqual([
|
|
62
63
|
"aws_lambda_function.api",
|
|
@@ -78,7 +79,7 @@ describe("buildGraph", () => {
|
|
|
78
79
|
aws_route53_record: { cdn: [{ name: "${module.cdn.domain}" }] },
|
|
79
80
|
},
|
|
80
81
|
};
|
|
81
|
-
const g =
|
|
82
|
+
const g = buildFixtureGraph(tree);
|
|
82
83
|
const cdn = g.nodes.find((n) => n.address === "module.cdn");
|
|
83
84
|
expect(cdn).toMatchObject({ kind: "module", name: "cdn" });
|
|
84
85
|
// module → bucket, and record → module
|
|
@@ -86,11 +87,13 @@ describe("buildGraph", () => {
|
|
|
86
87
|
from: "module.cdn",
|
|
87
88
|
to: "aws_s3_bucket.assets",
|
|
88
89
|
attrs: ["arn"],
|
|
90
|
+
via: ["bucket_arn"],
|
|
89
91
|
});
|
|
90
92
|
expect(g.edges).toContainEqual({
|
|
91
93
|
from: "aws_route53_record.cdn",
|
|
92
94
|
to: "module.cdn",
|
|
93
95
|
attrs: ["domain"],
|
|
96
|
+
via: ["name"],
|
|
94
97
|
});
|
|
95
98
|
});
|
|
96
99
|
|
|
@@ -104,7 +107,7 @@ describe("buildGraph", () => {
|
|
|
104
107
|
},
|
|
105
108
|
},
|
|
106
109
|
};
|
|
107
|
-
const g =
|
|
110
|
+
const g = buildFixtureGraph(tree);
|
|
108
111
|
const byAddr = Object.fromEntries(g.nodes.map((n) => [n.address, n]));
|
|
109
112
|
expect(byAddr["aws_instance.web"]).toMatchObject({ hasDynamic: true, instances: 1 });
|
|
110
113
|
expect(byAddr["aws_instance.worker"]).toMatchObject({ hasDynamic: true, instances: 1 });
|
|
@@ -118,20 +121,63 @@ describe("buildGraph", () => {
|
|
|
118
121
|
aws_instance: { web: [{ ami: "${data.aws_ami.ubuntu.id}" }] },
|
|
119
122
|
},
|
|
120
123
|
};
|
|
121
|
-
const g =
|
|
124
|
+
const g = buildFixtureGraph(tree);
|
|
122
125
|
expect(g.nodes.map((n) => n.address)).toEqual(["aws_instance.web"]); // data is not a node
|
|
123
126
|
expect(g.edges).toEqual([]); // ref to data → no resource edge
|
|
124
127
|
expect(g.nodes[0].hasDynamic).toBe(true);
|
|
125
128
|
});
|
|
126
129
|
|
|
130
|
+
test("an expression whose AST found no references contributes no edge", () => {
|
|
131
|
+
// `${var.m["aws_s3_bucket.assets.arn"]}` — the AST reports only `var.m`;
|
|
132
|
+
// the quoted address inside the brackets is a map key, not a reference.
|
|
133
|
+
const tree: Hcl2JsonTree = {
|
|
134
|
+
resource: {
|
|
135
|
+
aws_s3_bucket: { assets: [{ bucket: "x" }] },
|
|
136
|
+
aws_lambda_function: { api: [{ handler: '${var.m["aws_s3_bucket.assets.arn"]}' }] },
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
const g = buildFixtureGraph(tree);
|
|
140
|
+
expect(g.edges).toEqual([]);
|
|
141
|
+
});
|
|
142
|
+
|
|
127
143
|
test("var / local references are not edges", () => {
|
|
128
144
|
const tree: Hcl2JsonTree = {
|
|
129
145
|
resource: {
|
|
130
146
|
aws_s3_bucket: { assets: [{ bucket: "${var.name}", tags: { env: "${local.env}" } }] },
|
|
131
147
|
},
|
|
132
148
|
};
|
|
133
|
-
const g =
|
|
149
|
+
const g = buildFixtureGraph(tree);
|
|
134
150
|
expect(g.edges).toEqual([]);
|
|
135
151
|
expect(g.nodes[0].hasDynamic).toBe(false); // var/local alone are not the dynamic markers
|
|
136
152
|
});
|
|
137
153
|
});
|
|
154
|
+
|
|
155
|
+
describe("refFromAccessor", () => {
|
|
156
|
+
test("classifies resource, module, and data accessors", () => {
|
|
157
|
+
expect(refFromAccessor("aws_s3_bucket.assets.bucket")).toEqual({
|
|
158
|
+
address: "aws_s3_bucket.assets",
|
|
159
|
+
attr: "bucket",
|
|
160
|
+
});
|
|
161
|
+
expect(refFromAccessor("aws_s3_bucket.assets")).toEqual({ address: "aws_s3_bucket.assets", attr: undefined });
|
|
162
|
+
expect(refFromAccessor("module.cdn.domain")).toEqual({ address: "module.cdn", attr: "domain" });
|
|
163
|
+
expect(refFromAccessor("data.aws_ami.ubuntu.id")).toEqual({ address: "data.aws_ami.ubuntu", attr: "id" });
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("non-resource heads are not references", () => {
|
|
167
|
+
for (const accessor of ["var.name", "local.env", "each.value", "count.index", "self.arn", "path.module", "terraform.workspace"]) {
|
|
168
|
+
expect(refFromAccessor(accessor)).toBeNull();
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("quoted map keys and numeric indexes never become the attribute", () => {
|
|
173
|
+
// `var.m["aws_s3_bucket.assets.arn"]` → the AST renders `var.m."aws_s3_bucket.assets.arn"`
|
|
174
|
+
expect(refFromAccessor('var.m."aws_s3_bucket.assets.arn"')).toBeNull();
|
|
175
|
+
// `aws_instance.web[0].id` → `aws_instance.web.0.id`
|
|
176
|
+
expect(refFromAccessor("aws_instance.web.0.id")).toEqual({ address: "aws_instance.web", attr: "id" });
|
|
177
|
+
// `aws_s3_bucket.assets.tags["a.b"]` → the key must not shadow the attr
|
|
178
|
+
expect(refFromAccessor('aws_s3_bucket.assets.tags."a.b"')).toEqual({
|
|
179
|
+
address: "aws_s3_bucket.assets",
|
|
180
|
+
attr: "tags",
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
});
|
package/src/terraform/graph.ts
CHANGED
|
@@ -1,65 +1,116 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Pure Terraform dependency-graph builder for the carve-out advisor (#214 T1).
|
|
3
3
|
*
|
|
4
|
-
* Input is the JSON tree `@cdktf/hcl2json` produces (`Hcl2JsonTree`)
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* Input is the JSON tree `@cdktf/hcl2json` produces (`Hcl2JsonTree`) plus the
|
|
5
|
+
* traversal accessors its expression AST found per interpolated string
|
|
6
|
+
* (`ExpressionRefs`, resolved in `parse.ts` — #998). Tokenizing `${...}`
|
|
7
|
+
* expression bodies is the AST's job — a quoted address inside an expression
|
|
8
|
+
* (`var.m["aws_s3_bucket.assets.arn"]`) or an escaped `$${...}` literal is not
|
|
9
|
+
* a reference, which the regex scan this replaced could not tell. This module
|
|
10
|
+
* only classifies the accessors the AST produced: no wasm, no filesystem, so
|
|
11
|
+
* graph building stays unit-testable on hand-written fixtures.
|
|
8
12
|
*/
|
|
9
13
|
|
|
10
14
|
import { IDENTITY_ATTR } from "./tier-map";
|
|
11
15
|
import type { Hcl2JsonTree, TfEdge, TfGraph, TfNode } from "./types";
|
|
12
16
|
|
|
13
17
|
/**
|
|
14
|
-
*
|
|
15
|
-
*
|
|
18
|
+
* Traversal accessors per interpolated string, as the hcl2json expression AST
|
|
19
|
+
* reports them (`getReferencesInExpression`): `"${aws_s3_bucket.assets.arn}"`
|
|
20
|
+
* maps to `["aws_s3_bucket.assets.arn"]`. Keys are the raw hcl2json string
|
|
21
|
+
* values; a missing key means the expression yielded no references.
|
|
16
22
|
*/
|
|
17
|
-
|
|
23
|
+
export type ExpressionRefs = ReadonlyMap<string, readonly string[]>;
|
|
18
24
|
|
|
19
|
-
/**
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
/** `data.<type>.<name>` reference head. */
|
|
23
|
-
const DATA_REF = /\bdata\.([a-z][a-z0-9_]*)\.([A-Za-z0-9_-]+)(?:\.([A-Za-z0-9_]+))?/g;
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* `<type>.<name>` resource reference head. Deliberately broad — it also
|
|
27
|
-
* matches `var.x`, `local.y`, `each.value`, function-ish tokens. Only heads
|
|
28
|
-
* whose address is a known node survive into an edge (see `buildGraph`).
|
|
29
|
-
*/
|
|
30
|
-
const RESOURCE_REF = /\b([a-z][a-z0-9_]*)\.([A-Za-z0-9_-]+)(?:\.([A-Za-z0-9_]+))?/g;
|
|
31
|
-
|
|
32
|
-
/** Non-resource reference prefixes that must never be read as a resource type. */
|
|
33
|
-
const NON_RESOURCE_HEADS = new Set(["var", "local", "module", "data", "each", "count", "self", "path", "terraform"]);
|
|
25
|
+
/** Non-resource reference heads that must never be read as a resource type. */
|
|
26
|
+
const NON_RESOURCE_HEADS = new Set(["var", "local", "each", "count", "self", "path", "terraform"]);
|
|
34
27
|
|
|
35
28
|
interface RawRef {
|
|
36
29
|
address: string;
|
|
37
30
|
attr?: string;
|
|
31
|
+
/** The referring block's top-level attribute the reference sits in (#998). */
|
|
32
|
+
via?: string;
|
|
38
33
|
}
|
|
39
34
|
|
|
40
|
-
/**
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
35
|
+
/**
|
|
36
|
+
* Split a traversal accessor into segments. Dots inside a quoted map key
|
|
37
|
+
* (`var.m."a.b"` — how the AST renders `var.m["a.b"]`) do not split.
|
|
38
|
+
*/
|
|
39
|
+
function accessorSegments(accessor: string): string[] {
|
|
40
|
+
const segments: string[] = [];
|
|
41
|
+
let i = 0;
|
|
42
|
+
while (i < accessor.length) {
|
|
43
|
+
if (accessor[i] === '"') {
|
|
44
|
+
const close = accessor.indexOf('"', i + 1);
|
|
45
|
+
const end = close === -1 ? accessor.length : close + 1;
|
|
46
|
+
segments.push(accessor.slice(i, end));
|
|
47
|
+
i = end;
|
|
48
|
+
} else {
|
|
49
|
+
let j = i;
|
|
50
|
+
while (j < accessor.length && accessor[j] !== ".") j++;
|
|
51
|
+
segments.push(accessor.slice(i, j));
|
|
52
|
+
i = j;
|
|
53
|
+
}
|
|
54
|
+
if (accessor[i] === ".") i++;
|
|
46
55
|
}
|
|
47
|
-
|
|
48
|
-
|
|
56
|
+
return segments;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const NAME = /^[A-Za-z_][A-Za-z0-9_-]*$/;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Classify one AST traversal accessor into a resource/module/data reference.
|
|
63
|
+
* `var.*`/`local.*`/`each.*`-headed accessors are not references; quoted map
|
|
64
|
+
* keys and numeric indexes never become the attribute.
|
|
65
|
+
*/
|
|
66
|
+
export function refFromAccessor(accessor: string): RawRef | null {
|
|
67
|
+
const parts = accessorSegments(accessor);
|
|
68
|
+
const isName = (s: string | undefined): s is string => s !== undefined && NAME.test(s);
|
|
69
|
+
const attrAfter = (idx: number): string | undefined => parts.slice(idx).find((p) => isName(p));
|
|
70
|
+
|
|
71
|
+
if (parts[0] === "module") {
|
|
72
|
+
return isName(parts[1]) ? { address: `module.${parts[1]}`, attr: attrAfter(2) } : null;
|
|
49
73
|
}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
74
|
+
if (parts[0] === "data") {
|
|
75
|
+
return isName(parts[1]) && isName(parts[2])
|
|
76
|
+
? { address: `data.${parts[1]}.${parts[2]}`, attr: attrAfter(3) }
|
|
77
|
+
: null;
|
|
53
78
|
}
|
|
54
|
-
return
|
|
79
|
+
if (NON_RESOURCE_HEADS.has(parts[0]) || !isName(parts[0]) || !isName(parts[1])) return null;
|
|
80
|
+
return { address: `${parts[0]}.${parts[1]}`, attr: attrAfter(2) };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Every string value carrying an interpolation across the tree's resource and
|
|
85
|
+
* module blocks — exactly the expressions `parse.ts` must resolve through the
|
|
86
|
+
* AST before `buildGraph` can classify them.
|
|
87
|
+
*/
|
|
88
|
+
export function collectExpressions(tree: Hcl2JsonTree): string[] {
|
|
89
|
+
const exprs = new Set<string>();
|
|
90
|
+
const visit = (v: unknown): void => {
|
|
91
|
+
if (typeof v === "string") {
|
|
92
|
+
if (v.includes("${")) exprs.add(v);
|
|
93
|
+
} else if (Array.isArray(v)) {
|
|
94
|
+
v.forEach(visit);
|
|
95
|
+
} else if (v && typeof v === "object") {
|
|
96
|
+
for (const inner of Object.values(v as Record<string, unknown>)) visit(inner);
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
for (const named of Object.values(tree.resource ?? {})) for (const blocks of Object.values(named)) visit(blocks);
|
|
100
|
+
for (const blocks of Object.values(tree.module ?? {})) visit(blocks);
|
|
101
|
+
return [...exprs].sort();
|
|
55
102
|
}
|
|
56
103
|
|
|
57
|
-
/** Collect every
|
|
58
|
-
function refsInValue(value: unknown): RawRef[] {
|
|
104
|
+
/** Collect every reference reachable in a block's value tree, via the AST-resolved accessors. */
|
|
105
|
+
function refsInValue(value: unknown, exprRefs: ExpressionRefs): RawRef[] {
|
|
59
106
|
const refs: RawRef[] = [];
|
|
60
107
|
const visit = (v: unknown): void => {
|
|
61
108
|
if (typeof v === "string") {
|
|
62
|
-
|
|
109
|
+
if (!v.includes("${")) return;
|
|
110
|
+
for (const accessor of exprRefs.get(v) ?? []) {
|
|
111
|
+
const ref = refFromAccessor(accessor);
|
|
112
|
+
if (ref) refs.push(ref);
|
|
113
|
+
}
|
|
63
114
|
} else if (Array.isArray(v)) {
|
|
64
115
|
v.forEach(visit);
|
|
65
116
|
} else if (v && typeof v === "object") {
|
|
@@ -70,6 +121,20 @@ function refsInValue(value: unknown): RawRef[] {
|
|
|
70
121
|
return refs;
|
|
71
122
|
}
|
|
72
123
|
|
|
124
|
+
/**
|
|
125
|
+
* References in a whole block, each tagged with the top-level attribute it
|
|
126
|
+
* came in through — `via` is what a deferred outbound input gets named after
|
|
127
|
+
* when emit turns it into a build parameter (#998).
|
|
128
|
+
*/
|
|
129
|
+
function refsInBlock(block: unknown, exprRefs: ExpressionRefs): RawRef[] {
|
|
130
|
+
if (!block || typeof block !== "object" || Array.isArray(block)) return refsInValue(block, exprRefs);
|
|
131
|
+
const refs: RawRef[] = [];
|
|
132
|
+
for (const [key, value] of Object.entries(block as Record<string, unknown>)) {
|
|
133
|
+
for (const ref of refsInValue(value, exprRefs)) refs.push({ ...ref, via: key });
|
|
134
|
+
}
|
|
135
|
+
return refs;
|
|
136
|
+
}
|
|
137
|
+
|
|
73
138
|
/** A block carries `count`/`for_each` → dynamic, single instance until state resolves it. */
|
|
74
139
|
function blockHasMeta(block: unknown, key: string): boolean {
|
|
75
140
|
return !!block && typeof block === "object" && key in (block as Record<string, unknown>);
|
|
@@ -93,7 +158,7 @@ function literalIdentity(block: unknown, type: string): string | undefined {
|
|
|
93
158
|
* resolves to a known resource/module node — references to `var`/`local`/data
|
|
94
159
|
* are dropped.
|
|
95
160
|
*/
|
|
96
|
-
export function buildGraph(tree: Hcl2JsonTree): TfGraph {
|
|
161
|
+
export function buildGraph(tree: Hcl2JsonTree, exprRefs: ExpressionRefs): TfGraph {
|
|
97
162
|
const nodes: TfNode[] = [];
|
|
98
163
|
const dataAddresses = new Set<string>();
|
|
99
164
|
|
|
@@ -109,7 +174,7 @@ export function buildGraph(tree: Hcl2JsonTree): TfGraph {
|
|
|
109
174
|
const address = `${type}.${name}`;
|
|
110
175
|
const block = Array.isArray(blocks) ? blocks[0] : blocks;
|
|
111
176
|
const dynamic = blockHasMeta(block, "count") || blockHasMeta(block, "for_each");
|
|
112
|
-
const refs =
|
|
177
|
+
const refs = refsInBlock(block, exprRefs);
|
|
113
178
|
const touchesData = refs.some((r) => dataAddresses.has(r.address));
|
|
114
179
|
rawRefsByNode.set(address, refs);
|
|
115
180
|
nodes.push({
|
|
@@ -129,7 +194,7 @@ export function buildGraph(tree: Hcl2JsonTree): TfGraph {
|
|
|
129
194
|
const address = `module.${name}`;
|
|
130
195
|
const block = Array.isArray(blocks) ? blocks[0] : blocks;
|
|
131
196
|
const dynamic = blockHasMeta(block, "count") || blockHasMeta(block, "for_each");
|
|
132
|
-
const refs =
|
|
197
|
+
const refs = refsInBlock(block, exprRefs);
|
|
133
198
|
const touchesData = refs.some((r) => dataAddresses.has(r.address));
|
|
134
199
|
rawRefsByNode.set(address, refs);
|
|
135
200
|
nodes.push({
|
|
@@ -145,14 +210,16 @@ export function buildGraph(tree: Hcl2JsonTree): TfGraph {
|
|
|
145
210
|
const known = new Set(nodes.map((n) => n.address));
|
|
146
211
|
const edges: TfEdge[] = [];
|
|
147
212
|
for (const [from, refs] of rawRefsByNode) {
|
|
148
|
-
const byTarget = new Map<string, Set<string
|
|
213
|
+
const byTarget = new Map<string, { attrs: Set<string>; via: Set<string> }>();
|
|
149
214
|
for (const ref of refs) {
|
|
150
215
|
if (ref.address === from || !known.has(ref.address)) continue;
|
|
151
|
-
if (!byTarget.has(ref.address)) byTarget.set(ref.address, new Set());
|
|
152
|
-
|
|
216
|
+
if (!byTarget.has(ref.address)) byTarget.set(ref.address, { attrs: new Set(), via: new Set() });
|
|
217
|
+
const target = byTarget.get(ref.address)!;
|
|
218
|
+
if (ref.attr) target.attrs.add(ref.attr);
|
|
219
|
+
if (ref.via) target.via.add(ref.via);
|
|
153
220
|
}
|
|
154
|
-
for (const [to, attrs] of byTarget) {
|
|
155
|
-
edges.push({ from, to, attrs: [...attrs].sort() });
|
|
221
|
+
for (const [to, { attrs, via }] of byTarget) {
|
|
222
|
+
edges.push({ from, to, attrs: [...attrs].sort(), via: [...via].sort() });
|
|
156
223
|
}
|
|
157
224
|
}
|
|
158
225
|
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { mkdtempSync, rmSync, writeFileSync } from "fs";
|
|
3
|
+
import { tmpdir } from "os";
|
|
4
|
+
import { join } from "path";
|
|
5
|
+
import {
|
|
6
|
+
carveManifestPath,
|
|
7
|
+
listCarveManifests,
|
|
8
|
+
readCarveManifest,
|
|
9
|
+
resolveCarveManifest,
|
|
10
|
+
updateCarveManifest,
|
|
11
|
+
writeCarveManifest,
|
|
12
|
+
type CarveManifest,
|
|
13
|
+
} from "./manifest";
|
|
14
|
+
import type { CarveReport } from "./carve";
|
|
15
|
+
|
|
16
|
+
function report(target: string): CarveReport {
|
|
17
|
+
return {
|
|
18
|
+
target,
|
|
19
|
+
carveSet: [{ address: target, type: target.split(".")[0] }],
|
|
20
|
+
peelability: 90,
|
|
21
|
+
inbound: [],
|
|
22
|
+
outbound: [],
|
|
23
|
+
reversible: true,
|
|
24
|
+
diagnostics: [],
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function manifest(target: string): CarveManifest {
|
|
29
|
+
return { version: 1, target, from: "/estate", boundary: report(target) };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function withDir<T>(fn: (dir: string) => T): T {
|
|
33
|
+
const dir = mkdtempSync(join(tmpdir(), "chant-manifest-"));
|
|
34
|
+
try {
|
|
35
|
+
return fn(dir);
|
|
36
|
+
} finally {
|
|
37
|
+
rmSync(dir, { recursive: true, force: true });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe("carve manifest", () => {
|
|
42
|
+
test("round-trips through the output dir, named by target slug", () => {
|
|
43
|
+
withDir((dir) => {
|
|
44
|
+
const path = writeCarveManifest(dir, manifest("aws_s3_bucket.assets"));
|
|
45
|
+
expect(path).toBe(join(dir, "aws_s3_bucket-assets.carve.json"));
|
|
46
|
+
expect(path).toBe(carveManifestPath(dir, "aws_s3_bucket.assets"));
|
|
47
|
+
const read = readCarveManifest(path)!;
|
|
48
|
+
expect(read.target).toBe("aws_s3_bucket.assets");
|
|
49
|
+
expect(read.boundary.peelability).toBe(90);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("rejects missing, malformed, and wrong-version files", () => {
|
|
54
|
+
withDir((dir) => {
|
|
55
|
+
expect(readCarveManifest(join(dir, "nope.carve.json"))).toBeNull();
|
|
56
|
+
writeFileSync(join(dir, "bad.carve.json"), "not json");
|
|
57
|
+
expect(readCarveManifest(join(dir, "bad.carve.json"))).toBeNull();
|
|
58
|
+
writeFileSync(join(dir, "v9.carve.json"), JSON.stringify({ version: 9, target: "x" }));
|
|
59
|
+
expect(readCarveManifest(join(dir, "v9.carve.json"))).toBeNull();
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("resolve: no select + exactly one manifest composes; none or several is a clear error", () => {
|
|
64
|
+
withDir((dir) => {
|
|
65
|
+
expect(resolveCarveManifest(dir).error).toContain("--select");
|
|
66
|
+
|
|
67
|
+
writeCarveManifest(dir, manifest("aws_s3_bucket.assets"));
|
|
68
|
+
const one = resolveCarveManifest(dir);
|
|
69
|
+
expect(one.manifest!.target).toBe("aws_s3_bucket.assets");
|
|
70
|
+
expect(one.error).toBeUndefined();
|
|
71
|
+
|
|
72
|
+
writeCarveManifest(dir, manifest("aws_sqs_queue.jobs"));
|
|
73
|
+
const many = resolveCarveManifest(dir);
|
|
74
|
+
expect(many.error).toContain("aws_s3_bucket.assets");
|
|
75
|
+
expect(many.error).toContain("aws_sqs_queue.jobs");
|
|
76
|
+
expect(listCarveManifests(dir)).toHaveLength(2);
|
|
77
|
+
|
|
78
|
+
// An explicit select still resolves its own manifest among several.
|
|
79
|
+
const picked = resolveCarveManifest(dir, "aws_sqs_queue.jobs");
|
|
80
|
+
expect(picked.manifest!.target).toBe("aws_sqs_queue.jobs");
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("resolve with a select whose manifest is absent runs standalone (no error)", () => {
|
|
85
|
+
withDir((dir) => {
|
|
86
|
+
const res = resolveCarveManifest(dir, "aws_s3_bucket.assets");
|
|
87
|
+
expect(res.manifest).toBeUndefined();
|
|
88
|
+
expect(res.error).toBeUndefined();
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("update patches an existing manifest and no-ops on a missing one", () => {
|
|
93
|
+
withDir((dir) => {
|
|
94
|
+
expect(updateCarveManifest(dir, "aws_s3_bucket.assets", { bridge: { written: [], appliedInPlace: false, at: "t" } })).toBeUndefined();
|
|
95
|
+
|
|
96
|
+
writeCarveManifest(dir, manifest("aws_s3_bucket.assets"));
|
|
97
|
+
const path = updateCarveManifest(dir, "aws_s3_bucket.assets", {
|
|
98
|
+
apply: { marker: { stack: "assets", env: "prod" }, ownershipTags: { "chant:managed-by": "chant" }, at: "t" },
|
|
99
|
+
})!;
|
|
100
|
+
const read = readCarveManifest(path)!;
|
|
101
|
+
expect(read.apply!.marker).toEqual({ stack: "assets", env: "prod" });
|
|
102
|
+
expect(read.boundary.target).toBe("aws_s3_bucket.assets"); // untouched sections survive
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
});
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The carve state manifest (#998) — what makes emit → bridge → apply compose.
|
|
3
|
+
*
|
|
4
|
+
* `carve emit` persists the boundary report and adoption selector next to the
|
|
5
|
+
* emitted source as `<target>.carve.json`. The later steps read it back from
|
|
6
|
+
* their output dir, so `carve bridge` and `carve apply` no longer need the
|
|
7
|
+
* target re-specified by hand, and each step records what it did for the next.
|
|
8
|
+
* Plain JSON on disk — reviewable, diffable, nothing proprietary.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "fs";
|
|
12
|
+
import { join } from "path";
|
|
13
|
+
import type { CarveReport } from "./carve";
|
|
14
|
+
import type { OwnershipMarker } from "../ownership";
|
|
15
|
+
|
|
16
|
+
export const CARVE_MANIFEST_SUFFIX = ".carve.json";
|
|
17
|
+
|
|
18
|
+
export interface CarveManifest {
|
|
19
|
+
version: 1;
|
|
20
|
+
/** Terraform address of the carved resource, e.g. `aws_s3_bucket.assets`. */
|
|
21
|
+
target: string;
|
|
22
|
+
/** Terraform resource type, when known. */
|
|
23
|
+
tfType?: string;
|
|
24
|
+
/** Absolute path of the Terraform estate the carve came from. */
|
|
25
|
+
from: string;
|
|
26
|
+
/** Absolute path of the tfstate used for adoption (offline path). */
|
|
27
|
+
statePath?: string;
|
|
28
|
+
/** Boundary classification persisted when the manifest was written. */
|
|
29
|
+
boundary: CarveReport;
|
|
30
|
+
/** Recorded by `carve emit`. */
|
|
31
|
+
emit?: {
|
|
32
|
+
source: "tfstate" | "live";
|
|
33
|
+
/** Emitted chant source file path(s). */
|
|
34
|
+
files: string[];
|
|
35
|
+
/**
|
|
36
|
+
* Deferred outbound inputs declared as build parameters in the emitted
|
|
37
|
+
* project (#998), keyed by parameter name.
|
|
38
|
+
*/
|
|
39
|
+
params?: Record<
|
|
40
|
+
string,
|
|
41
|
+
{
|
|
42
|
+
/** The carved resource's own Terraform attribute the value enters through. */
|
|
43
|
+
tfAttr: string;
|
|
44
|
+
/** The survivor the Terraform source read, e.g. `aws_vpc.main`. */
|
|
45
|
+
survivor: string;
|
|
46
|
+
/** Survivor attribute(s) read. */
|
|
47
|
+
attrs: string[];
|
|
48
|
+
/** State-resolved default, when scalar. */
|
|
49
|
+
default?: string | number | boolean;
|
|
50
|
+
}
|
|
51
|
+
>;
|
|
52
|
+
at: string;
|
|
53
|
+
};
|
|
54
|
+
/** Recorded by `carve bridge`. */
|
|
55
|
+
bridge?: {
|
|
56
|
+
written: string[];
|
|
57
|
+
appliedInPlace: boolean;
|
|
58
|
+
/** The git-applyable `.patch` carrying the whole survivor edit. */
|
|
59
|
+
patch?: string;
|
|
60
|
+
/** Carved addresses whose own `.tf` block the rewrites remove (#998). */
|
|
61
|
+
excised?: string[];
|
|
62
|
+
at: string;
|
|
63
|
+
};
|
|
64
|
+
/** Recorded by `carve apply`. */
|
|
65
|
+
apply?: {
|
|
66
|
+
marker: OwnershipMarker;
|
|
67
|
+
ownershipTags: Record<string, string>;
|
|
68
|
+
/** Emitted files the marker was stamped into (`--write-source`). */
|
|
69
|
+
stampedFiles?: string[];
|
|
70
|
+
at: string;
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Filesystem-safe slug for a Terraform address (shared file-naming convention). */
|
|
75
|
+
export function carveSlug(target: string): string {
|
|
76
|
+
return target.replace(/[^A-Za-z0-9_]+/g, "-");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Where the manifest for `target` lives inside an output dir. */
|
|
80
|
+
export function carveManifestPath(outDir: string, target: string): string {
|
|
81
|
+
return join(outDir, `${carveSlug(target)}${CARVE_MANIFEST_SUFFIX}`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Write (or overwrite) a manifest into the output dir. Returns the path. */
|
|
85
|
+
export function writeCarveManifest(outDir: string, manifest: CarveManifest): string {
|
|
86
|
+
mkdirSync(outDir, { recursive: true });
|
|
87
|
+
const path = carveManifestPath(outDir, manifest.target);
|
|
88
|
+
writeFileSync(path, JSON.stringify(manifest, null, 2) + "\n");
|
|
89
|
+
return path;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Read a manifest, or null when absent/unreadable/not a v1 manifest. */
|
|
93
|
+
export function readCarveManifest(path: string): CarveManifest | null {
|
|
94
|
+
if (!existsSync(path)) return null;
|
|
95
|
+
try {
|
|
96
|
+
const parsed = JSON.parse(readFileSync(path, "utf-8")) as CarveManifest;
|
|
97
|
+
if (parsed?.version !== 1) return null;
|
|
98
|
+
if (typeof parsed.target !== "string" || typeof parsed.boundary !== "object") return null;
|
|
99
|
+
return parsed;
|
|
100
|
+
} catch {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** All carve manifest paths in an output dir. */
|
|
106
|
+
export function listCarveManifests(outDir: string): string[] {
|
|
107
|
+
if (!existsSync(outDir)) return [];
|
|
108
|
+
return readdirSync(outDir)
|
|
109
|
+
.filter((f) => f.endsWith(CARVE_MANIFEST_SUFFIX))
|
|
110
|
+
.map((f) => join(outDir, f))
|
|
111
|
+
.sort();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface ResolvedManifest {
|
|
115
|
+
manifest?: CarveManifest;
|
|
116
|
+
path?: string;
|
|
117
|
+
error?: string;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Resolve the manifest a later carve step composes with. With a selected
|
|
122
|
+
* target, that target's manifest (absent is fine — the step just runs
|
|
123
|
+
* standalone). Without one, the single manifest in the output dir; none or
|
|
124
|
+
* several is an error naming what is (or is not) there.
|
|
125
|
+
*/
|
|
126
|
+
export function resolveCarveManifest(outDir: string, select?: string): ResolvedManifest {
|
|
127
|
+
if (select) {
|
|
128
|
+
const path = carveManifestPath(outDir, select);
|
|
129
|
+
const manifest = readCarveManifest(path);
|
|
130
|
+
return manifest ? { manifest, path } : {};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const paths = listCarveManifests(outDir);
|
|
134
|
+
if (paths.length === 0) {
|
|
135
|
+
return { error: `No carve manifest in ${outDir} — run \`chant carve emit\` first, or pass --select <tf-address>.` };
|
|
136
|
+
}
|
|
137
|
+
const manifests = paths
|
|
138
|
+
.map((path) => ({ path, manifest: readCarveManifest(path) }))
|
|
139
|
+
.filter((m): m is { path: string; manifest: CarveManifest } => m.manifest !== null);
|
|
140
|
+
if (manifests.length === 0) {
|
|
141
|
+
return { error: `No readable carve manifest in ${outDir} — pass --select <tf-address>.` };
|
|
142
|
+
}
|
|
143
|
+
if (manifests.length > 1) {
|
|
144
|
+
const targets = manifests.map((m) => m.manifest.target).join(", ");
|
|
145
|
+
return { error: `Several carves in ${outDir} (${targets}) — pass --select <tf-address> to pick one.` };
|
|
146
|
+
}
|
|
147
|
+
return { manifest: manifests[0].manifest, path: manifests[0].path };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Read-modify-write a section of an existing manifest. No-op when absent. */
|
|
151
|
+
export function updateCarveManifest(
|
|
152
|
+
outDir: string,
|
|
153
|
+
target: string,
|
|
154
|
+
patch: Partial<Pick<CarveManifest, "boundary" | "emit" | "bridge" | "apply">>,
|
|
155
|
+
): string | undefined {
|
|
156
|
+
const path = carveManifestPath(outDir, target);
|
|
157
|
+
const manifest = readCarveManifest(path);
|
|
158
|
+
if (!manifest) return undefined;
|
|
159
|
+
Object.assign(manifest, patch);
|
|
160
|
+
writeFileSync(path, JSON.stringify(manifest, null, 2) + "\n");
|
|
161
|
+
return path;
|
|
162
|
+
}
|
|
@@ -47,7 +47,45 @@ describe.runIf(true)("parseTerraformDir (real wasm)", () => {
|
|
|
47
47
|
"aws_s3_bucket.assets",
|
|
48
48
|
]);
|
|
49
49
|
expect(g.edges).toEqual([
|
|
50
|
-
{ from: "aws_lambda_function.api", to: "aws_s3_bucket.assets", attrs: ["arn", "bucket"] },
|
|
50
|
+
{ from: "aws_lambda_function.api", to: "aws_s3_bucket.assets", attrs: ["arn", "bucket"], via: ["environment"] },
|
|
51
|
+
]);
|
|
52
|
+
} finally {
|
|
53
|
+
rmSync(dir, { recursive: true, force: true });
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("edge detection is AST-driven: string literals and escapes are not references", async () => {
|
|
58
|
+
if (!parserAvailable) return; // optional dep absent — skip
|
|
59
|
+
const dir = mkdtempSync(join(tmpdir(), "chant-tf-ast-"));
|
|
60
|
+
try {
|
|
61
|
+
writeFileSync(
|
|
62
|
+
join(dir, "main.tf"),
|
|
63
|
+
[
|
|
64
|
+
`resource "aws_s3_bucket" "assets" {`,
|
|
65
|
+
` bucket = "myapp-assets-prod"`,
|
|
66
|
+
`}`,
|
|
67
|
+
`resource "aws_cloudwatch_log_group" "api" {`,
|
|
68
|
+
` name = "/myapp/api"`,
|
|
69
|
+
`}`,
|
|
70
|
+
`resource "aws_lambda_function" "api" {`,
|
|
71
|
+
` environment {`,
|
|
72
|
+
` variables = {`,
|
|
73
|
+
` # a quoted address is a map key, not a reference`,
|
|
74
|
+
` LOG_GROUP = var.settings["aws_cloudwatch_log_group.api.name"]`,
|
|
75
|
+
` # an escaped interpolation is a literal, not a reference`,
|
|
76
|
+
` ESCAPED = "$\${aws_s3_bucket.assets.id}"`,
|
|
77
|
+
` # references survive function calls and conditionals`,
|
|
78
|
+
` URL = format("s3://%s", aws_s3_bucket.assets.bucket)`,
|
|
79
|
+
` ARN = var.on ? aws_s3_bucket.assets.arn : ""`,
|
|
80
|
+
` }`,
|
|
81
|
+
` }`,
|
|
82
|
+
`}`,
|
|
83
|
+
].join("\n") + "\n",
|
|
84
|
+
);
|
|
85
|
+
const g = await parseTerraformDir(dir);
|
|
86
|
+
// No phantom edge to the log group; real refs found through format()/?: .
|
|
87
|
+
expect(g.edges).toEqual([
|
|
88
|
+
{ from: "aws_lambda_function.api", to: "aws_s3_bucket.assets", attrs: ["arn", "bucket"], via: ["environment"] },
|
|
51
89
|
]);
|
|
52
90
|
} finally {
|
|
53
91
|
rmSync(dir, { recursive: true, force: true });
|