@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,81 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { AWS_CARVE_TYPES, awsCarveType, applyAwsMapper } from "./aws-resources";
3
+ import { TIER_MAP } from "./tier-map";
4
+ import { canAdoptFromState } from "./adopt-state";
5
+
6
+ describe("AWS carve-out table", () => {
7
+ test("advise and emit cover exactly the same AWS types (no cliff)", () => {
8
+ // Every AWS type the advisor ranks (tier map) must be emittable, and vice
9
+ // versa. Non-AWS entries (kubernetes_manifest) are excluded.
10
+ const tierAws = Object.keys(TIER_MAP).filter((t) => t.startsWith("aws_")).sort();
11
+ const emitAws = AWS_CARVE_TYPES.map((t) => t.tfType).sort();
12
+ expect(tierAws).toEqual(emitAws);
13
+ for (const t of emitAws) expect(canAdoptFromState(t)).toBe(true);
14
+ });
15
+
16
+ test("covers the common carve targets across families", () => {
17
+ const types = new Set(AWS_CARVE_TYPES.map((t) => t.tfType));
18
+ for (const t of [
19
+ "aws_s3_bucket", "aws_iam_role", "aws_iam_policy", "aws_dynamodb_table",
20
+ "aws_lambda_function", "aws_sns_topic", "aws_sqs_queue", "aws_kms_key",
21
+ "aws_secretsmanager_secret", "aws_ssm_parameter", "aws_ecr_repository",
22
+ "aws_vpc", "aws_subnet", "aws_security_group", "aws_route53_zone",
23
+ "aws_cloudwatch_log_group",
24
+ ]) {
25
+ expect(types.has(t)).toBe(true);
26
+ }
27
+ expect(AWS_CARVE_TYPES.length).toBeGreaterThanOrEqual(20);
28
+ });
29
+
30
+ test("constructors are non-empty and consistent per native type", () => {
31
+ // A constructor may be shared by TF aliases for the same native resource
32
+ // (e.g. aws_lb and aws_alb both → LoadBalancer), but a given native type
33
+ // must always map to the same constructor.
34
+ const byNative = new Map<string, string>();
35
+ for (const t of AWS_CARVE_TYPES) {
36
+ expect(t.ctor.length).toBeGreaterThan(0);
37
+ const seen = byNative.get(t.nativeType);
38
+ if (seen) expect(t.ctor).toBe(seen);
39
+ else byNative.set(t.nativeType, t.ctor);
40
+ }
41
+ });
42
+ });
43
+
44
+ describe("applyAwsMapper", () => {
45
+ test("renames fields to CloudFormation props and reshapes tags", () => {
46
+ const { props, mappedKeys } = applyAwsMapper(awsCarveType("aws_s3_bucket")!, {
47
+ id: "b", bucket: "my-bucket", tags: { Env: "prod" }, force_destroy: false,
48
+ });
49
+ expect(props).toEqual({ BucketName: "my-bucket", Tags: [{ Key: "Env", Value: "prod" }] });
50
+ expect(mappedKeys).toContain("bucket");
51
+ expect(mappedKeys).toContain("tags");
52
+ expect(mappedKeys).not.toContain("force_destroy"); // stays unmapped
53
+ });
54
+
55
+ test("parses a JSON-string policy into an object", () => {
56
+ const { props } = applyAwsMapper(awsCarveType("aws_iam_role")!, {
57
+ name: "r",
58
+ assume_role_policy: '{"Version":"2012-10-17","Statement":[]}',
59
+ managed_policy_arns: ["arn:aws:iam::aws:policy/X"],
60
+ });
61
+ expect(props.RoleName).toBe("r");
62
+ expect(props.AssumeRolePolicyDocument).toEqual({ Version: "2012-10-17", Statement: [] });
63
+ expect(props.ManagedPolicyArns).toEqual(["arn:aws:iam::aws:policy/X"]);
64
+ });
65
+
66
+ test("wraps an instance-profile role into a list", () => {
67
+ const { props } = applyAwsMapper(awsCarveType("aws_iam_instance_profile")!, { name: "p", role: "my-role" });
68
+ expect(props).toEqual({ InstanceProfileName: "p", Roles: ["my-role"] });
69
+ });
70
+
71
+ test("a malformed JSON policy is preserved as a string, not dropped", () => {
72
+ const { props } = applyAwsMapper(awsCarveType("aws_iam_policy")!, { name: "p", policy: "not json {" });
73
+ expect(props.PolicyDocument).toBe("not json {");
74
+ });
75
+
76
+ test("omits absent attributes", () => {
77
+ const { props } = applyAwsMapper(awsCarveType("aws_subnet")!, { vpc_id: "vpc-1", cidr_block: "10.0.1.0/24" });
78
+ expect(props).toEqual({ VpcId: "vpc-1", CidrBlock: "10.0.1.0/24" });
79
+ expect(props).not.toHaveProperty("AvailabilityZone");
80
+ });
81
+ });
@@ -0,0 +1,214 @@
1
+ /**
2
+ * The AWS carve-out resource table — the single source of truth for which AWS
3
+ * Terraform types chant can carve, and how.
4
+ *
5
+ * Both the advisor (tier map / scoring) and `carve emit --state` derive from
6
+ * this one list, so advise and emit cover exactly the same AWS types: advise
7
+ * never ranks a resource emit cannot produce, and emit never claims a type
8
+ * advise did not score. Adding a type here lights it up for both at once.
9
+ *
10
+ * Each entry maps the common, high-confidence Terraform attributes to their
11
+ * CloudFormation property names (chant AWS constructors take CFN PascalCase
12
+ * props). Attributes without a mapping here are preserved in a reference
13
+ * comment on emit, never dropped — a curated seed, not a full TF↔CFN transform.
14
+ */
15
+
16
+ /** A Terraform attribute → CloudFormation property mapping. */
17
+ type FieldSpec = string | { prop: string; transform: (v: unknown) => unknown };
18
+
19
+ export interface AwsCarveType {
20
+ /** Terraform resource type, e.g. `aws_s3_bucket`. */
21
+ tfType: string;
22
+ /** Native-spec map tier: 1 clean 1:1, 2 some reshaping, 3 heavy composite. */
23
+ tier: 1 | 2 | 3;
24
+ /** CloudFormation type, e.g. `AWS::S3::Bucket`. */
25
+ nativeType: string;
26
+ /** chant AWS lexicon constructor, e.g. `Bucket` (verified against generated exports). */
27
+ ctor: string;
28
+ /** The HCL attribute carrying the physical name (for the live-import hint / graph identity). */
29
+ identityAttr?: string;
30
+ /** Terraform attribute → CFN property mappings. */
31
+ fields: Record<string, FieldSpec>;
32
+ /** Map the Terraform `tags` map to CloudFormation `Tags` list. */
33
+ tags?: boolean;
34
+ }
35
+
36
+ export const AWS_LEXICON_IMPORT = "@intentius/chant-lexicon-aws";
37
+
38
+ /** Parse a Terraform JSON-string attribute (IAM policy docs) into an object. */
39
+ const asJson = (v: unknown): unknown => {
40
+ if (typeof v !== "string") return v;
41
+ try {
42
+ return JSON.parse(v);
43
+ } catch {
44
+ return v;
45
+ }
46
+ };
47
+ const json = (prop: string): FieldSpec => ({ prop, transform: asJson });
48
+
49
+ export const AWS_CARVE_TYPES: AwsCarveType[] = [
50
+ // ── Storage & data ──
51
+ { tfType: "aws_s3_bucket", tier: 1, nativeType: "AWS::S3::Bucket", ctor: "Bucket", identityAttr: "bucket",
52
+ fields: { bucket: "BucketName" }, tags: true },
53
+ { tfType: "aws_dynamodb_table", tier: 2, nativeType: "AWS::DynamoDB::Table", ctor: "Table", identityAttr: "name",
54
+ fields: { name: "TableName", billing_mode: "BillingMode" }, tags: true },
55
+ { tfType: "aws_efs_file_system", tier: 1, nativeType: "AWS::EFS::FileSystem", ctor: "EFSFileSystem",
56
+ fields: { encrypted: "Encrypted", performance_mode: "PerformanceMode", throughput_mode: "ThroughputMode" }, tags: true },
57
+
58
+ // ── Messaging ──
59
+ { tfType: "aws_sns_topic", tier: 1, nativeType: "AWS::SNS::Topic", ctor: "Topic", identityAttr: "name",
60
+ fields: { name: "TopicName", display_name: "DisplayName", fifo_topic: "FifoTopic" }, tags: true },
61
+ { tfType: "aws_sqs_queue", tier: 1, nativeType: "AWS::SQS::Queue", ctor: "Queue", identityAttr: "name",
62
+ fields: { name: "QueueName", visibility_timeout_seconds: "VisibilityTimeout", fifo_queue: "FifoQueue", delay_seconds: "DelaySeconds" }, tags: true },
63
+
64
+ // ── IAM ──
65
+ { tfType: "aws_iam_role", tier: 2, nativeType: "AWS::IAM::Role", ctor: "Role", identityAttr: "name",
66
+ fields: { name: "RoleName", assume_role_policy: json("AssumeRolePolicyDocument"), managed_policy_arns: "ManagedPolicyArns", description: "Description", path: "Path" }, tags: true },
67
+ { tfType: "aws_iam_policy", tier: 1, nativeType: "AWS::IAM::ManagedPolicy", ctor: "ManagedPolicy", identityAttr: "name",
68
+ fields: { name: "ManagedPolicyName", policy: json("PolicyDocument"), description: "Description", path: "Path" } },
69
+ { tfType: "aws_iam_instance_profile", tier: 1, nativeType: "AWS::IAM::InstanceProfile", ctor: "InstanceProfile", identityAttr: "name",
70
+ fields: { name: "InstanceProfileName", path: "Path", role: { prop: "Roles", transform: (v) => (v === undefined ? v : [v]) } } },
71
+
72
+ // ── Registry, keys, secrets, config ──
73
+ { tfType: "aws_ecr_repository", tier: 1, nativeType: "AWS::ECR::Repository", ctor: "ECRRepository", identityAttr: "name",
74
+ fields: { name: "RepositoryName", image_tag_mutability: "ImageTagMutability" }, tags: true },
75
+ { tfType: "aws_kms_key", tier: 2, nativeType: "AWS::KMS::Key", ctor: "KmsKey",
76
+ fields: { description: "Description", enable_key_rotation: "EnableKeyRotation", deletion_window_in_days: "PendingWindowInDays", is_enabled: "Enabled" }, tags: true },
77
+ { tfType: "aws_secretsmanager_secret", tier: 1, nativeType: "AWS::SecretsManager::Secret", ctor: "Secret", identityAttr: "name",
78
+ fields: { name: "Name", description: "Description", kms_key_id: "KmsKeyId" }, tags: true },
79
+ { tfType: "aws_ssm_parameter", tier: 1, nativeType: "AWS::SSM::Parameter", ctor: "SsmParameter", identityAttr: "name",
80
+ fields: { name: "Name", type: "Type", value: "Value", tier: "Tier", description: "Description" } },
81
+
82
+ // ── Compute ──
83
+ { tfType: "aws_lambda_function", tier: 2, nativeType: "AWS::Lambda::Function", ctor: "Function", identityAttr: "function_name",
84
+ fields: { function_name: "FunctionName", runtime: "Runtime", handler: "Handler", memory_size: "MemorySize", timeout: "Timeout", description: "Description", architectures: "Architectures" }, tags: true },
85
+ { tfType: "aws_ecs_service", tier: 3, nativeType: "AWS::ECS::Service", ctor: "EcsService", identityAttr: "name",
86
+ fields: { name: "ServiceName", cluster: "Cluster", desired_count: "DesiredCount", launch_type: "LaunchType", task_definition: "TaskDefinition" }, tags: true },
87
+
88
+ // ── Networking ──
89
+ { tfType: "aws_vpc", tier: 1, nativeType: "AWS::EC2::VPC", ctor: "Vpc",
90
+ fields: { cidr_block: "CidrBlock", enable_dns_support: "EnableDnsSupport", enable_dns_hostnames: "EnableDnsHostnames", instance_tenancy: "InstanceTenancy" }, tags: true },
91
+ { tfType: "aws_subnet", tier: 1, nativeType: "AWS::EC2::Subnet", ctor: "Subnet",
92
+ fields: { vpc_id: "VpcId", cidr_block: "CidrBlock", availability_zone: "AvailabilityZone", map_public_ip_on_launch: "MapPublicIpOnLaunch" }, tags: true },
93
+ { tfType: "aws_security_group", tier: 2, nativeType: "AWS::EC2::SecurityGroup", ctor: "SecurityGroup", identityAttr: "name",
94
+ fields: { description: "GroupDescription", name: "GroupName", vpc_id: "VpcId" }, tags: true },
95
+ { tfType: "aws_route_table", tier: 2, nativeType: "AWS::EC2::RouteTable", ctor: "RouteTable",
96
+ fields: { vpc_id: "VpcId" }, tags: true },
97
+ { tfType: "aws_internet_gateway", tier: 1, nativeType: "AWS::EC2::InternetGateway", ctor: "InternetGateway",
98
+ fields: {}, tags: true },
99
+ { tfType: "aws_eip", tier: 1, nativeType: "AWS::EC2::EIP", ctor: "EIP",
100
+ fields: { domain: "Domain", instance: "InstanceId" }, tags: true },
101
+ { tfType: "aws_nat_gateway", tier: 2, nativeType: "AWS::EC2::NatGateway", ctor: "NatGateway",
102
+ fields: { subnet_id: "SubnetId", allocation_id: "AllocationId", connectivity_type: "ConnectivityType" }, tags: true },
103
+
104
+ // ── DNS & observability ──
105
+ { tfType: "aws_route53_zone", tier: 1, nativeType: "AWS::Route53::HostedZone", ctor: "HostedZone", identityAttr: "name",
106
+ fields: { name: "Name", comment: "HostedZoneConfig" } },
107
+ { tfType: "aws_cloudwatch_log_group", tier: 1, nativeType: "AWS::Logs::LogGroup", ctor: "LogGroup", identityAttr: "name",
108
+ fields: { name: "LogGroupName", retention_in_days: "RetentionInDays", kms_key_id: "KmsKeyId" }, tags: true },
109
+ { tfType: "aws_cloudwatch_metric_alarm", tier: 2, nativeType: "AWS::CloudWatch::Alarm", ctor: "Alarm", identityAttr: "alarm_name",
110
+ fields: { alarm_name: "AlarmName", comparison_operator: "ComparisonOperator", metric_name: "MetricName", namespace: "Namespace", threshold: "Threshold", evaluation_periods: "EvaluationPeriods", period: "Period", statistic: "Statistic", alarm_description: "AlarmDescription" }, tags: true },
111
+ { tfType: "aws_cloudwatch_event_rule", tier: 2, nativeType: "AWS::Events::Rule", ctor: "EventRule", identityAttr: "name",
112
+ fields: { name: "Name", schedule_expression: "ScheduleExpression", event_pattern: json("EventPattern"), description: "Description", state: "State" } },
113
+
114
+ // ── Compute (EC2 / ECS / ASG) ──
115
+ { tfType: "aws_instance", tier: 2, nativeType: "AWS::EC2::Instance", ctor: "Instance",
116
+ fields: { ami: "ImageId", instance_type: "InstanceType", subnet_id: "SubnetId", key_name: "KeyName", availability_zone: "AvailabilityZone", iam_instance_profile: "IamInstanceProfile" }, tags: true },
117
+ { tfType: "aws_launch_template", tier: 2, nativeType: "AWS::EC2::LaunchTemplate", ctor: "LaunchTemplate", identityAttr: "name",
118
+ fields: { name: "LaunchTemplateName" }, tags: true },
119
+ { tfType: "aws_autoscaling_group", tier: 2, nativeType: "AWS::AutoScaling::AutoScalingGroup", ctor: "AutoScalingGroup", identityAttr: "name",
120
+ fields: { name: "AutoScalingGroupName", min_size: "MinSize", max_size: "MaxSize", desired_capacity: "DesiredCapacity", vpc_zone_identifier: "VPCZoneIdentifier", health_check_type: "HealthCheckType" } },
121
+ { tfType: "aws_ecs_cluster", tier: 1, nativeType: "AWS::ECS::Cluster", ctor: "EcsCluster", identityAttr: "name",
122
+ fields: { name: "ClusterName" }, tags: true },
123
+ { tfType: "aws_ecs_task_definition", tier: 3, nativeType: "AWS::ECS::TaskDefinition", ctor: "TaskDefinition",
124
+ fields: { family: "Family", cpu: "Cpu", memory: "Memory", network_mode: "NetworkMode", execution_role_arn: "ExecutionRoleArn", task_role_arn: "TaskRoleArn" }, tags: true },
125
+ { tfType: "aws_ebs_volume", tier: 1, nativeType: "AWS::EC2::Volume", ctor: "EC2Volume",
126
+ fields: { availability_zone: "AvailabilityZone", size: "Size", type: "VolumeType", encrypted: "Encrypted", iops: "Iops" }, tags: true },
127
+
128
+ // ── Load balancing (ELBv2; aws_alb is a legacy alias) ──
129
+ { tfType: "aws_lb", tier: 2, nativeType: "AWS::ElasticLoadBalancingV2::LoadBalancer", ctor: "LoadBalancer", identityAttr: "name",
130
+ fields: { name: "Name", load_balancer_type: "Type", internal: { prop: "Scheme", transform: (v) => (v === true ? "internal" : v === false ? "internet-facing" : v) } }, tags: true },
131
+ { tfType: "aws_alb", tier: 2, nativeType: "AWS::ElasticLoadBalancingV2::LoadBalancer", ctor: "LoadBalancer", identityAttr: "name",
132
+ fields: { name: "Name", load_balancer_type: "Type", internal: { prop: "Scheme", transform: (v) => (v === true ? "internal" : v === false ? "internet-facing" : v) } }, tags: true },
133
+ { tfType: "aws_lb_target_group", tier: 2, nativeType: "AWS::ElasticLoadBalancingV2::TargetGroup", ctor: "TargetGroup", identityAttr: "name",
134
+ fields: { name: "Name", port: "Port", protocol: "Protocol", vpc_id: "VpcId", target_type: "TargetType" }, tags: true },
135
+ { tfType: "aws_lb_listener", tier: 2, nativeType: "AWS::ElasticLoadBalancingV2::Listener", ctor: "Listener",
136
+ fields: { port: "Port", protocol: "Protocol", load_balancer_arn: "LoadBalancerArn" } },
137
+
138
+ // ── Databases & cache ──
139
+ { tfType: "aws_db_instance", tier: 2, nativeType: "AWS::RDS::DBInstance", ctor: "DbInstance", identityAttr: "identifier",
140
+ fields: { identifier: "DBInstanceIdentifier", engine: "Engine", engine_version: "EngineVersion", instance_class: "DBInstanceClass", allocated_storage: "AllocatedStorage", db_name: "DBName", multi_az: "MultiAZ", storage_encrypted: "StorageEncrypted" }, tags: true },
141
+ { tfType: "aws_db_subnet_group", tier: 1, nativeType: "AWS::RDS::DBSubnetGroup", ctor: "RDSDBSubnetGroup", identityAttr: "name",
142
+ fields: { name: "DBSubnetGroupName", description: "DBSubnetGroupDescription", subnet_ids: "SubnetIds" }, tags: true },
143
+ { tfType: "aws_elasticache_cluster", tier: 2, nativeType: "AWS::ElastiCache::CacheCluster", ctor: "CacheCluster",
144
+ fields: { engine: "Engine", node_type: "CacheNodeType", num_cache_nodes: "NumCacheNodes", engine_version: "EngineVersion" }, tags: true },
145
+ { tfType: "aws_elasticache_replication_group", tier: 2, nativeType: "AWS::ElastiCache::ReplicationGroup", ctor: "ReplicationGroup", identityAttr: "replication_group_id",
146
+ fields: { replication_group_id: "ReplicationGroupId", description: "ReplicationGroupDescription", node_type: "CacheNodeType", engine: "Engine" }, tags: true },
147
+
148
+ // ── DNS, CDN & API ──
149
+ { tfType: "aws_route53_record", tier: 2, nativeType: "AWS::Route53::RecordSet", ctor: "RecordSet",
150
+ fields: { name: "Name", type: "Type", ttl: "TTL", zone_id: "HostedZoneId", records: "ResourceRecords" } },
151
+ { tfType: "aws_api_gateway_rest_api", tier: 2, nativeType: "AWS::ApiGateway::RestApi", ctor: "RestApi", identityAttr: "name",
152
+ fields: { name: "Name", description: "Description" }, tags: true },
153
+ { tfType: "aws_apigatewayv2_api", tier: 2, nativeType: "AWS::ApiGatewayV2::Api", ctor: "HttpApi", identityAttr: "name",
154
+ fields: { name: "Name", protocol_type: "ProtocolType", description: "Description" }, tags: true },
155
+
156
+ // ── IAM (users/groups), certs & keys ──
157
+ { tfType: "aws_iam_user", tier: 1, nativeType: "AWS::IAM::User", ctor: "IAMUser", identityAttr: "name",
158
+ fields: { name: "UserName", path: "Path" }, tags: true },
159
+ { tfType: "aws_iam_group", tier: 1, nativeType: "AWS::IAM::Group", ctor: "IAMGroup", identityAttr: "name",
160
+ fields: { name: "GroupName", path: "Path" } },
161
+ { tfType: "aws_acm_certificate", tier: 2, nativeType: "AWS::CertificateManager::Certificate", ctor: "AcmCertificate", identityAttr: "domain_name",
162
+ fields: { domain_name: "DomainName", validation_method: "ValidationMethod", subject_alternative_names: "SubjectAlternativeNames" }, tags: true },
163
+ { tfType: "aws_kms_alias", tier: 1, nativeType: "AWS::KMS::Alias", ctor: "KMSAlias", identityAttr: "name",
164
+ fields: { name: "AliasName", target_key_id: "TargetKeyId" } },
165
+
166
+ // ── Messaging & step functions ──
167
+ { tfType: "aws_sns_topic_subscription", tier: 2, nativeType: "AWS::SNS::Subscription", ctor: "Subscription",
168
+ fields: { protocol: "Protocol", endpoint: "Endpoint", topic_arn: "TopicArn" } },
169
+ { tfType: "aws_sfn_state_machine", tier: 3, nativeType: "AWS::StepFunctions::StateMachine", ctor: "StateMachine", identityAttr: "name",
170
+ fields: { name: "StateMachineName", role_arn: "RoleArn", type: "StateMachineType" }, tags: true },
171
+ ];
172
+
173
+ const BY_TYPE = new Map(AWS_CARVE_TYPES.map((t) => [t.tfType, t]));
174
+
175
+ export function awsCarveType(tfType: string): AwsCarveType | undefined {
176
+ return BY_TYPE.get(tfType);
177
+ }
178
+
179
+ /** TF `tags` map → CloudFormation `Tags` list of {Key, Value}. */
180
+ function tagsToCfn(tags: unknown): Array<{ Key: string; Value: unknown }> | undefined {
181
+ if (!tags || typeof tags !== "object" || Array.isArray(tags)) return undefined;
182
+ const entries = Object.entries(tags as Record<string, unknown>);
183
+ return entries.length ? entries.map(([Key, Value]) => ({ Key, Value })) : undefined;
184
+ }
185
+
186
+ /**
187
+ * Apply an entry's field mappings to a resource's Terraform attributes, yielding
188
+ * CloudFormation properties and the set of attribute keys that were consumed
189
+ * (so the caller can report the rest as unmapped).
190
+ */
191
+ export function applyAwsMapper(
192
+ entry: AwsCarveType,
193
+ attrs: Record<string, unknown>,
194
+ ): { props: Record<string, unknown>; mappedKeys: string[] } {
195
+ const props: Record<string, unknown> = {};
196
+ const mappedKeys: string[] = ["id", "arn"]; // identity/computed attrs, ignored not dropped
197
+ for (const [tfAttr, spec] of Object.entries(entry.fields)) {
198
+ const value = attrs[tfAttr];
199
+ if (value === undefined) continue;
200
+ if (typeof spec === "string") {
201
+ props[spec] = value;
202
+ } else {
203
+ const t = spec.transform(value);
204
+ if (t !== undefined) props[spec.prop] = t;
205
+ }
206
+ mappedKeys.push(tfAttr);
207
+ }
208
+ if (entry.tags) {
209
+ const tags = tagsToCfn(attrs.tags);
210
+ if (tags) props.Tags = tags;
211
+ mappedKeys.push("tags");
212
+ }
213
+ return { props, mappedKeys };
214
+ }
@@ -0,0 +1,118 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { buildGraph } from "./graph";
3
+ import { boundaryReport } from "./carve";
4
+ import { generateBridge, type CarvedIdentity } from "./bridge";
5
+ import type { Hcl2JsonTree } from "./types";
6
+
7
+ const workedExample: Hcl2JsonTree = {
8
+ resource: {
9
+ aws_s3_bucket: { assets: [{ bucket: "myapp-assets-prod" }] },
10
+ aws_lambda_function: {
11
+ api: [
12
+ {
13
+ function_name: "myapp-api",
14
+ environment: {
15
+ variables: {
16
+ ASSETS_BUCKET: "${aws_s3_bucket.assets.bucket}",
17
+ ASSETS_ARN: "${aws_s3_bucket.assets.arn}",
18
+ },
19
+ },
20
+ },
21
+ ],
22
+ },
23
+ },
24
+ };
25
+
26
+ // The original surviving Terraform text (bare HCL references, as authored).
27
+ const API_TF = `resource "aws_lambda_function" "api" {
28
+ function_name = "myapp-api"
29
+ environment {
30
+ variables = {
31
+ ASSETS_BUCKET = aws_s3_bucket.assets.bucket
32
+ ASSETS_ARN = aws_s3_bucket.assets.arn
33
+ }
34
+ }
35
+ }
36
+ `;
37
+
38
+ const identities = new Map<string, CarvedIdentity>([
39
+ ["aws_s3_bucket.assets", { attr: "bucket", value: "myapp-assets-prod" }],
40
+ ]);
41
+
42
+ describe("generateBridge — inbound (data-source rewrite)", () => {
43
+ test("adds a data source for the carved bucket", () => {
44
+ const report = boundaryReport(buildGraph(workedExample), "aws_s3_bucket.assets")!;
45
+ const plan = generateBridge(report, [{ path: "api.tf", content: API_TF }], identities);
46
+
47
+ expect(plan.dataSources).toHaveLength(1);
48
+ expect(plan.dataSources[0]).toMatchObject({ address: "aws_s3_bucket.assets", type: "aws_s3_bucket", name: "assets" });
49
+ expect(plan.dataSources[0].hcl).toBe(
50
+ 'data "aws_s3_bucket" "assets" {\n bucket = "myapp-assets-prod"\n}',
51
+ );
52
+ });
53
+
54
+ test("rewrites survivor references to the data source", () => {
55
+ const report = boundaryReport(buildGraph(workedExample), "aws_s3_bucket.assets")!;
56
+ const plan = generateBridge(report, [{ path: "api.tf", content: API_TF }], identities);
57
+
58
+ const rewrite = plan.rewrites.find((r) => r.path === "api.tf")!;
59
+ expect(rewrite.changed).toBe(true);
60
+ expect(rewrite.rewritten).toContain("data.aws_s3_bucket.assets.bucket");
61
+ expect(rewrite.rewritten).toContain("data.aws_s3_bucket.assets.arn");
62
+ // The bare references are gone.
63
+ expect(rewrite.rewritten).not.toMatch(/(?<!data\.)\baws_s3_bucket\.assets\.bucket/);
64
+ });
65
+
66
+ test("does not double-prefix an already-rewritten reference (idempotent)", () => {
67
+ const report = boundaryReport(buildGraph(workedExample), "aws_s3_bucket.assets")!;
68
+ const already = API_TF.replace(/aws_s3_bucket\.assets/g, "data.aws_s3_bucket.assets");
69
+ const plan = generateBridge(report, [{ path: "api.tf", content: already }], identities);
70
+ const rewrite = plan.rewrites.find((r) => r.path === "api.tf")!;
71
+ expect(rewrite.changed).toBe(false);
72
+ expect(rewrite.rewritten).not.toContain("data.data.aws_s3_bucket");
73
+ });
74
+
75
+ test("does not touch the carved resource's own declaration (quoted labels)", () => {
76
+ const report = boundaryReport(buildGraph(workedExample), "aws_s3_bucket.assets")!;
77
+ const decl = `resource "aws_s3_bucket" "assets" {\n bucket = "myapp-assets-prod"\n}\n`;
78
+ const plan = generateBridge(report, [{ path: "bucket.tf", content: decl }], identities);
79
+ const rewrite = plan.rewrites.find((r) => r.path === "bucket.tf")!;
80
+ // `resource "aws_s3_bucket" "assets"` uses quoted labels, not the dotted
81
+ // token, so it is left alone.
82
+ expect(rewrite.rewritten).toContain('resource "aws_s3_bucket" "assets"');
83
+ expect(rewrite.changed).toBe(false);
84
+ });
85
+
86
+ test("unknown identity emits a TODO data source instead of a wrong value", () => {
87
+ const report = boundaryReport(buildGraph(workedExample), "aws_s3_bucket.assets")!;
88
+ const plan = generateBridge(report, [{ path: "api.tf", content: API_TF }], new Map());
89
+ expect(plan.dataSources[0].hcl).toContain("# TODO");
90
+ });
91
+ });
92
+
93
+ describe("generateBridge — outbound (deferred inputs)", () => {
94
+ test("records outbound edges as deferred deploy-time inputs", () => {
95
+ const tree: Hcl2JsonTree = {
96
+ resource: {
97
+ aws_sns_topic: { alerts: [{ name: "a" }] },
98
+ aws_sqs_queue: { dlq: [{ name: "d", redrive: "${aws_sns_topic.alerts.arn}" }] },
99
+ },
100
+ };
101
+ const report = boundaryReport(buildGraph(tree), "aws_sqs_queue.dlq")!;
102
+ const plan = generateBridge(report, [], new Map());
103
+ expect(plan.dataSources).toEqual([]); // no inbound → no survivor patch
104
+ expect(plan.deferredInputs).toHaveLength(1);
105
+ expect(plan.deferredInputs[0]).toMatchObject({ survivor: "aws_sns_topic.alerts", carved: "aws_sqs_queue.dlq" });
106
+ });
107
+ });
108
+
109
+ describe("generateBridge — runbook", () => {
110
+ test("is reversible and observe-first, with the state-rm and import steps", () => {
111
+ const report = boundaryReport(buildGraph(workedExample), "aws_s3_bucket.assets")!;
112
+ const plan = generateBridge(report, [{ path: "api.tf", content: API_TF }], identities);
113
+ expect(plan.runbook).toContain("terraform state rm aws_s3_bucket.assets");
114
+ expect(plan.runbook).toContain("does NOT destroy");
115
+ expect(plan.runbook).toContain("terraform import aws_s3_bucket.assets");
116
+ expect(plan.runbook).toMatch(/reversible/i);
117
+ });
118
+ });
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Boundary bridging for the strangler-fig carve (#197) — the centerpiece.
3
+ *
4
+ * Carving a resource cuts edges. This generates the edits to the Terraform you
5
+ * LEAVE BEHIND so its plan stays valid once the resource is chant-owned:
6
+ *
7
+ * - inbound edge → the survivor loses its reference to the carved resource.
8
+ * Bridge: add a `data` source for the (now chant-managed) resource and
9
+ * rewrite the survivor's `type.name.attr` references to `data.type.name.attr`.
10
+ * Required immediately, or `terraform plan` errors on the dangling ref.
11
+ *
12
+ * - outbound edge → the carved resource read a value from a survivor. That
13
+ * value must enter chant from outside synthesis, as a deploy-time input.
14
+ * Deferred until apply; here we only record the provenance.
15
+ *
16
+ * Pure text transform over the original `.tf` content — no wasm, no shell, no
17
+ * mutation. The command layer writes the results and (optionally) diffs them
18
+ * into a git-applyable patch. A carve never destroys or recreates; the runbook
19
+ * is reversible via `terraform import`.
20
+ */
21
+
22
+ import type { CarveReport } from "./carve";
23
+
24
+ export interface CarvedIdentity {
25
+ /** The HCL identity attribute, e.g. `bucket` or `name`. */
26
+ attr?: string;
27
+ /** Its literal value, e.g. `myapp-assets-prod`. */
28
+ value?: string;
29
+ }
30
+
31
+ export interface DataSourceBlock {
32
+ /** Carved resource address the data source stands in for. */
33
+ address: string;
34
+ type: string;
35
+ name: string;
36
+ hcl: string;
37
+ }
38
+
39
+ export interface FileRewrite {
40
+ path: string;
41
+ original: string;
42
+ rewritten: string;
43
+ changed: boolean;
44
+ }
45
+
46
+ export interface DeferredInput {
47
+ survivor: string;
48
+ carved: string;
49
+ attrs: string[];
50
+ note: string;
51
+ }
52
+
53
+ export interface BridgePlan {
54
+ target: string;
55
+ dataSources: DataSourceBlock[];
56
+ rewrites: FileRewrite[];
57
+ deferredInputs: DeferredInput[];
58
+ runbook: string;
59
+ }
60
+
61
+ /** `aws_s3_bucket.assets` → a regex matching that reference head, not already `data.`-prefixed. */
62
+ function referenceRegex(address: string): RegExp {
63
+ const escaped = address.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
64
+ // (?<!data\.) — do not double-prefix an existing data source reference.
65
+ return new RegExp(`(?<!data\\.)\\b${escaped}\\b`, "g");
66
+ }
67
+
68
+ function dataSourceHcl(type: string, name: string, identity: CarvedIdentity): string {
69
+ const body =
70
+ identity.attr && identity.value !== undefined
71
+ ? ` ${identity.attr} = ${JSON.stringify(identity.value)}`
72
+ : ` # TODO: identify the resource (its name/id was interpolated in the source)`;
73
+ return `data ${JSON.stringify(type)} ${JSON.stringify(name)} {\n${body}\n}`;
74
+ }
75
+
76
+ /**
77
+ * Build the bridge plan for a carve.
78
+ *
79
+ * @param report the boundary report (from `boundaryReport`)
80
+ * @param files every `.tf` file in the estate, as { path, content }
81
+ * @param identities physical identity per carved address, for the data sources
82
+ */
83
+ export function generateBridge(
84
+ report: CarveReport,
85
+ files: Array<{ path: string; content: string }>,
86
+ identities: Map<string, CarvedIdentity>,
87
+ ): BridgePlan {
88
+ // Carved resources that something still depends on need a data source.
89
+ const carvedWithInbound = new Map<string, { type: string; name: string }>();
90
+ for (const e of report.inbound) {
91
+ const [type, ...rest] = e.carved.split(".");
92
+ carvedWithInbound.set(e.carved, { type, name: rest.join(".") });
93
+ }
94
+
95
+ const dataSources: DataSourceBlock[] = [...carvedWithInbound.entries()].map(([address, { type, name }]) => ({
96
+ address,
97
+ type,
98
+ name,
99
+ hcl: dataSourceHcl(type, name, identities.get(address) ?? {}),
100
+ }));
101
+
102
+ // Rewrite every survivor reference to the carved resources into a data ref.
103
+ const rewrites: FileRewrite[] = files.map(({ path, content }) => {
104
+ let rewritten = content;
105
+ for (const address of carvedWithInbound.keys()) {
106
+ rewritten = rewritten.replace(referenceRegex(address), `data.${address}`);
107
+ }
108
+ return { path, original: content, rewritten, changed: rewritten !== content };
109
+ });
110
+
111
+ const deferredInputs: DeferredInput[] = report.outbound.map((e) => ({
112
+ survivor: e.survivor,
113
+ carved: e.carved,
114
+ attrs: e.attrs,
115
+ note: `${e.carved} reads ${e.survivor}.${e.attrs.join(", ")} — supply this as a deploy-time input to the carved component at apply.`,
116
+ }));
117
+
118
+ return {
119
+ target: report.target,
120
+ dataSources,
121
+ rewrites,
122
+ deferredInputs,
123
+ runbook: buildRunbook(report, dataSources, deferredInputs),
124
+ };
125
+ }
126
+
127
+ /** The reversible, observe-first handoff runbook (#197). */
128
+ function buildRunbook(report: CarveReport, dataSources: DataSourceBlock[], deferred: DeferredInput[]): string {
129
+ const carvedAddrs = report.carveSet.map((m) => m.address);
130
+ const L: string[] = [];
131
+ L.push(`# Carve-out: ${report.target} → chant [observe-first, reversible]`);
132
+ L.push("");
133
+ L.push(`Peelability ${report.peelability}. Carves ${carvedAddrs.length} Terraform resource(s) into chant, leaving the live resource untouched.`);
134
+ L.push("");
135
+ L.push("## 1. Review the emitted chant source");
136
+ L.push(" (produced by `chant carve emit` — confirm it builds to a spec-true template)");
137
+ L.push("");
138
+ L.push("## 2. Stop Terraform managing the resource (does NOT destroy it)");
139
+ L.push(` terraform state rm ${carvedAddrs.join(" ")}`);
140
+ L.push("");
141
+ L.push("## 3. Confirm no destroy, then patch the survivors");
142
+ L.push(" terraform plan # expect 0 to destroy; refs to the carved resource now error");
143
+ if (dataSources.length) {
144
+ L.push(" # apply the generated bridge patch — it adds these data sources and rewires references:");
145
+ for (const ds of dataSources) L.push(` # data.${ds.type}.${ds.name} (for ${ds.address})`);
146
+ } else {
147
+ L.push(" # no inbound edges — no survivor patch needed.");
148
+ }
149
+ L.push(" terraform plan # expect: in-place updates to the survivors only");
150
+ L.push(" terraform apply");
151
+ L.push("");
152
+ L.push("## 4. Adopt the live resource into chant (now an orphan)");
153
+ L.push(" chant carve emit --from <tf-dir> --select " + report.target + " --env <env> # already done if you ran emit");
154
+ L.push(" # then apply the ownership marker so chant owns it (carve apply, next step)");
155
+ if (deferred.length) {
156
+ L.push("");
157
+ L.push("## Deferred deploy-time inputs (wire these when you turn the dial to apply)");
158
+ for (const d of deferred) L.push(` - ${d.note}`);
159
+ }
160
+ L.push("");
161
+ L.push("## Rollback (any time before apply-graduation)");
162
+ L.push(` terraform import ${report.target} <physical-id> # back under Terraform`);
163
+ return L.join("\n");
164
+ }