@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,140 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { buildGraph } from "./graph";
3
+ import { scoreEstate, bandFor } from "./score";
4
+ import type { Hcl2JsonTree } from "./types";
5
+
6
+ const byAddress = (results: ReturnType<typeof scoreEstate>) =>
7
+ Object.fromEntries(results.map((r) => [r.address, r]));
8
+
9
+ describe("scoreEstate — #197 worked examples", () => {
10
+ test("clean leaf: aws_s3_bucket.assets ≈ 88 (versioning folded, 1 inbound from Lambda)", () => {
11
+ const tree: Hcl2JsonTree = {
12
+ resource: {
13
+ aws_s3_bucket: { assets: [{ bucket: "myapp-assets-prod" }] },
14
+ aws_s3_bucket_versioning: {
15
+ assets: [{ bucket: "${aws_s3_bucket.assets.id}", versioning_configuration: { status: "Enabled" } }],
16
+ },
17
+ aws_lambda_function: {
18
+ api: [
19
+ {
20
+ environment: {
21
+ variables: {
22
+ ASSETS_BUCKET: "${aws_s3_bucket.assets.bucket}",
23
+ ASSETS_ARN: "${aws_s3_bucket.assets.arn}",
24
+ },
25
+ },
26
+ },
27
+ ],
28
+ },
29
+ },
30
+ };
31
+ const results = scoreEstate(buildGraph(tree));
32
+ const map = byAddress(results);
33
+
34
+ // The folded sub-resource is not ranked on its own.
35
+ expect(map["aws_s3_bucket_versioning.assets"]).toBeUndefined();
36
+
37
+ const bucket = map["aws_s3_bucket.assets"];
38
+ expect(bucket.score).toBe(88); // 100 - 12*1(inbound) ; versioning edge discounted
39
+ expect(bucket.band).toBe("clean leaf");
40
+ expect(bucket.mapsTo).toBe("AWS::S3::Bucket");
41
+ expect(bucket.breakdown.inbound).toBe(1);
42
+ });
43
+
44
+ test("carvable w/ edits: module.cdn ≈ 61 (2 inbound, tier-2 composite map)", () => {
45
+ const tree: Hcl2JsonTree = {
46
+ module: { cdn: [{ source: "./cdn" }] },
47
+ resource: {
48
+ aws_route53_record: {
49
+ www: [{ name: "${module.cdn.domain}" }],
50
+ apex: [{ name: "${module.cdn.domain}" }],
51
+ },
52
+ },
53
+ };
54
+ const results = scoreEstate(buildGraph(tree));
55
+ const cdn = byAddress(results)["module.cdn"];
56
+ expect(cdn.score).toBe(61); // 100 - 12*2 - 15*(2-1)
57
+ expect(cdn.band).toBe("carvable w/ edits");
58
+ expect(cdn.breakdown.inbound).toBe(2);
59
+ expect(cdn.breakdown.tier).toBe(2);
60
+ });
61
+
62
+ test("leave in Terraform: aws_vpc.main with 40 inbound → clamped to 0", () => {
63
+ const consumers: Record<string, unknown[]> = {};
64
+ for (let i = 0; i < 40; i++) {
65
+ consumers[`s${i}`] = [{ vpc_id: "${aws_vpc.main.id}" }];
66
+ }
67
+ const tree: Hcl2JsonTree = {
68
+ resource: {
69
+ aws_vpc: { main: [{ cidr_block: "10.0.0.0/16" }] },
70
+ aws_subnet: consumers,
71
+ },
72
+ };
73
+ const vpc = byAddress(scoreEstate(buildGraph(tree)))["aws_vpc.main"];
74
+ expect(vpc.score).toBe(0);
75
+ expect(vpc.band).toBe("leave in Terraform");
76
+ expect(vpc.breakdown.inbound).toBe(40);
77
+ });
78
+ });
79
+
80
+ describe("scoreEstate — penalties", () => {
81
+ test("unsupported provider/type scores 0 regardless of edges", () => {
82
+ const tree: Hcl2JsonTree = {
83
+ resource: { random_pet: { name: [{ length: 2 }] } },
84
+ };
85
+ const r = scoreEstate(buildGraph(tree))[0];
86
+ expect(r.score).toBe(0);
87
+ expect(r.breakdown.tier).toBeNull();
88
+ expect(r.mapsTo).toBeUndefined();
89
+ });
90
+
91
+ test("count/for_each applies the dynamic penalty", () => {
92
+ const tree: Hcl2JsonTree = {
93
+ resource: {
94
+ aws_sqs_queue: {
95
+ plain: [{ name: "q" }],
96
+ fanned: [{ count: 3, name: "q" }],
97
+ },
98
+ },
99
+ };
100
+ const map = byAddress(scoreEstate(buildGraph(tree)));
101
+ expect(map["aws_sqs_queue.plain"].score).toBe(100); // tier1, no edges, no dynamic
102
+ expect(map["aws_sqs_queue.fanned"].score).toBe(90); // -10 dynamic
103
+ });
104
+
105
+ test("outbound dependency costs less than inbound", () => {
106
+ // A tier-1 leaf that depends on one survivor: -4 outbound.
107
+ const tree: Hcl2JsonTree = {
108
+ resource: {
109
+ aws_sns_topic: { alerts: [{ name: "a" }] },
110
+ aws_sqs_queue: { dlq: [{ name: "d", redrive: "${aws_sns_topic.alerts.arn}" }] },
111
+ },
112
+ };
113
+ const map = byAddress(scoreEstate(buildGraph(tree)));
114
+ expect(map["aws_sqs_queue.dlq"].score).toBe(96); // 100 - 4*1 outbound
115
+ expect(map["aws_sns_topic.alerts"].score).toBe(88); // 100 - 12*1 inbound
116
+ });
117
+
118
+ test("results are ranked most-peelable first", () => {
119
+ const tree: Hcl2JsonTree = {
120
+ resource: {
121
+ aws_vpc: { main: [{ cidr_block: "10.0.0.0/16" }] },
122
+ aws_sqs_queue: { q: [{ name: "q", vpc: "${aws_vpc.main.id}" }] },
123
+ aws_sns_topic: { t: [{ name: "t" }] },
124
+ },
125
+ };
126
+ const scores = scoreEstate(buildGraph(tree)).map((r) => r.score);
127
+ expect(scores).toEqual([...scores].sort((a, b) => b - a));
128
+ });
129
+ });
130
+
131
+ describe("bandFor", () => {
132
+ test("band boundaries", () => {
133
+ expect(bandFor(100)).toBe("clean leaf");
134
+ expect(bandFor(80)).toBe("clean leaf");
135
+ expect(bandFor(79)).toBe("carvable w/ edits");
136
+ expect(bandFor(50)).toBe("carvable w/ edits");
137
+ expect(bandFor(49)).toBe("leave in Terraform");
138
+ expect(bandFor(0)).toBe("leave in Terraform");
139
+ });
140
+ });
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Peelability scoring for the carve-out advisor (#214 T3).
3
+ *
4
+ * The model, verbatim from #197:
5
+ *
6
+ * score = 100
7
+ * - 12 * inbound # survivors that depend on this → each a TF data-source patch
8
+ * - 4 * outbound # this depends on survivors → a deferred deploy-time input
9
+ * - 15 * (tier - 1) # native-spec map: tier1=0, tier2=-15, tier3=-30
10
+ * - 10 * has_dynamic # count / for_each / data present
11
+ * - 3 * (instances - 1) # state-expanded instance count
12
+ * clamp 0..100 (unsupported provider/type → 0)
13
+ *
14
+ * Sub-resources that inline into a parent (see `FOLDS_INTO`) are folded into the
15
+ * parent's carve set: they are removed from the ranking and their edge to the
16
+ * parent is not counted as inbound — inlining them is free, not boundary work.
17
+ */
18
+
19
+ import { inboundEdges, outboundEdges } from "./graph";
20
+ import { FOLDS_INTO, resolveTier } from "./tier-map";
21
+ import type { TfGraph, TfNode } from "./types";
22
+
23
+ export type PeelabilityBand = "clean leaf" | "carvable w/ edits" | "leave in Terraform";
24
+
25
+ export interface PeelabilityBreakdown {
26
+ inbound: number;
27
+ outbound: number;
28
+ tier: 1 | 2 | 3 | null;
29
+ hasDynamic: boolean;
30
+ instances: number;
31
+ /** Signed penalty contributions, for report transparency. */
32
+ penalties: {
33
+ inbound: number;
34
+ outbound: number;
35
+ tier: number;
36
+ dynamic: number;
37
+ instances: number;
38
+ };
39
+ }
40
+
41
+ export interface Peelability {
42
+ address: string;
43
+ kind: "resource" | "module";
44
+ score: number;
45
+ band: PeelabilityBand;
46
+ /** Native spec type a carve would target; undefined for modules/unsupported. */
47
+ mapsTo?: string;
48
+ breakdown: PeelabilityBreakdown;
49
+ }
50
+
51
+ const clamp = (n: number): number => Math.max(0, Math.min(100, n));
52
+
53
+ export function bandFor(score: number): PeelabilityBand {
54
+ if (score >= 80) return "clean leaf";
55
+ if (score >= 50) return "carvable w/ edits";
56
+ return "leave in Terraform";
57
+ }
58
+
59
+ /** Module tier: a module maps to a chant composite — reshaping, so tier 2 by default. */
60
+ const MODULE_TIER = 2 as const;
61
+
62
+ /**
63
+ * Build the set of addresses that fold into a parent present in the estate, plus
64
+ * the reverse lookup (parent → its folded children), so a parent can discount
65
+ * inbound edges coming from its own folded sub-resources.
66
+ */
67
+ function computeFolds(graph: TfGraph): { folded: Set<string>; childrenOf: Map<string, Set<string>> } {
68
+ const byAddress = new Map(graph.nodes.map((n) => [n.address, n]));
69
+ const folded = new Set<string>();
70
+ const childrenOf = new Map<string, Set<string>>();
71
+ for (const node of graph.nodes) {
72
+ if (node.kind !== "resource" || !node.type) continue;
73
+ const parentType = FOLDS_INTO[node.type];
74
+ if (!parentType) continue;
75
+ const parentAddress = `${parentType}.${node.name}`;
76
+ if (!byAddress.has(parentAddress)) continue; // sub-resource without its parent → score it on its own
77
+ folded.add(node.address);
78
+ if (!childrenOf.has(parentAddress)) childrenOf.set(parentAddress, new Set());
79
+ childrenOf.get(parentAddress)!.add(node.address);
80
+ }
81
+ return { folded, childrenOf };
82
+ }
83
+
84
+ function scoreNode(node: TfNode, graph: TfGraph, foldedChildren: Set<string>): Peelability {
85
+ // Inbound excludes edges from this node's own folded sub-resources.
86
+ const inbound = inboundEdges(graph, node.address).filter((e) => !foldedChildren.has(e.from)).length;
87
+ const outbound = outboundEdges(graph, node.address).length;
88
+
89
+ const tierInfo = node.kind === "module" ? { tier: MODULE_TIER, mapsTo: undefined } : resolveTier(node.type!);
90
+ const tier = tierInfo ? tierInfo.tier : null;
91
+
92
+ // Unsupported provider/type → 0, no partial credit.
93
+ if (tier === null) {
94
+ return {
95
+ address: node.address,
96
+ kind: node.kind,
97
+ score: 0,
98
+ band: "leave in Terraform",
99
+ breakdown: {
100
+ inbound,
101
+ outbound,
102
+ tier: null,
103
+ hasDynamic: node.hasDynamic,
104
+ instances: node.instances,
105
+ penalties: { inbound: 0, outbound: 0, tier: 0, dynamic: 0, instances: 0 },
106
+ },
107
+ };
108
+ }
109
+
110
+ const penalties = {
111
+ inbound: -12 * inbound,
112
+ outbound: -4 * outbound,
113
+ tier: -15 * (tier - 1),
114
+ dynamic: node.hasDynamic ? -10 : 0,
115
+ instances: -3 * Math.max(0, node.instances - 1),
116
+ };
117
+ const score = clamp(
118
+ 100 + penalties.inbound + penalties.outbound + penalties.tier + penalties.dynamic + penalties.instances,
119
+ );
120
+
121
+ return {
122
+ address: node.address,
123
+ kind: node.kind,
124
+ score,
125
+ band: bandFor(score),
126
+ mapsTo: tierInfo && "mapsTo" in tierInfo ? tierInfo.mapsTo : undefined,
127
+ breakdown: {
128
+ inbound,
129
+ outbound,
130
+ tier,
131
+ hasDynamic: node.hasDynamic,
132
+ instances: node.instances,
133
+ penalties,
134
+ },
135
+ };
136
+ }
137
+
138
+ /**
139
+ * Score every carvable node in the estate, ranked most-peelable first. Folded
140
+ * sub-resources are omitted (they carve with their parent, not on their own).
141
+ */
142
+ export function scoreEstate(graph: TfGraph): Peelability[] {
143
+ const { folded, childrenOf } = computeFolds(graph);
144
+ return graph.nodes
145
+ .filter((n) => !folded.has(n.address))
146
+ .map((n) => scoreNode(n, graph, childrenOf.get(n.address) ?? new Set()))
147
+ .sort((a, b) => b.score - a.score || (a.address < b.address ? -1 : a.address > b.address ? 1 : 0));
148
+ }
@@ -0,0 +1,76 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { mkdtempSync, writeFileSync, rmSync } from "fs";
3
+ import { tmpdir } from "os";
4
+ import { join } from "path";
5
+ import { readStateInstanceCounts, applyStateCounts } from "./state";
6
+ import { buildGraph } from "./graph";
7
+ import { scoreEstate } from "./score";
8
+ import type { Hcl2JsonTree } from "./types";
9
+
10
+ function withState<T>(state: unknown, fn: (path: string) => T): T {
11
+ const dir = mkdtempSync(join(tmpdir(), "chant-tfstate-"));
12
+ const path = join(dir, "terraform.tfstate");
13
+ try {
14
+ writeFileSync(path, JSON.stringify(state));
15
+ return fn(path);
16
+ } finally {
17
+ rmSync(dir, { recursive: true, force: true });
18
+ }
19
+ }
20
+
21
+ const STATE = {
22
+ version: 4,
23
+ resources: [
24
+ { mode: "managed", type: "aws_instance", name: "web", instances: [{}, {}, {}] },
25
+ { mode: "managed", type: "aws_sqs_queue", name: "solo", instances: [{}] },
26
+ // data source — ignored
27
+ { mode: "data", type: "aws_ami", name: "ubuntu", instances: [{}] },
28
+ // nested in a module — no top-level node, ignored
29
+ { module: "module.cdn", mode: "managed", type: "aws_s3_bucket", name: "logs", instances: [{}, {}] },
30
+ ],
31
+ };
32
+
33
+ describe("readStateInstanceCounts", () => {
34
+ test("counts root-module managed instances, skips data + module-nested", () => {
35
+ withState(STATE, (path) => {
36
+ const counts = readStateInstanceCounts(path);
37
+ expect(counts.get("aws_instance.web")).toBe(3);
38
+ expect(counts.get("aws_sqs_queue.solo")).toBe(1);
39
+ expect(counts.has("aws_ami.ubuntu")).toBe(false); // data source
40
+ expect(counts.has("aws_s3_bucket.logs")).toBe(false); // module-nested
41
+ });
42
+ });
43
+ });
44
+
45
+ describe("applyStateCounts", () => {
46
+ test("overlays counts; nodes absent from state keep 1", () => {
47
+ const graph = buildGraph({
48
+ resource: {
49
+ aws_instance: { web: [{ count: 3, ami: "x" }] },
50
+ aws_sqs_queue: { orphan: [{ name: "q" }] },
51
+ },
52
+ } as Hcl2JsonTree);
53
+ applyStateCounts(graph, new Map([["aws_instance.web", 3]]));
54
+ const byAddr = Object.fromEntries(graph.nodes.map((n) => [n.address, n]));
55
+ expect(byAddr["aws_instance.web"].instances).toBe(3);
56
+ expect(byAddr["aws_sqs_queue.orphan"].instances).toBe(1);
57
+ });
58
+
59
+ test("state-accurate fan-out lowers peelability via the instances penalty", () => {
60
+ const tree: Hcl2JsonTree = {
61
+ resource: { aws_sqs_queue: { fan: [{ count: 5, name: "q" }] } },
62
+ };
63
+ // Without state: instances=1, only the -10 dynamic penalty → 90.
64
+ const noState = scoreEstate(buildGraph(tree))[0];
65
+ expect(noState.score).toBe(90);
66
+ expect(noState.breakdown.instances).toBe(1);
67
+
68
+ // With state: 5 instances → -10 dynamic -3*(5-1)=-12 → 78, drops out of "clean leaf".
69
+ const withCounts = buildGraph(tree);
70
+ applyStateCounts(withCounts, new Map([["aws_sqs_queue.fan", 5]]));
71
+ const stated = scoreEstate(withCounts)[0];
72
+ expect(stated.breakdown.instances).toBe(5);
73
+ expect(stated.score).toBe(78);
74
+ expect(stated.band).toBe("carvable w/ edits");
75
+ });
76
+ });
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Opt-in `.tfstate` reader for the carve-out advisor (#214 T2).
3
+ *
4
+ * A `.tf`-only parse cannot know how many instances a `count`/`for_each` block
5
+ * expands to — it reports 1 and flags the node dynamic. State is where those
6
+ * are unrolled: each managed resource carries an `instances` array with one
7
+ * entry per expansion. Feeding state in makes the `-3*(instances-1)` penalty
8
+ * accurate, so a 200-instance fan-out ranks as the boundary work it really is.
9
+ *
10
+ * This is deliberately separate and opt-in (`--state <path>`): a pure-`.tf`
11
+ * parse is a plain file read, while reading state pulls in a state backend's
12
+ * output. JSON only — no HCL, no wasm.
13
+ */
14
+
15
+ import { readFileSync } from "fs";
16
+ import type { TfGraph } from "./types";
17
+
18
+ /** The subset of tfstate v4 the advisor reads. */
19
+ interface TfStateResource {
20
+ module?: string;
21
+ mode?: "managed" | "data";
22
+ type: string;
23
+ name: string;
24
+ instances?: Array<{ attributes?: Record<string, unknown> }>;
25
+ }
26
+ interface TfState {
27
+ version?: number;
28
+ resources?: TfStateResource[];
29
+ }
30
+
31
+ /** A resource's resolved attributes, read from state for adoption. */
32
+ export interface StateResource {
33
+ type: string;
34
+ name: string;
35
+ /** The first instance's resolved attributes (id, arn, tags, ...). */
36
+ attributes: Record<string, unknown>;
37
+ }
38
+
39
+ /**
40
+ * Read one root-module managed resource's resolved attributes from state, by
41
+ * Terraform address (`aws_s3_bucket.assets`). This is the true source of a
42
+ * Terraform-managed resource's live shape — unlike a CloudFormation export, it
43
+ * exists whether or not the resource was ever in a CFN stack. Returns null if
44
+ * the address is absent, is a data source, or is module-nested.
45
+ */
46
+ export function readStateResource(statePath: string, address: string): StateResource | null {
47
+ const state = JSON.parse(readFileSync(statePath, "utf-8")) as TfState;
48
+ for (const res of state.resources ?? []) {
49
+ if (res.mode === "data" || res.module) continue;
50
+ if (`${res.type}.${res.name}` !== address) continue;
51
+ const attributes = res.instances?.[0]?.attributes;
52
+ if (!attributes) return null;
53
+ return { type: res.type, name: res.name, attributes };
54
+ }
55
+ return null;
56
+ }
57
+
58
+ /**
59
+ * Map each root-module managed resource address to its state-expanded instance
60
+ * count. Resources nested in a `module` are skipped: the `.tf` graph models a
61
+ * module as a single node, so there is no per-resource node to attach the count
62
+ * to (a documented v1 limitation — module internals are not descended into).
63
+ */
64
+ export function readStateInstanceCounts(statePath: string): Map<string, number> {
65
+ const raw = readFileSync(statePath, "utf-8");
66
+ const state = JSON.parse(raw) as TfState;
67
+ const counts = new Map<string, number>();
68
+ for (const res of state.resources ?? []) {
69
+ if (res.mode === "data") continue; // data sources are not carvable nodes
70
+ if (res.module) continue; // nested in a module → no top-level node to map
71
+ const address = `${res.type}.${res.name}`;
72
+ counts.set(address, Array.isArray(res.instances) ? res.instances.length : 1);
73
+ }
74
+ return counts;
75
+ }
76
+
77
+ /**
78
+ * Overlay state instance counts onto a graph in place. Nodes absent from state
79
+ * keep their `.tf`-derived count of 1. Returns the same graph for chaining.
80
+ */
81
+ export function applyStateCounts(graph: TfGraph, counts: Map<string, number>): TfGraph {
82
+ for (const node of graph.nodes) {
83
+ const n = counts.get(node.address);
84
+ if (n !== undefined) node.instances = Math.max(1, n);
85
+ }
86
+ return graph;
87
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Terraform-type → native-spec tier map for the carve-out advisor (#214 T3).
3
+ *
4
+ * The AWS entries are derived from the single AWS carve-out table
5
+ * (`aws-resources.ts`), so the advisor ranks exactly the AWS types `carve emit`
6
+ * can produce — no advise↔emit cliff. Non-AWS entries (e.g. `kubernetes_manifest`)
7
+ * are listed here directly; emit for those is a separate target.
8
+ *
9
+ * tier 1 — a clean 1:1 native resource
10
+ * tier 2 — maps, but with reshaping
11
+ * tier 3 — a hard/partial map
12
+ * null — no known native mapping (unsupported provider/type) → score 0
13
+ */
14
+
15
+ import { AWS_CARVE_TYPES } from "./aws-resources";
16
+
17
+ export interface TierInfo {
18
+ tier: 1 | 2 | 3;
19
+ /** The native spec type a carve would target, for the report. */
20
+ mapsTo: string;
21
+ }
22
+
23
+ /** TF resource type → native tier. Absent = unsupported (score 0). */
24
+ export const TIER_MAP: Record<string, TierInfo> = {
25
+ // AWS: derived from the carve-out table so advise and emit stay in lockstep.
26
+ ...Object.fromEntries(AWS_CARVE_TYPES.map((t) => [t.tfType, { tier: t.tier, mapsTo: t.nativeType }])),
27
+ // Kubernetes — near-1:1 manifest. Ranked by advise; emit support is a separate target.
28
+ kubernetes_manifest: { tier: 1, mapsTo: "k8s:manifest" },
29
+ };
30
+
31
+ /**
32
+ * TF "sub-resource" types that inline into a parent resource rather than
33
+ * standing alone (TF splits config the native spec keeps in one resource).
34
+ * A sub-resource that shares its parent's name is folded into the parent's
35
+ * carve set: it is not ranked on its own, and its edge to the parent does not
36
+ * count as inbound boundary work — inlining it is free.
37
+ *
38
+ * Maps sub-resource TF type → parent TF type.
39
+ */
40
+ export const FOLDS_INTO: Record<string, string> = {
41
+ aws_s3_bucket_versioning: "aws_s3_bucket",
42
+ aws_s3_bucket_acl: "aws_s3_bucket",
43
+ aws_s3_bucket_policy: "aws_s3_bucket",
44
+ aws_s3_bucket_public_access_block: "aws_s3_bucket",
45
+ aws_s3_bucket_server_side_encryption_configuration: "aws_s3_bucket",
46
+ aws_s3_bucket_lifecycle_configuration: "aws_s3_bucket",
47
+ };
48
+
49
+ /**
50
+ * The HCL attribute carrying a resource's physical name, per type — derived
51
+ * from the AWS carve-out table. Used for the graph's identity and the
52
+ * live-import hint. Absent → fall back to the TF logical name.
53
+ */
54
+ export const IDENTITY_ATTR: Record<string, string> = Object.fromEntries(
55
+ AWS_CARVE_TYPES.filter((t) => t.identityAttr).map((t) => [t.tfType, t.identityAttr!]),
56
+ );
57
+
58
+ export function resolveTier(tfType: string): TierInfo | null {
59
+ return TIER_MAP[tfType] ?? null;
60
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Types for the read-only Terraform carve-out advisor (#214).
3
+ *
4
+ * The advisor never emits, patches, or mutates state. It parses a Terraform
5
+ * estate into a dependency graph and scores each resource/module by
6
+ * "peelability" — how cleanly it could later be carved into native chant
7
+ * source (the emit/boundary/apply phases stay in #197, gated on demand).
8
+ *
9
+ * The graph layer here is intentionally pure: it consumes the JSON shape
10
+ * `@cdktf/hcl2json` produces (see `parse.ts`), so the graph and scoring can
11
+ * be tested without loading the wasm parser.
12
+ */
13
+
14
+ /** A node in the Terraform dependency graph — one resource or module block. */
15
+ export interface TfNode {
16
+ /** Terraform address, e.g. `aws_s3_bucket.assets` or `module.cdn`. */
17
+ address: string;
18
+ kind: "resource" | "module";
19
+ /** Resource type (`aws_s3_bucket`); undefined for modules. */
20
+ type?: string;
21
+ /** Local name (`assets`) or module name (`cdn`). */
22
+ name: string;
23
+ /**
24
+ * State-expanded instance count. 1 unless `count`/`for_each` is present and
25
+ * a `.tfstate` was read (see `state.ts`, #214 T2). Without state, a block
26
+ * with `count`/`for_each` reports 1 and sets `hasDynamic`.
27
+ */
28
+ instances: number;
29
+ /** `count`, `for_each`, a `data` source, or an unresolved interpolation is present. */
30
+ hasDynamic: boolean;
31
+ /**
32
+ * The resource's physical name, when it is a plain literal in the HCL (e.g.
33
+ * `bucket = "myapp-assets-prod"`). Used to build the live-import selector on
34
+ * emit. Undefined when the identity attribute is absent or interpolated.
35
+ */
36
+ identity?: string;
37
+ }
38
+
39
+ /**
40
+ * A directed dependency edge: `from` references `to`.
41
+ *
42
+ * For a candidate carve target T:
43
+ * - inbound = edges where `to === T` (surviving resources depend on T → each is a data-source patch)
44
+ * - outbound = edges where `from === T` (T depends on survivors → a deferred deploy-time input)
45
+ */
46
+ export interface TfEdge {
47
+ from: string;
48
+ to: string;
49
+ /** The attribute path(s) referenced, e.g. `["id", "arn"]`. */
50
+ attrs: string[];
51
+ }
52
+
53
+ export interface TfGraph {
54
+ nodes: TfNode[];
55
+ edges: TfEdge[];
56
+ }
57
+
58
+ /**
59
+ * The subset of `@cdktf/hcl2json`'s `parse()` output the advisor reads. A
60
+ * merged tree across every `.tf` file in the estate. Values are left as the
61
+ * raw hcl2json encoding (interpolations survive as `"${...}"` strings), which
62
+ * is what edge extraction scans.
63
+ */
64
+ export interface Hcl2JsonTree {
65
+ resource?: Record<string, Record<string, unknown[]>>;
66
+ module?: Record<string, unknown[]>;
67
+ data?: Record<string, Record<string, unknown[]>>;
68
+ [k: string]: unknown;
69
+ }