@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.
Files changed (144) hide show
  1. package/dist/build.d.ts +15 -1
  2. package/dist/build.d.ts.map +1 -1
  3. package/dist/cli/commands/build.d.ts.map +1 -1
  4. package/dist/cli/commands/carve-apply.d.ts +9 -0
  5. package/dist/cli/commands/carve-apply.d.ts.map +1 -1
  6. package/dist/cli/commands/carve-bridge.d.ts +7 -0
  7. package/dist/cli/commands/carve-bridge.d.ts.map +1 -1
  8. package/dist/cli/commands/carve-emit.d.ts +7 -0
  9. package/dist/cli/commands/carve-emit.d.ts.map +1 -1
  10. package/dist/cli/commands/init.d.ts +2 -0
  11. package/dist/cli/commands/init.d.ts.map +1 -1
  12. package/dist/cli/handlers/carve-apply.d.ts.map +1 -1
  13. package/dist/cli/handlers/components.d.ts.map +1 -1
  14. package/dist/cli/handlers/explain.d.ts +11 -0
  15. package/dist/cli/handlers/explain.d.ts.map +1 -0
  16. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  17. package/dist/cli/main.d.ts.map +1 -1
  18. package/dist/cli/mcp/tools/explain.d.ts.map +1 -1
  19. package/dist/cli/plugins.d.ts +8 -0
  20. package/dist/cli/plugins.d.ts.map +1 -1
  21. package/dist/cli/registry.d.ts +2 -0
  22. package/dist/cli/registry.d.ts.map +1 -1
  23. package/dist/codegen/docs-rule-scanning.d.ts +11 -0
  24. package/dist/codegen/docs-rule-scanning.d.ts.map +1 -1
  25. package/dist/codegen/okf-lexicon.d.ts +36 -0
  26. package/dist/codegen/okf-lexicon.d.ts.map +1 -0
  27. package/dist/codegen/package.d.ts +2 -1
  28. package/dist/codegen/package.d.ts.map +1 -1
  29. package/dist/components/deploy-units.d.ts.map +1 -1
  30. package/dist/components/verbs/sbom.d.ts +1 -1
  31. package/dist/components/verbs/sbom.d.ts.map +1 -1
  32. package/dist/components/verbs/vuln-gate.d.ts +15 -4
  33. package/dist/components/verbs/vuln-gate.d.ts.map +1 -1
  34. package/dist/components/verbs/vuln-scan.d.ts +18 -6
  35. package/dist/components/verbs/vuln-scan.d.ts.map +1 -1
  36. package/dist/config.d.ts +12 -0
  37. package/dist/config.d.ts.map +1 -1
  38. package/dist/deep-observation.d.ts +13 -0
  39. package/dist/deep-observation.d.ts.map +1 -1
  40. package/dist/lexicon.d.ts +47 -0
  41. package/dist/lexicon.d.ts.map +1 -1
  42. package/dist/lifecycle/observe.d.ts.map +1 -1
  43. package/dist/lifecycle/snapshot.d.ts.map +1 -1
  44. package/dist/okf.d.ts +57 -0
  45. package/dist/okf.d.ts.map +1 -0
  46. package/dist/terraform/__fixtures__/build-graph.d.ts +13 -0
  47. package/dist/terraform/__fixtures__/build-graph.d.ts.map +1 -0
  48. package/dist/terraform/adopt-state.d.ts +33 -1
  49. package/dist/terraform/adopt-state.d.ts.map +1 -1
  50. package/dist/terraform/aws-resources.d.ts.map +1 -1
  51. package/dist/terraform/bridge.d.ts +7 -1
  52. package/dist/terraform/bridge.d.ts.map +1 -1
  53. package/dist/terraform/carve.d.ts +12 -0
  54. package/dist/terraform/carve.d.ts.map +1 -1
  55. package/dist/terraform/excise.d.ts +31 -0
  56. package/dist/terraform/excise.d.ts.map +1 -0
  57. package/dist/terraform/graduate.d.ts +12 -0
  58. package/dist/terraform/graduate.d.ts.map +1 -1
  59. package/dist/terraform/graph.d.ts +35 -5
  60. package/dist/terraform/graph.d.ts.map +1 -1
  61. package/dist/terraform/manifest.d.ts +89 -0
  62. package/dist/terraform/manifest.d.ts.map +1 -0
  63. package/dist/terraform/parse.d.ts +9 -4
  64. package/dist/terraform/parse.d.ts.map +1 -1
  65. package/dist/terraform/tier-map.d.ts +4 -3
  66. package/dist/terraform/tier-map.d.ts.map +1 -1
  67. package/dist/terraform/types.d.ts +7 -0
  68. package/dist/terraform/types.d.ts.map +1 -1
  69. package/dist/terraform/unified-diff.d.ts +11 -0
  70. package/dist/terraform/unified-diff.d.ts.map +1 -0
  71. package/package.json +1 -1
  72. package/src/__snapshots__/okf.test.ts.snap +381 -0
  73. package/src/audit/rules-doc.ts +1 -1
  74. package/src/build.test.ts +95 -0
  75. package/src/build.ts +46 -1
  76. package/src/cli/commands/build.ts +9 -1
  77. package/src/cli/commands/carve-apply.test.ts +94 -2
  78. package/src/cli/commands/carve-apply.ts +62 -11
  79. package/src/cli/commands/carve-bridge.test.ts +82 -4
  80. package/src/cli/commands/carve-bridge.ts +79 -11
  81. package/src/cli/commands/carve-emit-state.test.ts +110 -1
  82. package/src/cli/commands/carve-emit.ts +184 -8
  83. package/src/cli/commands/init.ts +1 -1
  84. package/src/cli/handlers/carve-apply.ts +1 -0
  85. package/src/cli/handlers/components.ts +4 -2
  86. package/src/cli/handlers/explain.test.ts +93 -0
  87. package/src/cli/handlers/explain.ts +60 -0
  88. package/src/cli/handlers/lifecycle.ts +16 -9
  89. package/src/cli/main.ts +21 -5
  90. package/src/cli/mcp/server.test.ts +17 -0
  91. package/src/cli/mcp/tools/explain.ts +15 -3
  92. package/src/cli/plugins.ts +17 -0
  93. package/src/cli/registry.ts +2 -0
  94. package/src/codegen/__snapshots__/okf-lexicon.test.ts.snap +170 -0
  95. package/src/codegen/docs-rule-scanning.ts +35 -14
  96. package/src/codegen/okf-lexicon.test.ts +248 -0
  97. package/src/codegen/okf-lexicon.ts +303 -0
  98. package/src/codegen/package.ts +29 -4
  99. package/src/components/config-defaults.test.ts +30 -0
  100. package/src/components/deploy-units.test.ts +13 -0
  101. package/src/components/deploy-units.ts +5 -0
  102. package/src/components/verbs/__fixtures__/grype-with-kev-epss.json +1 -0
  103. package/src/components/verbs/__fixtures__/trivy-with-kev-epss.json +631 -0
  104. package/src/components/verbs/exploitability-roundtrip.test.ts +190 -0
  105. package/src/components/verbs/sbom.ts +1 -1
  106. package/src/components/verbs/vuln-gate.test.ts +144 -1
  107. package/src/components/verbs/vuln-gate.ts +61 -10
  108. package/src/components/verbs/vuln-scan.test.ts +94 -0
  109. package/src/components/verbs/vuln-scan.ts +44 -9
  110. package/src/config.test.ts +21 -0
  111. package/src/config.ts +18 -0
  112. package/src/deep-observation.test.ts +19 -0
  113. package/src/deep-observation.ts +32 -13
  114. package/src/lexicon.ts +50 -0
  115. package/src/lifecycle/observe.test.ts +18 -0
  116. package/src/lifecycle/observe.ts +10 -3
  117. package/src/lifecycle/snapshot.ts +5 -4
  118. package/src/okf.test.ts +169 -0
  119. package/src/okf.ts +308 -0
  120. package/src/terraform/__fixtures__/build-graph.ts +25 -0
  121. package/src/terraform/adopt-state.test.ts +34 -1
  122. package/src/terraform/adopt-state.ts +69 -2
  123. package/src/terraform/aws-resources.test.ts +52 -2
  124. package/src/terraform/aws-resources.ts +95 -4
  125. package/src/terraform/bridge.test.ts +38 -14
  126. package/src/terraform/bridge.ts +48 -14
  127. package/src/terraform/carve.test.ts +9 -7
  128. package/src/terraform/carve.ts +0 -0
  129. package/src/terraform/excise.test.ts +92 -0
  130. package/src/terraform/excise.ts +139 -0
  131. package/src/terraform/graduate.test.ts +73 -6
  132. package/src/terraform/graduate.ts +49 -0
  133. package/src/terraform/graph.test.ts +73 -10
  134. package/src/terraform/graph.ts +124 -47
  135. package/src/terraform/manifest.test.ts +105 -0
  136. package/src/terraform/manifest.ts +162 -0
  137. package/src/terraform/parse.test.ts +39 -1
  138. package/src/terraform/parse.ts +36 -9
  139. package/src/terraform/score.test.ts +8 -8
  140. package/src/terraform/state.test.ts +4 -4
  141. package/src/terraform/tier-map.ts +60 -8
  142. package/src/terraform/types.ts +7 -0
  143. package/src/terraform/unified-diff.test.ts +77 -0
  144. package/src/terraform/unified-diff.ts +146 -0
@@ -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`). No wasm,
5
- * no filesystem so the graph and its edge classification are unit-testable
6
- * on hand-written fixtures. `parse.ts` is the thin glue that loads the wasm
7
- * parser and feeds this.
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
- * hcl2json leaves interpolations as `${...}` strings. Match each expression
15
- * body; reference heads are extracted from it in `refsFromExpression`.
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
- const INTERPOLATION = /\$\{([^}]+)\}/g;
23
+ export type ExpressionRefs = ReadonlyMap<string, readonly string[]>;
18
24
 
19
- /** `module.<name>` reference head. */
20
- const MODULE_REF = /\bmodule\.([A-Za-z0-9_-]+)(?:\.([A-Za-z0-9_]+))?/g;
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
- /** Pull every resource/module/data reference out of one interpolation body. */
41
- function refsFromExpression(expr: string): RawRef[] {
42
- const refs: RawRef[] = [];
43
-
44
- for (const m of expr.matchAll(MODULE_REF)) {
45
- refs.push({ address: `module.${m[1]}`, attr: m[2] });
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
- for (const m of expr.matchAll(DATA_REF)) {
48
- refs.push({ address: `data.${m[1]}.${m[2]}`, attr: m[3] });
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
- for (const m of expr.matchAll(RESOURCE_REF)) {
51
- if (NON_RESOURCE_HEADS.has(m[1])) continue;
52
- refs.push({ address: `${m[1]}.${m[2]}`, attr: m[3] });
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 refs;
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
- /** Collect every `${...}` reference reachable in a block's value tree. */
58
- function refsInValue(value: unknown): RawRef[] {
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
- for (const m of v.matchAll(INTERPOLATION)) refs.push(...refsFromExpression(m[1]));
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
- /** The resource's physical name, if its identity attribute is a plain literal (not interpolated). */
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
- const value = (block as Record<string, unknown>)[attr];
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 = refsInValue(block);
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 = refsInValue(block);
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
- if (ref.attr) byTarget.get(ref.address)!.add(ref.attr);
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 });
@@ -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 export we depend on. */
18
- type Hcl2JsonParse = (filename: string, hcl: string) => Promise<Hcl2JsonTree>;
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<Hcl2JsonParse> {
39
+ export async function loadHcl2json(): Promise<Hcl2Json> {
36
40
  try {
37
- const mod = (await import("@cdktf/hcl2json")) as { parse: Hcl2JsonParse };
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 parse = await loadHcl2json();
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
  }