@intentius/chant 0.18.37 → 0.19.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 (80) hide show
  1. package/dist/cli/commands/carve-apply.d.ts +30 -0
  2. package/dist/cli/commands/carve-apply.d.ts.map +1 -0
  3. package/dist/cli/commands/carve-bridge.d.ts +32 -0
  4. package/dist/cli/commands/carve-bridge.d.ts.map +1 -0
  5. package/dist/cli/commands/carve-emit.d.ts +58 -0
  6. package/dist/cli/commands/carve-emit.d.ts.map +1 -0
  7. package/dist/cli/commands/carve.d.ts +29 -0
  8. package/dist/cli/commands/carve.d.ts.map +1 -0
  9. package/dist/cli/handlers/carve-apply.d.ts +9 -0
  10. package/dist/cli/handlers/carve-apply.d.ts.map +1 -0
  11. package/dist/cli/handlers/carve-bridge.d.ts +10 -0
  12. package/dist/cli/handlers/carve-bridge.d.ts.map +1 -0
  13. package/dist/cli/handlers/carve-emit.d.ts +11 -0
  14. package/dist/cli/handlers/carve-emit.d.ts.map +1 -0
  15. package/dist/cli/handlers/carve.d.ts +12 -0
  16. package/dist/cli/handlers/carve.d.ts.map +1 -0
  17. package/dist/cli/main.d.ts.map +1 -1
  18. package/dist/cli/registry.d.ts +12 -0
  19. package/dist/cli/registry.d.ts.map +1 -1
  20. package/dist/terraform/adopt-state.d.ts +32 -0
  21. package/dist/terraform/adopt-state.d.ts.map +1 -0
  22. package/dist/terraform/aws-resources.d.ts +49 -0
  23. package/dist/terraform/aws-resources.d.ts.map +1 -0
  24. package/dist/terraform/bridge.d.ts +65 -0
  25. package/dist/terraform/bridge.d.ts.map +1 -0
  26. package/dist/terraform/carve.d.ts +66 -0
  27. package/dist/terraform/carve.d.ts.map +1 -0
  28. package/dist/terraform/graduate.d.ts +43 -0
  29. package/dist/terraform/graduate.d.ts.map +1 -0
  30. package/dist/terraform/graph.d.ts +24 -0
  31. package/dist/terraform/graph.d.ts.map +1 -0
  32. package/dist/terraform/parse.d.ts +33 -0
  33. package/dist/terraform/parse.d.ts.map +1 -0
  34. package/dist/terraform/score.d.ts +50 -0
  35. package/dist/terraform/score.d.ts.map +1 -0
  36. package/dist/terraform/state.d.ts +42 -0
  37. package/dist/terraform/state.d.ts.map +1 -0
  38. package/dist/terraform/tier-map.d.ts +38 -0
  39. package/dist/terraform/tier-map.d.ts.map +1 -0
  40. package/dist/terraform/types.d.ts +66 -0
  41. package/dist/terraform/types.d.ts.map +1 -0
  42. package/package.json +2 -1
  43. package/src/cli/commands/carve-apply.test.ts +82 -0
  44. package/src/cli/commands/carve-apply.ts +106 -0
  45. package/src/cli/commands/carve-bridge.test.ts +97 -0
  46. package/src/cli/commands/carve-bridge.ts +134 -0
  47. package/src/cli/commands/carve-emit-state.test.ts +114 -0
  48. package/src/cli/commands/carve-emit.test.ts +158 -0
  49. package/src/cli/commands/carve-emit.ts +181 -0
  50. package/src/cli/commands/carve.test.ts +104 -0
  51. package/src/cli/commands/carve.ts +123 -0
  52. package/src/cli/handlers/carve-apply.ts +34 -0
  53. package/src/cli/handlers/carve-bridge.ts +33 -0
  54. package/src/cli/handlers/carve-emit.ts +51 -0
  55. package/src/cli/handlers/carve.ts +44 -0
  56. package/src/cli/main.ts +48 -0
  57. package/src/cli/registry.ts +12 -0
  58. package/src/terraform/__fixtures__/advise.test.ts +51 -0
  59. package/src/terraform/__fixtures__/carve-arc.test.ts +77 -0
  60. package/src/terraform/__fixtures__/sample-estate/main.tf +58 -0
  61. package/src/terraform/adopt-state.test.ts +64 -0
  62. package/src/terraform/adopt-state.ts +79 -0
  63. package/src/terraform/aws-resources.test.ts +81 -0
  64. package/src/terraform/aws-resources.ts +214 -0
  65. package/src/terraform/bridge.test.ts +118 -0
  66. package/src/terraform/bridge.ts +164 -0
  67. package/src/terraform/carve.test.ts +97 -0
  68. package/src/terraform/carve.ts +0 -0
  69. package/src/terraform/graduate.test.ts +49 -0
  70. package/src/terraform/graduate.ts +78 -0
  71. package/src/terraform/graph.test.ts +137 -0
  72. package/src/terraform/graph.ts +176 -0
  73. package/src/terraform/parse.test.ts +56 -0
  74. package/src/terraform/parse.ts +87 -0
  75. package/src/terraform/score.test.ts +140 -0
  76. package/src/terraform/score.ts +148 -0
  77. package/src/terraform/state.test.ts +76 -0
  78. package/src/terraform/state.ts +87 -0
  79. package/src/terraform/tier-map.ts +60 -0
  80. package/src/terraform/types.ts +69 -0
@@ -0,0 +1,97 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { buildGraph } from "./graph";
3
+ import { resolveCarveSet, boundaryReport } from "./carve";
4
+ import type { Hcl2JsonTree } from "./types";
5
+
6
+ // #197 worked example: a bucket a Lambda reads, with a versioning sub-resource.
7
+ const workedExample: Hcl2JsonTree = {
8
+ resource: {
9
+ aws_s3_bucket: { assets: [{ bucket: "myapp-assets-prod" }] },
10
+ aws_s3_bucket_versioning: {
11
+ assets: [{ bucket: "${aws_s3_bucket.assets.id}", versioning_configuration: { status: "Enabled" } }],
12
+ },
13
+ aws_lambda_function: {
14
+ api: [
15
+ {
16
+ environment: {
17
+ variables: {
18
+ ASSETS_BUCKET: "${aws_s3_bucket.assets.bucket}",
19
+ ASSETS_ARN: "${aws_s3_bucket.assets.arn}",
20
+ },
21
+ },
22
+ },
23
+ ],
24
+ },
25
+ },
26
+ };
27
+
28
+ describe("resolveCarveSet", () => {
29
+ test("selected node plus its folded sub-resources", () => {
30
+ const set = resolveCarveSet(buildGraph(workedExample), "aws_s3_bucket.assets");
31
+ expect(set.map((m) => m.address).sort()).toEqual([
32
+ "aws_s3_bucket.assets",
33
+ "aws_s3_bucket_versioning.assets",
34
+ ]);
35
+ const folded = set.find((m) => m.address === "aws_s3_bucket_versioning.assets");
36
+ expect(folded?.foldedInto).toBe("aws_s3_bucket.assets");
37
+ });
38
+
39
+ test("empty for an unknown address", () => {
40
+ expect(resolveCarveSet(buildGraph(workedExample), "aws_s3_bucket.nope")).toEqual([]);
41
+ });
42
+ });
43
+
44
+ describe("boundaryReport", () => {
45
+ test("classifies the Lambda inbound edge; folds the versioning internal edge away", () => {
46
+ const report = boundaryReport(buildGraph(workedExample), "aws_s3_bucket.assets")!;
47
+ expect(report.target).toBe("aws_s3_bucket.assets");
48
+ expect(report.peelability).toBe(88);
49
+ expect(report.reversible).toBe(true);
50
+
51
+ // The only boundary edge is the Lambda → bucket inbound; the versioning →
52
+ // bucket edge is internal to the carve set and dropped.
53
+ expect(report.inbound).toEqual([
54
+ {
55
+ direction: "inbound",
56
+ survivor: "aws_lambda_function.api",
57
+ carved: "aws_s3_bucket.assets",
58
+ attrs: ["arn", "bucket"],
59
+ bridge: "tf-data-source",
60
+ required: "immediately",
61
+ },
62
+ ]);
63
+ expect(report.outbound).toEqual([]);
64
+ });
65
+
66
+ test("outbound edges become deferred inputs", () => {
67
+ const tree: Hcl2JsonTree = {
68
+ resource: {
69
+ aws_sns_topic: { alerts: [{ name: "a" }] },
70
+ aws_sqs_queue: { dlq: [{ name: "d", redrive: "${aws_sns_topic.alerts.arn}" }] },
71
+ },
72
+ };
73
+ // Carve the queue: it depends on the topic (a survivor) → outbound/deferred.
74
+ const report = boundaryReport(buildGraph(tree), "aws_sqs_queue.dlq")!;
75
+ expect(report.inbound).toEqual([]);
76
+ expect(report.outbound).toEqual([
77
+ {
78
+ direction: "outbound",
79
+ survivor: "aws_sns_topic.alerts",
80
+ carved: "aws_sqs_queue.dlq",
81
+ attrs: ["arn"],
82
+ bridge: "deferred-input",
83
+ required: "at-apply",
84
+ },
85
+ ]);
86
+ });
87
+
88
+ test("unsupported type is flagged in diagnostics", () => {
89
+ const tree: Hcl2JsonTree = { resource: { random_pet: { n: [{ length: 2 }] } } };
90
+ const report = boundaryReport(buildGraph(tree), "random_pet.n")!;
91
+ expect(report.diagnostics.join(" ")).toMatch(/no known native mapping/i);
92
+ });
93
+
94
+ test("null for an unknown target", () => {
95
+ expect(boundaryReport(buildGraph(workedExample), "aws_s3_bucket.nope")).toBeNull();
96
+ });
97
+ });
Binary file
@@ -0,0 +1,49 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { buildGraph } from "./graph";
3
+ import { boundaryReport } from "./carve";
4
+ import { graduationPlan, DEFAULT_TAG_OWNERSHIP_KEYS } from "./graduate";
5
+ import type { Hcl2JsonTree } from "./types";
6
+
7
+ const bucketTree: Hcl2JsonTree = {
8
+ resource: {
9
+ aws_s3_bucket: { assets: [{ bucket: "myapp-assets-prod" }] },
10
+ aws_lambda_function: { api: [{ x: "${aws_s3_bucket.assets.arn}" }] },
11
+ },
12
+ };
13
+
14
+ describe("graduationPlan", () => {
15
+ test("resolves the ownership marker + tags (chant-owned)", () => {
16
+ const report = boundaryReport(buildGraph(bucketTree), "aws_s3_bucket.assets")!;
17
+ const plan = graduationPlan(report, { stack: "assets", env: "prod" });
18
+
19
+ expect(plan.marker).toEqual({ stack: "assets", env: "prod" });
20
+ expect(plan.ownershipTags).toEqual({
21
+ [DEFAULT_TAG_OWNERSHIP_KEYS.managedBy]: "chant",
22
+ [DEFAULT_TAG_OWNERSHIP_KEYS.stack]: "assets",
23
+ [DEFAULT_TAG_OWNERSHIP_KEYS.env]: "prod",
24
+ });
25
+ });
26
+
27
+ test("stack defaults to the resource's local name", () => {
28
+ const report = boundaryReport(buildGraph(bucketTree), "aws_s3_bucket.assets")!;
29
+ const plan = graduationPlan(report);
30
+ expect(plan.marker.stack).toBe("assets");
31
+ expect(plan.ownershipTags).not.toHaveProperty(DEFAULT_TAG_OWNERSHIP_KEYS.env); // no env → omitted
32
+ });
33
+
34
+ test("runbook is reversible and BYOL (import rollback, no chant-runs-apply)", () => {
35
+ const report = boundaryReport(buildGraph(bucketTree), "aws_s3_bucket.assets")!;
36
+ const plan = graduationPlan(report, { env: "prod" });
37
+ const runbook = plan.steps.join("\n");
38
+ expect(runbook).toMatch(/terraform import aws_s3_bucket\.assets/);
39
+ expect(runbook).toMatch(/cloudformation deploy|ApplyOp/);
40
+ expect(runbook).toMatch(/lifecycle diff --live/);
41
+ });
42
+
43
+ test("warns when outbound edges leave deferred inputs to wire", () => {
44
+ // Carve the Lambda: it depends on the bucket (survivor) → outbound/deferred.
45
+ const report = boundaryReport(buildGraph(bucketTree), "aws_lambda_function.api")!;
46
+ const plan = graduationPlan(report, { env: "prod" });
47
+ expect(plan.warnings.join(" ")).toMatch(/deferred deploy-time input/i);
48
+ });
49
+ });
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Apply graduation for the strangler-fig carve (#197) — the last step.
3
+ *
4
+ * After a resource is emitted (`carve emit`) and the survivors are bridged
5
+ * (`carve bridge`), it sits at the observe position: chant has the source, but
6
+ * the live resource is an orphan carrying no chant ownership. Graduation is the
7
+ * dial-turn to apply: mark the resource chant-owned so `chant carve` / status
8
+ * recognize it, and finalize the ordered apply runbook.
9
+ *
10
+ * This is BYOL-honest: it does NOT call the cloud. It resolves the ownership
11
+ * marker (reusing core's `ownershipEntries`) and produces the graduation
12
+ * artifacts + runbook. The apply itself is whatever lifecycle you brought —
13
+ * the native CLI, a CI pipeline, an ApplyOp — and it stamps the marker the
14
+ * emitted source now carries.
15
+ */
16
+
17
+ import { ownershipEntries, type ChannelKeys, type OwnershipMarker } from "../ownership";
18
+ import type { CarveReport } from "./carve";
19
+
20
+ /**
21
+ * Default tag channel, mirroring the AWS lexicon's `AWS_TAG_OWNERSHIP_KEYS`
22
+ * (`lexicons/aws/src/ownership.ts`). Core cannot import a lexicon, so the
23
+ * convention is duplicated here; the values match so the graduation plan shows
24
+ * the tags the AWS apply path will actually stamp.
25
+ */
26
+ export const DEFAULT_TAG_OWNERSHIP_KEYS: ChannelKeys = {
27
+ managedBy: "chant:managed-by",
28
+ stack: "chant:stack",
29
+ env: "chant:env",
30
+ };
31
+
32
+ export interface GraduationPlan {
33
+ target: string;
34
+ marker: OwnershipMarker;
35
+ /** The ownership tags the emitted resource must carry to be chant-owned. */
36
+ ownershipTags: Record<string, string>;
37
+ /** The finalized, ordered apply runbook. */
38
+ steps: string[];
39
+ warnings: string[];
40
+ }
41
+
42
+ export interface GraduationOptions {
43
+ /** Chant stack name for the ownership marker (defaults to the resource name). */
44
+ stack?: string;
45
+ /** Environment identity for the marker. */
46
+ env?: string;
47
+ /** Override the tag channel (e.g. for a non-AWS lexicon). */
48
+ channel?: ChannelKeys;
49
+ }
50
+
51
+ export function graduationPlan(report: CarveReport, opts: GraduationOptions = {}): GraduationPlan {
52
+ const stack = opts.stack ?? (report.target.split(".").slice(1).join(".") || report.target);
53
+ const marker: OwnershipMarker = { stack, env: opts.env };
54
+ const ownershipTags = ownershipEntries(opts.channel ?? DEFAULT_TAG_OWNERSHIP_KEYS, marker);
55
+
56
+ const warnings: string[] = [];
57
+ if (report.outbound.length) {
58
+ warnings.push(
59
+ `${report.outbound.length} deferred deploy-time input(s) must be wired before apply (see the boundary report).`,
60
+ );
61
+ }
62
+
63
+ const tagList = Object.entries(ownershipTags)
64
+ .map(([k, v]) => `${k}=${v}`)
65
+ .join(", ");
66
+
67
+ const steps = [
68
+ `1. Confirm the emitted source builds spec-true: chant build <emitted-src> -o ${report.target}.template.json`,
69
+ `2. Add the ownership marker so chant owns the resource — tags: ${tagList}`,
70
+ `3. Apply with your lifecycle (BYOL), e.g.:`,
71
+ ` aws cloudformation deploy --template-file ${report.target}.template.json --stack-name ${stack} ...`,
72
+ ` or graduate to an ApplyOp for a durable, gated apply.`,
73
+ `4. Verify chant now owns it: chant lifecycle diff --live <env> (expect: unchanged, owned)`,
74
+ `Rollback before this point: terraform import ${report.target} <physical-id>`,
75
+ ];
76
+
77
+ return { target: report.target, marker, ownershipTags, steps, warnings };
78
+ }
@@ -0,0 +1,137 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { buildGraph, inboundEdges, outboundEdges } from "./graph";
3
+ import type { Hcl2JsonTree } from "./types";
4
+
5
+ /**
6
+ * The #197 worked example, in the JSON shape `@cdktf/hcl2json` emits: a bucket,
7
+ * its versioning sub-resource, and a Lambda that reads the bucket's name + arn.
8
+ */
9
+ const workedExample: Hcl2JsonTree = {
10
+ resource: {
11
+ aws_s3_bucket: {
12
+ assets: [{ bucket: "myapp-assets-prod", tags: { Team: "web", Env: "prod" } }],
13
+ },
14
+ aws_s3_bucket_versioning: {
15
+ assets: [
16
+ {
17
+ bucket: "${aws_s3_bucket.assets.id}",
18
+ versioning_configuration: { status: "Enabled" },
19
+ },
20
+ ],
21
+ },
22
+ aws_lambda_function: {
23
+ api: [
24
+ {
25
+ function_name: "myapp-api",
26
+ environment: {
27
+ variables: {
28
+ ASSETS_BUCKET: "${aws_s3_bucket.assets.bucket}",
29
+ ASSETS_ARN: "${aws_s3_bucket.assets.arn}",
30
+ },
31
+ },
32
+ },
33
+ ],
34
+ },
35
+ },
36
+ };
37
+
38
+ describe("buildGraph", () => {
39
+ test("extracts resource nodes with addresses", () => {
40
+ const g = buildGraph(workedExample);
41
+ expect(g.nodes.map((n) => n.address)).toEqual([
42
+ "aws_lambda_function.api",
43
+ "aws_s3_bucket.assets",
44
+ "aws_s3_bucket_versioning.assets",
45
+ ]);
46
+ expect(g.nodes.every((n) => n.kind === "resource")).toBe(true);
47
+ });
48
+
49
+ test("records directed edges with referenced attributes, deduped per target", () => {
50
+ const g = buildGraph(workedExample);
51
+ // versioning → bucket (.id), lambda → bucket (.bucket, .arn)
52
+ 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"] },
55
+ ]);
56
+ });
57
+
58
+ test("inbound/outbound classification", () => {
59
+ const g = buildGraph(workedExample);
60
+ // Two survivors depend on the bucket; the bucket depends on nothing.
61
+ expect(inboundEdges(g, "aws_s3_bucket.assets").map((e) => e.from)).toEqual([
62
+ "aws_lambda_function.api",
63
+ "aws_s3_bucket_versioning.assets",
64
+ ]);
65
+ expect(outboundEdges(g, "aws_s3_bucket.assets")).toEqual([]);
66
+ // The Lambda depends on the bucket (outbound) and nothing depends on it.
67
+ expect(outboundEdges(g, "aws_lambda_function.api").map((e) => e.to)).toEqual([
68
+ "aws_s3_bucket.assets",
69
+ ]);
70
+ expect(inboundEdges(g, "aws_lambda_function.api")).toEqual([]);
71
+ });
72
+
73
+ test("module blocks become nodes and are referenceable", () => {
74
+ const tree: Hcl2JsonTree = {
75
+ module: { cdn: [{ source: "./cdn", bucket_arn: "${aws_s3_bucket.assets.arn}" }] },
76
+ resource: {
77
+ aws_s3_bucket: { assets: [{ bucket: "x" }] },
78
+ aws_route53_record: { cdn: [{ name: "${module.cdn.domain}" }] },
79
+ },
80
+ };
81
+ const g = buildGraph(tree);
82
+ const cdn = g.nodes.find((n) => n.address === "module.cdn");
83
+ expect(cdn).toMatchObject({ kind: "module", name: "cdn" });
84
+ // module → bucket, and record → module
85
+ expect(g.edges).toContainEqual({
86
+ from: "module.cdn",
87
+ to: "aws_s3_bucket.assets",
88
+ attrs: ["arn"],
89
+ });
90
+ expect(g.edges).toContainEqual({
91
+ from: "aws_route53_record.cdn",
92
+ to: "module.cdn",
93
+ attrs: ["domain"],
94
+ });
95
+ });
96
+
97
+ test("count / for_each mark a node dynamic (single instance until state resolves)", () => {
98
+ const tree: Hcl2JsonTree = {
99
+ resource: {
100
+ aws_instance: {
101
+ web: [{ count: 3, ami: "ami-123" }],
102
+ worker: [{ for_each: "${var.workers}", ami: "ami-456" }],
103
+ bastion: [{ ami: "ami-789" }],
104
+ },
105
+ },
106
+ };
107
+ const g = buildGraph(tree);
108
+ const byAddr = Object.fromEntries(g.nodes.map((n) => [n.address, n]));
109
+ expect(byAddr["aws_instance.web"]).toMatchObject({ hasDynamic: true, instances: 1 });
110
+ expect(byAddr["aws_instance.worker"]).toMatchObject({ hasDynamic: true, instances: 1 });
111
+ expect(byAddr["aws_instance.bastion"]).toMatchObject({ hasDynamic: false, instances: 1 });
112
+ });
113
+
114
+ test("a reference to a data source marks the referrer dynamic but is not an edge", () => {
115
+ const tree: Hcl2JsonTree = {
116
+ data: { aws_ami: { ubuntu: [{ owners: ["099720109477"] }] } },
117
+ resource: {
118
+ aws_instance: { web: [{ ami: "${data.aws_ami.ubuntu.id}" }] },
119
+ },
120
+ };
121
+ const g = buildGraph(tree);
122
+ expect(g.nodes.map((n) => n.address)).toEqual(["aws_instance.web"]); // data is not a node
123
+ expect(g.edges).toEqual([]); // ref to data → no resource edge
124
+ expect(g.nodes[0].hasDynamic).toBe(true);
125
+ });
126
+
127
+ test("var / local references are not edges", () => {
128
+ const tree: Hcl2JsonTree = {
129
+ resource: {
130
+ aws_s3_bucket: { assets: [{ bucket: "${var.name}", tags: { env: "${local.env}" } }] },
131
+ },
132
+ };
133
+ const g = buildGraph(tree);
134
+ expect(g.edges).toEqual([]);
135
+ expect(g.nodes[0].hasDynamic).toBe(false); // var/local alone are not the dynamic markers
136
+ });
137
+ });
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Pure Terraform dependency-graph builder for the carve-out advisor (#214 T1).
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.
8
+ */
9
+
10
+ import { IDENTITY_ATTR } from "./tier-map";
11
+ import type { Hcl2JsonTree, TfEdge, TfGraph, TfNode } from "./types";
12
+
13
+ /**
14
+ * hcl2json leaves interpolations as `${...}` strings. Match each expression
15
+ * body; reference heads are extracted from it in `refsFromExpression`.
16
+ */
17
+ const INTERPOLATION = /\$\{([^}]+)\}/g;
18
+
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"]);
34
+
35
+ interface RawRef {
36
+ address: string;
37
+ attr?: string;
38
+ }
39
+
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] });
46
+ }
47
+ for (const m of expr.matchAll(DATA_REF)) {
48
+ refs.push({ address: `data.${m[1]}.${m[2]}`, attr: m[3] });
49
+ }
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] });
53
+ }
54
+ return refs;
55
+ }
56
+
57
+ /** Collect every `${...}` reference reachable in a block's value tree. */
58
+ function refsInValue(value: unknown): RawRef[] {
59
+ const refs: RawRef[] = [];
60
+ const visit = (v: unknown): void => {
61
+ if (typeof v === "string") {
62
+ for (const m of v.matchAll(INTERPOLATION)) refs.push(...refsFromExpression(m[1]));
63
+ } else if (Array.isArray(v)) {
64
+ v.forEach(visit);
65
+ } else if (v && typeof v === "object") {
66
+ for (const inner of Object.values(v as Record<string, unknown>)) visit(inner);
67
+ }
68
+ };
69
+ visit(value);
70
+ return refs;
71
+ }
72
+
73
+ /** A block carries `count`/`for_each` → dynamic, single instance until state resolves it. */
74
+ function blockHasMeta(block: unknown, key: string): boolean {
75
+ return !!block && typeof block === "object" && key in (block as Record<string, unknown>);
76
+ }
77
+
78
+ /** The resource's physical name, if its identity attribute is a plain literal (not interpolated). */
79
+ function literalIdentity(block: unknown, type: string): string | undefined {
80
+ const attr = IDENTITY_ATTR[type];
81
+ if (!attr || !block || typeof block !== "object") return undefined;
82
+ const value = (block as Record<string, unknown>)[attr];
83
+ if (typeof value !== "string" || value.includes("${")) return undefined;
84
+ return value;
85
+ }
86
+
87
+ /**
88
+ * Build the dependency graph from a merged hcl2json tree.
89
+ *
90
+ * `resource` and `module` blocks become nodes. `data` sources are NOT nodes
91
+ * (they are not carvable infrastructure), but a reference *to* a data source
92
+ * marks the referring node dynamic. An edge is recorded only when its target
93
+ * resolves to a known resource/module node — references to `var`/`local`/data
94
+ * are dropped.
95
+ */
96
+ export function buildGraph(tree: Hcl2JsonTree): TfGraph {
97
+ const nodes: TfNode[] = [];
98
+ const dataAddresses = new Set<string>();
99
+
100
+ // First pass: register data-source addresses so refs to them can be spotted.
101
+ for (const [type, named] of Object.entries(tree.data ?? {})) {
102
+ for (const name of Object.keys(named)) dataAddresses.add(`data.${type}.${name}`);
103
+ }
104
+
105
+ // Resource nodes.
106
+ const rawRefsByNode = new Map<string, RawRef[]>();
107
+ for (const [type, named] of Object.entries(tree.resource ?? {})) {
108
+ for (const [name, blocks] of Object.entries(named)) {
109
+ const address = `${type}.${name}`;
110
+ const block = Array.isArray(blocks) ? blocks[0] : blocks;
111
+ const dynamic = blockHasMeta(block, "count") || blockHasMeta(block, "for_each");
112
+ const refs = refsInValue(block);
113
+ const touchesData = refs.some((r) => dataAddresses.has(r.address));
114
+ rawRefsByNode.set(address, refs);
115
+ nodes.push({
116
+ address,
117
+ kind: "resource",
118
+ type,
119
+ name,
120
+ instances: 1,
121
+ hasDynamic: dynamic || touchesData,
122
+ identity: literalIdentity(block, type),
123
+ });
124
+ }
125
+ }
126
+
127
+ // Module nodes.
128
+ for (const [name, blocks] of Object.entries(tree.module ?? {})) {
129
+ const address = `module.${name}`;
130
+ const block = Array.isArray(blocks) ? blocks[0] : blocks;
131
+ const dynamic = blockHasMeta(block, "count") || blockHasMeta(block, "for_each");
132
+ const refs = refsInValue(block);
133
+ const touchesData = refs.some((r) => dataAddresses.has(r.address));
134
+ rawRefsByNode.set(address, refs);
135
+ nodes.push({
136
+ address,
137
+ kind: "module",
138
+ name,
139
+ instances: 1,
140
+ hasDynamic: dynamic || touchesData,
141
+ });
142
+ }
143
+
144
+ // Edges: keep only references that resolve to a known node.
145
+ const known = new Set(nodes.map((n) => n.address));
146
+ const edges: TfEdge[] = [];
147
+ for (const [from, refs] of rawRefsByNode) {
148
+ const byTarget = new Map<string, Set<string>>();
149
+ for (const ref of refs) {
150
+ 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);
153
+ }
154
+ for (const [to, attrs] of byTarget) {
155
+ edges.push({ from, to, attrs: [...attrs].sort() });
156
+ }
157
+ }
158
+
159
+ // Code-point ordering (not localeCompare) so output is locale-independent and
160
+ // punctuation sorts predictably (`.` < `_`).
161
+ const cmp = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0);
162
+ return {
163
+ nodes: nodes.sort((a, b) => cmp(a.address, b.address)),
164
+ edges: edges.sort((a, b) => cmp(a.from, b.from) || cmp(a.to, b.to)),
165
+ };
166
+ }
167
+
168
+ /** Edges where other nodes depend on `address` (each → a surviving-TF data-source patch). */
169
+ export function inboundEdges(graph: TfGraph, address: string): TfEdge[] {
170
+ return graph.edges.filter((e) => e.to === address);
171
+ }
172
+
173
+ /** Edges where `address` depends on other nodes (each → a deferred deploy-time input). */
174
+ export function outboundEdges(graph: TfGraph, address: string): TfEdge[] {
175
+ return graph.edges.filter((e) => e.from === address);
176
+ }
@@ -0,0 +1,56 @@
1
+ import { describe, test, expect, beforeAll } from "vitest";
2
+ import { mkdtempSync, writeFileSync, rmSync } from "fs";
3
+ import { tmpdir } from "os";
4
+ import { join } from "path";
5
+ import { parseTerraformDir, loadHcl2json, Hcl2JsonNotInstalled } from "./parse";
6
+
7
+ /**
8
+ * `@cdktf/hcl2json` is an optional (dev-only in this repo) dependency. These
9
+ * tests exercise the real wasm parser when it resolves and skip cleanly when it
10
+ * does not, so a consumer install without the parser never fails the suite.
11
+ */
12
+ let parserAvailable = false;
13
+ beforeAll(async () => {
14
+ try {
15
+ await loadHcl2json();
16
+ parserAvailable = true;
17
+ } catch {
18
+ parserAvailable = false;
19
+ }
20
+ });
21
+
22
+ describe("loadHcl2json", () => {
23
+ test("missing parser throws an install-hint error, not a raw MODULE_NOT_FOUND", () => {
24
+ const err = new Hcl2JsonNotInstalled(new Error("Cannot find module '@cdktf/hcl2json'"));
25
+ expect(err.message).toContain("npm install -D @cdktf/hcl2json");
26
+ expect(err.message).toContain("HCL parser");
27
+ expect(err.name).toBe("Hcl2JsonNotInstalled");
28
+ });
29
+ });
30
+
31
+ describe.runIf(true)("parseTerraformDir (real wasm)", () => {
32
+ test("parses a multi-file estate into the expected graph", async () => {
33
+ if (!parserAvailable) return; // optional dep absent — skip
34
+ const dir = mkdtempSync(join(tmpdir(), "chant-tf-"));
35
+ try {
36
+ writeFileSync(
37
+ join(dir, "bucket.tf"),
38
+ `resource "aws_s3_bucket" "assets" {\n bucket = "myapp-assets-prod"\n}\n`,
39
+ );
40
+ writeFileSync(
41
+ join(dir, "api.tf"),
42
+ `resource "aws_lambda_function" "api" {\n environment {\n variables = {\n ASSETS_BUCKET = aws_s3_bucket.assets.bucket\n ASSETS_ARN = aws_s3_bucket.assets.arn\n }\n }\n}\n`,
43
+ );
44
+ const g = await parseTerraformDir(dir);
45
+ expect(g.nodes.map((n) => n.address)).toEqual([
46
+ "aws_lambda_function.api",
47
+ "aws_s3_bucket.assets",
48
+ ]);
49
+ expect(g.edges).toEqual([
50
+ { from: "aws_lambda_function.api", to: "aws_s3_bucket.assets", attrs: ["arn", "bucket"] },
51
+ ]);
52
+ } finally {
53
+ rmSync(dir, { recursive: true, force: true });
54
+ }
55
+ });
56
+ });
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Thin wasm glue for the carve-out advisor (#214 T1): read a Terraform estate's
3
+ * `.tf` files, run them through `@cdktf/hcl2json`, merge into one tree, and hand
4
+ * off to the pure `buildGraph`.
5
+ *
6
+ * `@cdktf/hcl2json` is NOT a chant dependency — it carries a ~1.8 MB wasm blob
7
+ * and only carve-out users need it. It is lazy-loaded here and, if absent, the
8
+ * advisor fails with a one-line install hint.
9
+ */
10
+
11
+ import { readdirSync, readFileSync } from "fs";
12
+ import { join } from "path";
13
+ import { buildGraph } from "./graph";
14
+ import { readStateInstanceCounts, applyStateCounts } from "./state";
15
+ import type { Hcl2JsonTree, TfGraph } from "./types";
16
+
17
+ /** Minimal shape of the parser export we depend on. */
18
+ type Hcl2JsonParse = (filename: string, hcl: string) => Promise<Hcl2JsonTree>;
19
+
20
+ export class Hcl2JsonNotInstalled extends Error {
21
+ constructor(cause: unknown) {
22
+ super(
23
+ "Terraform carve-out needs the HCL parser, which is not installed.\n" +
24
+ " Install it once: npm install -D @cdktf/hcl2json\n" +
25
+ `(underlying error: ${cause instanceof Error ? cause.message : String(cause)})`,
26
+ );
27
+ this.name = "Hcl2JsonNotInstalled";
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Lazy-load the optional HCL parser. Throws `Hcl2JsonNotInstalled` with an
33
+ * install hint when the package is missing, rather than a raw MODULE_NOT_FOUND.
34
+ */
35
+ export async function loadHcl2json(): Promise<Hcl2JsonParse> {
36
+ try {
37
+ const mod = (await import("@cdktf/hcl2json")) as { parse: Hcl2JsonParse };
38
+ return mod.parse;
39
+ } catch (err) {
40
+ throw new Hcl2JsonNotInstalled(err);
41
+ }
42
+ }
43
+
44
+ /** Deep-merge hcl2json trees across files (resource/module/data namespaces). */
45
+ function mergeTrees(into: Hcl2JsonTree, next: Hcl2JsonTree): void {
46
+ for (const section of ["resource", "data"] as const) {
47
+ const src = next[section];
48
+ if (!src) continue;
49
+ const dst = (into[section] ??= {}) as Record<string, Record<string, unknown[]>>;
50
+ for (const [type, named] of Object.entries(src)) {
51
+ dst[type] = { ...(dst[type] ?? {}), ...named };
52
+ }
53
+ }
54
+ if (next.module) into.module = { ...(into.module ?? {}), ...next.module };
55
+ }
56
+
57
+ /** List every `.tf` file directly under `dir` (non-recursive; matches Terraform's own module scoping). */
58
+ function listTfFiles(dir: string): string[] {
59
+ return readdirSync(dir)
60
+ .filter((f) => f.endsWith(".tf"))
61
+ .map((f) => join(dir, f))
62
+ .sort();
63
+ }
64
+
65
+ export interface ParseTerraformOptions {
66
+ /** Opt-in `.tfstate` path (#214 T2): overlays state-expanded instance counts. */
67
+ statePath?: string;
68
+ }
69
+
70
+ /**
71
+ * Parse a Terraform directory into a dependency graph. Reads only `.tf` unless
72
+ * `statePath` is given. Pure `.tf` estates parse fully; `count`/`for_each`
73
+ * blocks report a single instance and are flagged `hasDynamic`. With state, the
74
+ * instance counts become accurate (see `state.ts`).
75
+ */
76
+ export async function parseTerraformDir(dir: string, opts: ParseTerraformOptions = {}): Promise<TfGraph> {
77
+ const parse = await loadHcl2json();
78
+ const files = listTfFiles(dir);
79
+ const merged: Hcl2JsonTree = {};
80
+ for (const file of files) {
81
+ const tree = await parse(file, readFileSync(file, "utf-8"));
82
+ mergeTrees(merged, tree);
83
+ }
84
+ const graph = buildGraph(merged);
85
+ if (opts.statePath) applyStateCounts(graph, readStateInstanceCounts(opts.statePath));
86
+ return graph;
87
+ }