@intentius/chant 0.41.20 → 0.44.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/build.d.ts +15 -1
- package/dist/build.d.ts.map +1 -1
- package/dist/cli/commands/build.d.ts.map +1 -1
- 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/plugins.d.ts +8 -0
- package/dist/cli/plugins.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/deploy-units.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 +47 -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/aws-resources.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/tier-map.d.ts +4 -3
- package/dist/terraform/tier-map.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/audit/rules-doc.ts +1 -1
- package/src/build.test.ts +95 -0
- package/src/build.ts +46 -1
- package/src/cli/commands/build.ts +9 -1
- 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 +16 -9
- 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/plugins.ts +17 -0
- 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/deploy-units.test.ts +13 -0
- package/src/components/deploy-units.ts +5 -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 +50 -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/aws-resources.test.ts +52 -2
- package/src/terraform/aws-resources.ts +95 -4
- 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 +73 -10
- package/src/terraform/graph.ts +124 -47
- 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/tier-map.ts +60 -8
- package/src/terraform/types.ts +7 -0
- package/src/terraform/unified-diff.test.ts +77 -0
- package/src/terraform/unified-diff.ts +146 -0
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) };
|
|
55
81
|
}
|
|
56
82
|
|
|
57
|
-
/**
|
|
58
|
-
|
|
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();
|
|
102
|
+
}
|
|
103
|
+
|
|
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,16 +121,40 @@ 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>);
|
|
76
141
|
}
|
|
77
142
|
|
|
78
|
-
/**
|
|
143
|
+
/**
|
|
144
|
+
* The resource's physical name, if its identity attribute is a plain literal
|
|
145
|
+
* (not interpolated). A dotted `IDENTITY_ATTR` entry walks nested blocks —
|
|
146
|
+
* hcl2json renders a nested block as a one-element array, so arrays step
|
|
147
|
+
* through their first element (`manifest.metadata.name`).
|
|
148
|
+
*/
|
|
79
149
|
function literalIdentity(block: unknown, type: string): string | undefined {
|
|
80
150
|
const attr = IDENTITY_ATTR[type];
|
|
81
151
|
if (!attr || !block || typeof block !== "object") return undefined;
|
|
82
|
-
|
|
152
|
+
let value: unknown = block;
|
|
153
|
+
for (const segment of attr.split(".")) {
|
|
154
|
+
if (Array.isArray(value)) value = value[0];
|
|
155
|
+
if (!value || typeof value !== "object") return undefined;
|
|
156
|
+
value = (value as Record<string, unknown>)[segment];
|
|
157
|
+
}
|
|
83
158
|
if (typeof value !== "string" || value.includes("${")) return undefined;
|
|
84
159
|
return value;
|
|
85
160
|
}
|
|
@@ -93,7 +168,7 @@ function literalIdentity(block: unknown, type: string): string | undefined {
|
|
|
93
168
|
* resolves to a known resource/module node — references to `var`/`local`/data
|
|
94
169
|
* are dropped.
|
|
95
170
|
*/
|
|
96
|
-
export function buildGraph(tree: Hcl2JsonTree): TfGraph {
|
|
171
|
+
export function buildGraph(tree: Hcl2JsonTree, exprRefs: ExpressionRefs): TfGraph {
|
|
97
172
|
const nodes: TfNode[] = [];
|
|
98
173
|
const dataAddresses = new Set<string>();
|
|
99
174
|
|
|
@@ -109,7 +184,7 @@ export function buildGraph(tree: Hcl2JsonTree): TfGraph {
|
|
|
109
184
|
const address = `${type}.${name}`;
|
|
110
185
|
const block = Array.isArray(blocks) ? blocks[0] : blocks;
|
|
111
186
|
const dynamic = blockHasMeta(block, "count") || blockHasMeta(block, "for_each");
|
|
112
|
-
const refs =
|
|
187
|
+
const refs = refsInBlock(block, exprRefs);
|
|
113
188
|
const touchesData = refs.some((r) => dataAddresses.has(r.address));
|
|
114
189
|
rawRefsByNode.set(address, refs);
|
|
115
190
|
nodes.push({
|
|
@@ -129,7 +204,7 @@ export function buildGraph(tree: Hcl2JsonTree): TfGraph {
|
|
|
129
204
|
const address = `module.${name}`;
|
|
130
205
|
const block = Array.isArray(blocks) ? blocks[0] : blocks;
|
|
131
206
|
const dynamic = blockHasMeta(block, "count") || blockHasMeta(block, "for_each");
|
|
132
|
-
const refs =
|
|
207
|
+
const refs = refsInBlock(block, exprRefs);
|
|
133
208
|
const touchesData = refs.some((r) => dataAddresses.has(r.address));
|
|
134
209
|
rawRefsByNode.set(address, refs);
|
|
135
210
|
nodes.push({
|
|
@@ -145,14 +220,16 @@ export function buildGraph(tree: Hcl2JsonTree): TfGraph {
|
|
|
145
220
|
const known = new Set(nodes.map((n) => n.address));
|
|
146
221
|
const edges: TfEdge[] = [];
|
|
147
222
|
for (const [from, refs] of rawRefsByNode) {
|
|
148
|
-
const byTarget = new Map<string, Set<string
|
|
223
|
+
const byTarget = new Map<string, { attrs: Set<string>; via: Set<string> }>();
|
|
149
224
|
for (const ref of refs) {
|
|
150
225
|
if (ref.address === from || !known.has(ref.address)) continue;
|
|
151
|
-
if (!byTarget.has(ref.address)) byTarget.set(ref.address, new Set());
|
|
152
|
-
|
|
226
|
+
if (!byTarget.has(ref.address)) byTarget.set(ref.address, { attrs: new Set(), via: new Set() });
|
|
227
|
+
const target = byTarget.get(ref.address)!;
|
|
228
|
+
if (ref.attr) target.attrs.add(ref.attr);
|
|
229
|
+
if (ref.via) target.via.add(ref.via);
|
|
153
230
|
}
|
|
154
|
-
for (const [to, attrs] of byTarget) {
|
|
155
|
-
edges.push({ from, to, attrs: [...attrs].sort() });
|
|
231
|
+
for (const [to, { attrs, via }] of byTarget) {
|
|
232
|
+
edges.push({ from, to, attrs: [...attrs].sort(), via: [...via].sort() });
|
|
156
233
|
}
|
|
157
234
|
}
|
|
158
235
|
|
|
@@ -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 });
|
package/src/terraform/parse.ts
CHANGED
|
@@ -10,12 +10,16 @@
|
|
|
10
10
|
|
|
11
11
|
import { readdirSync, readFileSync } from "fs";
|
|
12
12
|
import { join } from "path";
|
|
13
|
-
import { buildGraph } from "./graph";
|
|
13
|
+
import { buildGraph, collectExpressions, type ExpressionRefs } from "./graph";
|
|
14
14
|
import { readStateInstanceCounts, applyStateCounts } from "./state";
|
|
15
15
|
import type { Hcl2JsonTree, TfGraph } from "./types";
|
|
16
16
|
|
|
17
|
-
/** Minimal shape of the parser
|
|
18
|
-
|
|
17
|
+
/** Minimal shape of the parser exports we depend on. */
|
|
18
|
+
export interface Hcl2Json {
|
|
19
|
+
parse: (filename: string, hcl: string) => Promise<Hcl2JsonTree>;
|
|
20
|
+
/** Expression-AST reference extraction (#998) — one traversal accessor per reference. */
|
|
21
|
+
getReferencesInExpression: (filename: string, expression: string) => Promise<Array<{ value: string }>>;
|
|
22
|
+
}
|
|
19
23
|
|
|
20
24
|
export class Hcl2JsonNotInstalled extends Error {
|
|
21
25
|
constructor(cause: unknown) {
|
|
@@ -32,15 +36,38 @@ export class Hcl2JsonNotInstalled extends Error {
|
|
|
32
36
|
* Lazy-load the optional HCL parser. Throws `Hcl2JsonNotInstalled` with an
|
|
33
37
|
* install hint when the package is missing, rather than a raw MODULE_NOT_FOUND.
|
|
34
38
|
*/
|
|
35
|
-
export async function loadHcl2json(): Promise<
|
|
39
|
+
export async function loadHcl2json(): Promise<Hcl2Json> {
|
|
36
40
|
try {
|
|
37
|
-
|
|
38
|
-
return mod.parse;
|
|
41
|
+
return (await import("@cdktf/hcl2json")) as Hcl2Json;
|
|
39
42
|
} catch (err) {
|
|
40
43
|
throw new Hcl2JsonNotInstalled(err);
|
|
41
44
|
}
|
|
42
45
|
}
|
|
43
46
|
|
|
47
|
+
/**
|
|
48
|
+
* Resolve every interpolated string in the merged tree through the parser's
|
|
49
|
+
* expression AST (`getReferencesInExpression`), producing the accessor map
|
|
50
|
+
* `buildGraph` classifies. The AST — not a regex over the expression body —
|
|
51
|
+
* decides what is a reference, so a quoted address used as a map key or an
|
|
52
|
+
* escaped `$${...}` literal yields none. An expression the AST cannot parse in
|
|
53
|
+
* isolation resolves to no references (fewer edges, never a phantom one).
|
|
54
|
+
*/
|
|
55
|
+
async function resolveExpressionRefs(hcl2json: Hcl2Json, tree: Hcl2JsonTree): Promise<ExpressionRefs> {
|
|
56
|
+
const refs = new Map<string, string[]>();
|
|
57
|
+
for (const expr of collectExpressions(tree)) {
|
|
58
|
+
try {
|
|
59
|
+
const found = await hcl2json.getReferencesInExpression("expression.tf", expr);
|
|
60
|
+
refs.set(
|
|
61
|
+
expr,
|
|
62
|
+
found.map((r) => r.value),
|
|
63
|
+
);
|
|
64
|
+
} catch {
|
|
65
|
+
refs.set(expr, []);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return refs;
|
|
69
|
+
}
|
|
70
|
+
|
|
44
71
|
/** Deep-merge hcl2json trees across files (resource/module/data namespaces). */
|
|
45
72
|
function mergeTrees(into: Hcl2JsonTree, next: Hcl2JsonTree): void {
|
|
46
73
|
for (const section of ["resource", "data"] as const) {
|
|
@@ -74,14 +101,14 @@ export interface ParseTerraformOptions {
|
|
|
74
101
|
* instance counts become accurate (see `state.ts`).
|
|
75
102
|
*/
|
|
76
103
|
export async function parseTerraformDir(dir: string, opts: ParseTerraformOptions = {}): Promise<TfGraph> {
|
|
77
|
-
const
|
|
104
|
+
const hcl2json = await loadHcl2json();
|
|
78
105
|
const files = listTfFiles(dir);
|
|
79
106
|
const merged: Hcl2JsonTree = {};
|
|
80
107
|
for (const file of files) {
|
|
81
|
-
const tree = await parse(file, readFileSync(file, "utf-8"));
|
|
108
|
+
const tree = await hcl2json.parse(file, readFileSync(file, "utf-8"));
|
|
82
109
|
mergeTrees(merged, tree);
|
|
83
110
|
}
|
|
84
|
-
const graph = buildGraph(merged);
|
|
111
|
+
const graph = buildGraph(merged, await resolveExpressionRefs(hcl2json, merged));
|
|
85
112
|
if (opts.statePath) applyStateCounts(graph, readStateInstanceCounts(opts.statePath));
|
|
86
113
|
return graph;
|
|
87
114
|
}
|