Skip to content

chant carve advise

chant carve advise --from <terraform-dir | cdk.out> [flags]

chant carve advise points at foreign infrastructure and ranks each piece by peelability: how cleanly it could be carved into native chant source. Clean leaves score high, load-bearing or unmappable pieces score low.

Two sources are read. A Terraform estate is parsed from .tf (and optionally .tfstate), and each resource and module is ranked. A CDK cloud assembly (cdk.out) is read from its synthesized JSON, and each construct is ranked. Which one is in --from is decided by looking at the directory, not by a second flag.

It is read-only either way. It parses, scores, and prints. It emits no chant source, patches nothing, and touches no live resource. The emit, boundary-bridging, and apply phases have shipped as chant carve emit|bridge|apply and read Terraform only; this command is the analysis slice that is useful on its own. Advise also ranks more than emit can adopt — it bands the google provider types, the typed kubernetes provider resources and every CDK construct, which emit refuses, and it bands kubernetes_manifest, which emit adopts but bridge still refuses.

Use it to decide where an incremental move is worth starting. The point is not to carve everything. Small, cleanly-mappable pieces move; the gnarly long tail stays where it is. Once carving has begun, chant carve status reports where each carved address stands.

FlagDescription
--from <dir>The Terraform estate directory or CDK cloud assembly to analyze (required)
--state <path>Optional .tfstate for accurate count/for_each instance counts (Terraform only)
--jsonEmit the full per-resource breakdown as JSON (see The JSON report)
--report <path>Write the JSON report to a file

Terraform only. The advisor parses HCL with @cdktf/hcl2json, HashiCorp’s own parser compiled to wasm. It is not a chant dependency, because only carve-out users need it. The first run without it fails with an install hint:

Terminal window
npm install -D @cdktf/hcl2json

A CDK cloud assembly needs nothing installed. The assembly is already JSON, and its templates are already in AWS::* type space — the same space the tier map keys on.

Each resource starts at 100 and loses points for boundary work and mapping difficulty:

score = 100
- 12 * inbound # survivors that depend on this → a data-source patch each
- 4 * outbound # this depends on survivors → a deferred input each
- 4 * outputs # an output block reads this → a one-line rewrite each
- 15 * (tier - 1) # native-spec map: tier1=0, tier2=-15, tier3=-30
- 10 * has_dynamic # count / for_each / data present
- 3 * (instances - 1) # state-expanded instance count
clamp 0..100 # no known native mapping → 0

Inbound edges are the expensive ones: each resource that still depends on a carved piece needs its Terraform rewritten to a data source. Outbound edges are cheaper — a value the carved piece reads from a survivor becomes a deploy-time input, deferred until you actually apply.

An output block that reads the carved resource counts too. It is an inbound dependency with the same urgency (the surviving plan errors on the dangling reference), but bridging it rewrites a single expression in a block that manages nothing, so it is weighted like an outbound edge rather than a data-source patch.

ScoreBandMeaning
80–100clean leafcarve now
50–79carvable w/ editshas boundary work
0–49leave in Terraformunmappable or load-bearing

Terraform sub-resources that inline into a parent (for example aws_s3_bucket_versioning into aws_s3_bucket) are folded into the parent’s carve set. They are not ranked on their own, and their edge to the parent is not counted as boundary work.

Point --from at a cdk.out directory and the same model ranks constructs. Three files carry everything the advisor reads: manifest.json (which artifacts are stacks), tree.json (the construct tree), and the *.template.json files themselves. Nothing reads your CDK source, so a Python or Java app is ranked through the same code path as a TypeScript one.

Constructs rank, not CloudFormation resources. An L2 construct emits several resources — a Function emits a role, a policy and the function — and they carve together or not at all. Every resource names its emitting construct in Metadata["aws:cdk:path"], and tree.json says which ancestor of that path is a construct someone actually wrote, so resources fold up to the shallowest L2 or L3 ancestor. That construct ranks once, and the members field names the resources folded into it. This is the CDK analogue of Terraform sub-resources folding into their parent.

An L3 subtree ranks as one Composite candidate. A pattern construct, or a grouping construct you wrote yourself, is a chant Composite in waiting. It is reported with kind: "module" — the same tier-2 treatment a Terraform module gets — instead of having its leaves scored separately.

Edges are exact, including across stacks. Ref, Fn::GetAtt and Fn::Sub are intra-stack. Fn::ImportValue is resolved through the exporting stack’s Outputs back to the construct that produces the value, so a cross-stack dependency is a real edge between two constructs and shows up from both ends: inbound on the exporter, outbound on the importer, both tagged crossStack: true. DependsOn counts too. A stack Output that reads a construct is the CloudFormation twin of a Terraform output block, and is weighted the same way — except when its export is imported somewhere, in which case the direct cross-stack edge stands for that dependency rather than charging it twice.

Three signals are CDK’s own, layered on the shared score:

SignalEffect
Asset-backed (aws:asset:* metadata)-10, reported as a penalties.asset term. Carving a Lambda whose code is a CDK asset moves the bundling too
Nested stack (AWS::CloudFormation::Stack)Score 0 with the reason. Its resources live in a template the advisor does not descend into, so carving it means carving that whole template
Dummy lookup valuesScore 0 for every construct, with the reason. An assembly synthesized with unresolved context lookups — or still holding a placeholder like vpc-12345678 — is not a faithful picture of any account, so no score it produces is worth reading

Synthesis scaffolding never appears: the CDKMetadata resource, the bootstrap version parameter and asset parameters describe the synthesis rather than the infrastructure.

--state is refused against an assembly rather than ignored — it is a Terraform option, and a synthesized template already carries its own instance counts. So are carve emit, carve bridge and carve apply: bridging a CDK carve means rewriting the surviving app’s source, which does not generalize across jsii languages.

$ chant carve advise --from ./cdk.out
CDK cloud-assembly carve-out advisory for ./cdk.out
6 construct(s) scored. Advises only — nothing is emitted or changed.
CLEAN LEAF — carve now (3)
88 DataStack/Assets -> AWS::S3::Bucket
1 inbound (a reimport each)
2 CloudFormation resources fold into this construct.
CARVABLE — has boundary work (2)
69 AppStack/Api
1 inbound (a reimport each), 1 stack output(s) reading it (one-line rewrite each), tier 2 map
L3 construct subtree — a Composite candidate (#1000). Its leaves rank here as one unit rather than separately.
3 CloudFormation resources fold into this construct.
67 AppStack/Handler -> AWS::Lambda::Function
2 outbound (deferred input each), tier 2 map, asset-backed
Asset-backed (Code) — carving it moves the CDK bundling too, not just the resource.
3 CloudFormation resources fold into this construct.
LEAVE IN CDK (1)
0 AppStack/Reports.NestedStack
not carvable as it stands
Nested stack — its resources live in a template this advisor does not descend into, so
carving it means carving that whole template. Advise the nested assembly on its own.

Run it against the sample estate bundled with the tests:

$ chant carve advise --from packages/core/src/terraform/__fixtures__/sample-estate
Terraform carve-out advisory for .../sample-estate
8 resource(s)/module(s) scored. Advises only — nothing is emitted or changed.
CLEAN LEAF — carve now (6)
100 aws_cloudwatch_log_group.api -> AWS::Logs::LogGroup
clean 1:1 native map, no boundary edges
96 aws_subnet.a -> AWS::EC2::Subnet
1 outbound (deferred input each)
88 aws_s3_bucket.assets -> AWS::S3::Bucket
1 inbound (data-source patch each)
81 aws_lambda_function.api -> AWS::Lambda::Function
1 outbound (deferred input each), tier 2 map
CARVABLE — has boundary work (1)
64 aws_vpc.main -> AWS::EC2::VPC
3 inbound (data-source patch each)
LEAVE IN TERRAFORM (1)
0 random_pet.suffix
no known native mapping (unsupported provider/type)
Bands: 80-100 carve now | 50-79 carve with boundary edits | 0-49 leave in Terraform

Read it top to bottom: the log group has no dependents and a clean native map, so it carves for free. The bucket is a clean leaf too, held back one notch by the one Lambda that reads it. The VPC is carvable but three subnets hang off it, so carving it means three data-source patches. random_pet has no native mapping at all, so it stays.

--json prints the report to stdout; --report <path> writes the same bytes to a file. Other tools read it — behold renders it as a graph — so it is a contract, and it says which version of that contract it is:

{
"version": 1,
"from": "./infra",
"source": "terraform",
"advisory": "read-only — emits nothing, patches nothing, touches no live resource",
"count": 8,
"bands": { "clean leaf": 6, "carvable w/ edits": 1, "leave in Terraform": 1 },
"resources": [
{
"address": "aws_vpc.main",
"kind": "resource",
"score": 64,
"band": "carvable w/ edits",
"mapsTo": "AWS::EC2::VPC",
"breakdown": {
"inbound": 3,
"outbound": 0,
"outputs": 0,
"tier": 1,
"hasDynamic": false,
"instances": 1,
"penalties": { "inbound": -36, "outbound": 0, "outputs": 0, "tier": 0, "dynamic": 0, "instances": 0 }
},
"boundary": {
"inbound": [
{
"direction": "inbound",
"survivor": "aws_subnet.a",
"carved": "aws_vpc.main",
"attrs": ["id"],
"via": ["vpc_id"],
"bridge": "tf-data-source",
"required": "immediately"
}
],
"outbound": []
}
}
]
}

breakdown.inbound and breakdown.outbound are the counts the score was computed from. boundary is the same information as edges: every dependency link carving that resource would cut, named at both ends.

  • survivor is the address that stays in Terraform, carved the address that leaves. attrs are the producer-side attributes referenced (id, arn); via is the referring block’s own attribute the reference sits in (vpc_id) — the name a deferred input’s build parameter takes on emit.
  • An inbound edge is bridged by a data source and is required: "immediately": the surviving plan breaks without it. An outbound edge becomes a deferred-input, required: "at-apply".
  • An inbound edge whose survivor is an output.<name> pseudo-address comes from an output block. It carries bridge: "tf-output-rewrite" and via: ["value"], and carve bridge repoints the output at the same data source. It is counted in breakdown.outputs, not breakdown.inbound, so boundary.inbound.length equals breakdown.inbound + breakdown.outputs.
  • Every cut appears twice across the report — once as an inbound edge on the depended-on resource, once as an outbound edge on the resource that depends on it. Each list answers “what would carving this resource cut”, so a reader collecting edges across resources should key them in dependency direction to fold the two views into one.
  • The lists are the carve set’s boundary, so a folded sub-resource is never an endpoint: it carves with its parent, and edges internal to the set are not boundary work.
  • boundary present with two empty lists means no boundary work. boundary absent means the report was produced by a chant that predates the field — “none” and “not reported” are different claims.

The shape is the same. source says "cdk", and:

  • address is a construct path (AppStack/Handler) rather than a Terraform address. It is still an opaque string identifier, but it does not split on . into a type the way a Terraform address does.
  • kind is still "resource" or "module". An L3 construct subtree is a module, which is what that value already means: a candidate for one chant Composite rather than several resources.
  • mapsTo is the CloudFormation type — the identity map, which is the point. A synthesized template is already in the type space chant’s AWS lexicon constructors take.
  • members names the CloudFormation resources folded into the construct (id, type, stack, path). Absent on the Terraform path, where a fold is expressed by the child’s absence from the ranking instead.
  • notes carries what the arithmetic cannot say: why a construct was disqualified, that it is asset-backed, that it is an L3 Composite candidate.
  • bridge takes two more values: cdk-import where Terraform says tf-data-source (the surviving app reimports what was carved), and cfn-output-rewrite where it says tf-output-rewrite. A boundary edge whose two ends are in different templates also carries crossStack: true.
  • diagnostics is a top-level list of anything about the read the scores alone do not say — a missing tree.json, an assembly synthesized from unresolved context lookups.
  • Band names do not change. A construct in the lowest band is still "leave in Terraform" in the JSON, because the band is part of this contract and readers key on it; only the heading a person reads says “LEAVE IN CDK”.

version is a single integer, currently 1.

  • Additive within a version. New top-level fields, new per-resource fields, new kinds of entry in existing lists, and new values in an open-ended enum (a bridge kind, say) may appear in any release. Read leniently: ignore keys and values you do not know.
  • Removing or renaming a field, or changing what one means, bumps the version. So does narrowing a type. (An optional field becoming always-present is additive; the reverse is not.)
  • A reader handed a version it does not know should refuse the report and say so, rather than half-reading it.

The CDK source stays inside that promise and does not bump the version. Every field an existing reader validates keeps its type and meaning; everything new is additive and ignorable. A reader that reproduces a score as 100 + sum(breakdown.penalties) still gets the right number, because the CDK asset term is one more entry in that same map.

The tier map is a curated seed covering the common tier-1 leaves people carve first. A type it does not know scores 0 (leave where it is), which is the safe default: the advisor never claims something is carvable when it cannot map it. Both sources read the same table — the Terraform advisor keys it by Terraform type, the CDK advisor by the CloudFormation type the same entry names — so adding a type lights it up for both at once.

Three Terraform providers contribute entries: aws_* maps to CloudFormation types, google_* to Config Connector types, and kubernetes_* to manifests. A fourth is a file under packages/core/src/terraform/providers/ and a line in that directory’s index.

Terraform state is read only for root-module resources; resources nested inside a module are not descended into. A CDK nested stack is not descended into either: it is reported as one disqualified construct with the reason.