@intentius/chant 0.18.37 → 0.19.0

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 +74 -0
  64. package/src/terraform/aws-resources.ts +154 -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,34 @@
1
+ import { carveApply, formatCarveApply } from "../commands/carve-apply";
2
+ import { formatError } from "../format";
3
+ import type { CommandContext } from "../registry";
4
+
5
+ /**
6
+ * `chant carve apply --from <tf-dir> --select <address> --env <env> [--stack <name>] [--write]`
7
+ *
8
+ * Resolves the ownership marker + graduation runbook. BYOL-honest: no cloud
9
+ * calls. `--write` saves the graduation doc. No lexicon plugins needed.
10
+ */
11
+ export async function runCarveApply(ctx: CommandContext): Promise<number> {
12
+ const { args } = ctx;
13
+
14
+ const result = await carveApply({
15
+ from: args.migrateFrom, // `--from <terraform-dir>`
16
+ select: args.selectAddress, // `--select <tf-address>`
17
+ env: args.env,
18
+ stack: args.carveStack,
19
+ statePath: args.statePath,
20
+ output: args.output,
21
+ write: args.write,
22
+ });
23
+
24
+ if (!result.ok) {
25
+ console.error(formatError({
26
+ message: result.error ?? "carve apply failed",
27
+ hint: "chant carve apply --from ./tf --select aws_s3_bucket.assets --env prod",
28
+ }));
29
+ return 1;
30
+ }
31
+
32
+ console.log(formatCarveApply(result));
33
+ return 0;
34
+ }
@@ -0,0 +1,33 @@
1
+ import { carveBridge, formatCarveBridge } from "../commands/carve-bridge";
2
+ import { formatError } from "../format";
3
+ import type { CommandContext } from "../registry";
4
+
5
+ /**
6
+ * `chant carve bridge --from <tf-dir> --select <address> [--output <dir>] [--apply-rewrites]`
7
+ *
8
+ * Read-only by default: writes proposed survivor patches + a runbook to an
9
+ * output dir. `--apply-rewrites` edits the surviving `.tf` in place. Loads no
10
+ * lexicon plugins — pure Terraform-side analysis.
11
+ */
12
+ export async function runCarveBridge(ctx: CommandContext): Promise<number> {
13
+ const { args } = ctx;
14
+
15
+ const result = await carveBridge({
16
+ from: args.migrateFrom, // `--from <terraform-dir>`
17
+ select: args.selectAddress, // `--select <tf-address>`
18
+ statePath: args.statePath,
19
+ output: args.output,
20
+ applyRewrites: args.applyRewrites,
21
+ });
22
+
23
+ if (!result.ok) {
24
+ console.error(formatError({
25
+ message: result.error ?? "carve bridge failed",
26
+ hint: "chant carve bridge --from ./tf --select aws_s3_bucket.assets",
27
+ }));
28
+ return 1;
29
+ }
30
+
31
+ console.log(formatCarveBridge(result));
32
+ return 0;
33
+ }
@@ -0,0 +1,51 @@
1
+ import { carveEmit, formatCarveEmit } from "../commands/carve-emit";
2
+ import { liveImportFromPlugins } from "../commands/import";
3
+ import { loadPlugins } from "../plugins";
4
+ import { formatError } from "../format";
5
+ import type { CommandContext } from "../registry";
6
+
7
+ /**
8
+ * `chant carve emit --from <tf-dir> --select <address> (--state <tfstate> | --env <env>)`
9
+ *
10
+ * Adopts the selected Terraform resource into typed chant source. `--state`
11
+ * adopts offline from the tfstate (no plugins); `--env` adopts via the live
12
+ * cloud import path, for which the target lexicon is loaded lazily here — so a
13
+ * state-only carve never pays the plugin-load cost.
14
+ */
15
+ export async function runCarveEmit(ctx: CommandContext): Promise<number> {
16
+ const { args } = ctx;
17
+
18
+ // Load plugins only for the live path; the offline --state path needs none.
19
+ let plugins = ctx.plugins;
20
+ if (!args.statePath && args.env) {
21
+ try {
22
+ plugins = await loadPlugins(["aws"]);
23
+ } catch {
24
+ // fall through with whatever ctx provided; carveEmit surfaces a clear error
25
+ }
26
+ }
27
+
28
+ const result = await carveEmit(
29
+ {
30
+ from: args.migrateFrom, // `--from <terraform-dir>`
31
+ select: args.selectAddress, // `--select <tf-address>`
32
+ env: args.env,
33
+ liveName: args.liveName, // `--live-name <cfn-logical-id>`
34
+ statePath: args.statePath,
35
+ output: args.output,
36
+ reportFile: args.reportFile,
37
+ },
38
+ { plugins, liveImport: liveImportFromPlugins },
39
+ );
40
+
41
+ if (!result.ok) {
42
+ console.error(formatError({
43
+ message: result.error ?? "carve emit failed",
44
+ hint: "chant carve emit --from ./tf --select aws_s3_bucket.assets --env prod",
45
+ }));
46
+ return 1;
47
+ }
48
+
49
+ console.log(formatCarveEmit(result));
50
+ return 0;
51
+ }
@@ -0,0 +1,44 @@
1
+ import { carveAdvise, carveJson, formatCarveReport } from "../commands/carve";
2
+ import { formatError } from "../format";
3
+ import type { CommandContext } from "../registry";
4
+
5
+ /**
6
+ * `chant carve advise --from <terraform-dir> [--json] [--report <path>]`
7
+ *
8
+ * Read-only. Reuses `--from` (shared with migrate/import) for the estate dir.
9
+ * Loads no chant project and no lexicon plugins — pure analysis of a foreign
10
+ * Terraform tree.
11
+ */
12
+ export async function runCarveAdvise(ctx: CommandContext): Promise<number> {
13
+ const { args } = ctx;
14
+
15
+ const result = await carveAdvise({
16
+ from: args.migrateFrom, // `--from <terraform-dir>`
17
+ statePath: args.statePath, // `--state <tfstate>`
18
+ reportFile: args.reportFile, // `--report <path>`
19
+ });
20
+
21
+ if (!result.ok) {
22
+ console.error(formatError({
23
+ message: result.error ?? "carve advise failed",
24
+ hint: "chant carve advise --from ./path/to/terraform",
25
+ }));
26
+ return 1;
27
+ }
28
+
29
+ if (args.json) {
30
+ console.log(JSON.stringify(carveJson(result), null, 2));
31
+ } else {
32
+ console.log(formatCarveReport(result));
33
+ }
34
+ return 0;
35
+ }
36
+
37
+ /** Fallback for `chant carve` with no/unknown subcommand. */
38
+ export async function runCarveUnknown(_ctx: CommandContext): Promise<number> {
39
+ console.error(formatError({
40
+ message: "Unknown carve subcommand",
41
+ hint: "chant carve advise --from <terraform-dir> (read-only peelability report)",
42
+ }));
43
+ return 1;
44
+ }
package/src/cli/main.ts CHANGED
@@ -16,6 +16,10 @@ import { runInit, runInitLexicon } from "./handlers/init";
16
16
  import { runList, runDescribe, runImport, runAudit, runUpdate, runDoctor } from "./handlers/misc";
17
17
  import { runVendor } from "./handlers/vendor";
18
18
  import { runMigrate } from "./handlers/migrate";
19
+ import { runCarveAdvise, runCarveUnknown } from "./handlers/carve";
20
+ import { runCarveEmit } from "./handlers/carve-emit";
21
+ import { runCarveBridge } from "./handlers/carve-bridge";
22
+ import { runCarveApply } from "./handlers/carve-apply";
19
23
  import { runLifecycleSnapshot, runLifecycleShow, runLifecycleDiff, runLifecycleRollback, runLifecyclePlan, runLifecycleAffected, runLifecycleLog, runLifecycleUnknown } from "./handlers/lifecycle";
20
24
  import { runComponentsStatus, runComponentsReleaseRecord, runComponentsUnknown } from "./handlers/components";
21
25
  import { runGraph } from "./handlers/graph";
@@ -116,6 +120,18 @@ export function parseArgs(args: string[]): ParsedArgs {
116
120
  result.owned = true;
117
121
  } else if (arg === "--verbatim") {
118
122
  result.verbatim = true;
123
+ } else if (arg === "--state") {
124
+ result.statePath = args[++i];
125
+ } else if (arg === "--select") {
126
+ result.selectAddress = args[++i];
127
+ } else if (arg === "--live-name") {
128
+ result.liveName = args[++i];
129
+ } else if (arg === "--apply-rewrites") {
130
+ result.applyRewrites = true;
131
+ } else if (arg === "--stack") {
132
+ result.carveStack = args[++i];
133
+ } else if (arg === "--write") {
134
+ result.write = true;
119
135
  } else if (arg === "--to") {
120
136
  result.migrateTo = args[++i];
121
137
  } else if (arg === "--emit") {
@@ -245,6 +261,23 @@ Commands:
245
261
  --template <file> / --theme <file> for the html report)
246
262
  migrate <file> Translate a workflow between lexicons
247
263
  (default: --from github --to gitlab)
264
+ carve advise Read-only Terraform peelability advisor: rank which
265
+ --from <tf-dir> resources are cheap to carve into native chant
266
+ (--json, --report <path>). Emits nothing, changes nothing.
267
+ Needs @cdktf/hcl2json (npm install -D @cdktf/hcl2json).
268
+ carve emit Adopt a selected TF resource into chant source + report
269
+ --from <tf-dir> its boundary. --state <tfstate> adopts offline
270
+ --select <addr> (recommended for TF-managed resources); --env
271
+ --state|--env <env> adopts via live cloud import (--live-name
272
+ <logical-id> narrows a multi-resource stack).
273
+ carve bridge Generate the surviving-TF patch (data sources + rewired
274
+ --from <tf-dir> refs) + deferred inputs + reversible runbook.
275
+ --select <addr> Writes proposals for review; --apply-rewrites
276
+ edits the .tf in place.
277
+ carve apply Apply graduation: ownership marker + finalized apply
278
+ --from <tf-dir> runbook (dial-turn observe→apply). BYOL —
279
+ --select <addr> no cloud call; --write saves the doc.
280
+ --env <env>
248
281
 
249
282
  Ops:
250
283
  run <name> Start an Op workflow (spawns worker + submits to Temporal)
@@ -428,6 +461,20 @@ const registry: CommandDef[] = [
428
461
  { name: "import", handler: runImport },
429
462
  { name: "audit", handler: runAudit },
430
463
  { name: "migrate", handler: runMigrate },
464
+ // Read-only Terraform peelability advisor (#214). Compound so "advise" lands
465
+ // in args.path; the estate dir comes from --from. No plugins, no project.
466
+ { name: "carve advise", handler: runCarveAdvise },
467
+ // Emit step (#197): adopt a selected TF resource into chant source. The
468
+ // --state path is offline (no plugins); the --env live path loads the target
469
+ // lexicon lazily in the handler, so this command does not require plugins.
470
+ { name: "carve emit", handler: runCarveEmit },
471
+ // Boundary bridging (#197): patch the surviving TF (data sources + rewired
472
+ // refs) + runbook. No plugins; Terraform-side only. Read-only unless
473
+ // --apply-rewrites.
474
+ { name: "carve bridge", handler: runCarveBridge },
475
+ // Apply graduation (#197): ownership marker + finalized apply runbook.
476
+ // BYOL-honest — no cloud call; --write saves the graduation doc.
477
+ { name: "carve apply", handler: runCarveApply },
431
478
  { name: "init", handler: runInit },
432
479
  { name: "init lexicon", handler: runInitLexicon },
433
480
  { name: "update", handler: runUpdate },
@@ -477,6 +524,7 @@ const registry: CommandDef[] = [
477
524
  { name: "serve mcp", requiresPlugins: true, handler: runServeMcp },
478
525
 
479
526
  // Fallback for unknown subcommands (must come after compound entries)
527
+ { name: "carve", handler: runCarveUnknown },
480
528
  { name: "emulator", requiresPlugins: true, handler: runEmulator },
481
529
  { name: "lifecycle", handler: runLifecycleUnknown },
482
530
  { name: "dev", handler: runDevUnknown },
@@ -31,6 +31,18 @@ export interface ParsedArgs {
31
31
  live: boolean;
32
32
  /** `chant migrate --from <name>` (default "github") */
33
33
  migrateFrom?: string;
34
+ /** `chant carve advise --state <path>` — opt-in .tfstate for accurate instance counts */
35
+ statePath?: string;
36
+ /** `chant carve emit --select <tf-address>` — the Terraform resource to carve */
37
+ selectAddress?: string;
38
+ /** `chant carve emit --live-name <logicalId>` — CFN logical ID for live adoption */
39
+ liveName?: string;
40
+ /** `chant carve bridge --apply-rewrites` — write rewritten survivor .tf in place */
41
+ applyRewrites?: boolean;
42
+ /** `chant carve apply --stack <name>` — ownership-marker stack for graduation */
43
+ carveStack?: string;
44
+ /** `chant carve apply --write` — save the graduation doc */
45
+ write?: boolean;
34
46
  /** `chant migrate --to <name>` (default "gitlab") */
35
47
  migrateTo?: string;
36
48
  /** `chant migrate --emit yaml|ts` */
@@ -0,0 +1,51 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { join } from "path";
3
+ import { carveAdvise, formatCarveReport } from "../../cli/commands/carve";
4
+ import { loadHcl2json } from "../parse";
5
+
6
+ /**
7
+ * End-to-end demo test (#214 T5): run the advisor against the runnable sample
8
+ * estate the docs point at, and pin the headline scores so the doc examples
9
+ * stay honest. Skips cleanly when the optional wasm parser is absent.
10
+ */
11
+ let parserAvailable = false;
12
+ try {
13
+ await loadHcl2json();
14
+ parserAvailable = true;
15
+ } catch {
16
+ parserAvailable = false;
17
+ }
18
+
19
+ const ESTATE = join(__dirname, "sample-estate");
20
+
21
+ describe("carve advise against the sample estate", () => {
22
+ test("bands the estate the way the docs describe", async () => {
23
+ if (!parserAvailable) return;
24
+ const r = await carveAdvise({ from: ESTATE });
25
+ expect(r.ok).toBe(true);
26
+ const byAddr = Object.fromEntries((r.results ?? []).map((x) => [x.address, x]));
27
+
28
+ // Clean leaves
29
+ expect(byAddr["aws_s3_bucket.assets"].band).toBe("clean leaf");
30
+ expect(byAddr["aws_s3_bucket.assets"].score).toBe(88); // 1 inbound (Lambda); versioning folded
31
+ expect(byAddr["aws_cloudwatch_log_group.api"].score).toBe(100); // clean, no edges
32
+
33
+ // Folded sub-resource is not ranked on its own
34
+ expect(byAddr["aws_s3_bucket_versioning.assets"]).toBeUndefined();
35
+
36
+ // A subnet is a clean leaf (one outbound edge to the VPC).
37
+ expect(byAddr["aws_subnet.a"].score).toBe(96);
38
+
39
+ // The VPC three subnets hang off of is carvable, but with boundary work.
40
+ expect(byAddr["aws_vpc.main"].band).toBe("carvable w/ edits");
41
+ expect(byAddr["aws_vpc.main"].score).toBe(64); // 100 - 12*3 inbound
42
+
43
+ // Unsupported provider scores 0 → leave in Terraform
44
+ expect(byAddr["random_pet.suffix"].score).toBe(0);
45
+ expect(byAddr["random_pet.suffix"].band).toBe("leave in Terraform");
46
+
47
+ // The report never proposes a mutation.
48
+ const text = formatCarveReport(r);
49
+ expect(text).not.toMatch(/state rm|apply|destroy/i);
50
+ });
51
+ });
@@ -0,0 +1,77 @@
1
+ import { describe, test, expect, vi } from "vitest";
2
+ import { mkdtempSync, writeFileSync, rmSync } from "fs";
3
+ import { tmpdir } from "os";
4
+ import { join } from "path";
5
+ import { carveEmit } from "../../cli/commands/carve-emit";
6
+ import { carveBridge } from "../../cli/commands/carve-bridge";
7
+ import { carveApply } from "../../cli/commands/carve-apply";
8
+ import { loadHcl2json } from "../parse";
9
+ import type { ImportResult, LiveImportOptions } from "../../cli/commands/import";
10
+ import type { LexiconPlugin } from "../../lexicon";
11
+
12
+ /**
13
+ * End-to-end: one resource through the whole strangler-fig arc — emit → bridge
14
+ * → apply — on a single estate, asserting the steps compose. Live import is
15
+ * faked (no cloud). Skips when the wasm parser is absent.
16
+ */
17
+ let parserAvailable = false;
18
+ try {
19
+ await loadHcl2json();
20
+ parserAvailable = true;
21
+ } catch {
22
+ parserAvailable = false;
23
+ }
24
+
25
+ const ESTATE = `
26
+ resource "aws_s3_bucket" "assets" { bucket = "myapp-assets-prod" }
27
+ resource "aws_s3_bucket_versioning" "assets" {
28
+ bucket = aws_s3_bucket.assets.id
29
+ versioning_configuration { status = "Enabled" }
30
+ }
31
+ resource "aws_lambda_function" "api" {
32
+ function_name = "myapp-api"
33
+ environment { variables = { B = aws_s3_bucket.assets.bucket, A = aws_s3_bucket.assets.arn } }
34
+ }
35
+ `;
36
+
37
+ const fakeImport = vi.fn(
38
+ async (_p: LexiconPlugin[], _o: LiveImportOptions): Promise<ImportResult> => ({
39
+ success: true,
40
+ generatedFiles: ["infra/assets.ts"],
41
+ warnings: [],
42
+ lexicon: "aws",
43
+ }),
44
+ );
45
+
46
+ describe("carve arc: emit → bridge → apply", () => {
47
+ test("carries aws_s3_bucket.assets from Terraform to chant-owned", async () => {
48
+ if (!parserAvailable) return;
49
+ const dir = mkdtempSync(join(tmpdir(), "chant-arc-"));
50
+ try {
51
+ writeFileSync(join(dir, "main.tf"), ESTATE);
52
+ const sel = "aws_s3_bucket.assets";
53
+
54
+ // 1. emit: adopt + boundary (Lambda inbound; versioning folded).
55
+ const emit = await carveEmit({ from: dir, select: sel, env: "prod" }, { plugins: [], liveImport: fakeImport });
56
+ expect(emit.ok).toBe(true);
57
+ expect(emit.selector).toEqual({ type: "AWS::S3::Bucket" }); // live selector is by native type
58
+ expect(emit.report!.inbound.map((e) => e.survivor)).toEqual(["aws_lambda_function.api"]);
59
+
60
+ // 2. bridge: data source + rewired survivor (dry-run).
61
+ const out = join(dir, "carveout");
62
+ const bridge = await carveBridge({ from: dir, select: sel, output: out });
63
+ expect(bridge.ok).toBe(true);
64
+ expect(bridge.plan!.dataSources[0].type).toBe("aws_s3_bucket");
65
+ const rewired = bridge.plan!.rewrites.find((r) => r.changed)!;
66
+ expect(rewired.rewritten).toContain("data.aws_s3_bucket.assets.bucket");
67
+
68
+ // 3. apply: ownership marker + graduation plan.
69
+ const apply = await carveApply({ from: dir, select: sel, env: "prod", stack: "assets" });
70
+ expect(apply.ok).toBe(true);
71
+ expect(apply.plan!.ownershipTags["chant:managed-by"]).toBe("chant");
72
+ expect(apply.plan!.steps.join("\n")).toMatch(/terraform import aws_s3_bucket\.assets/);
73
+ } finally {
74
+ rmSync(dir, { recursive: true, force: true });
75
+ }
76
+ });
77
+ });
@@ -0,0 +1,58 @@
1
+ # A small, deliberately mixed Terraform estate for the carve-out advisor demo.
2
+ # Some resources are clean leaves, some carry boundary edges, some should stay.
3
+
4
+ # Clean leaf: a bucket a Lambda reads from. Its versioning sub-resource folds in.
5
+ resource "aws_s3_bucket" "assets" {
6
+ bucket = "myapp-assets-prod"
7
+ tags = { Team = "web", Env = "prod" }
8
+ }
9
+
10
+ resource "aws_s3_bucket_versioning" "assets" {
11
+ bucket = aws_s3_bucket.assets.id
12
+ versioning_configuration {
13
+ status = "Enabled"
14
+ }
15
+ }
16
+
17
+ # Clean leaf: a log group nothing depends on.
18
+ resource "aws_cloudwatch_log_group" "api" {
19
+ name = "/myapp/api"
20
+ retention_in_days = 30
21
+ }
22
+
23
+ # Stays in Terraform: the Lambda reads the bucket (tier-2 map + outbound edge).
24
+ resource "aws_lambda_function" "api" {
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
+ # Carvable w/ edits: three subnets depend on the VPC, so carving it means three
35
+ # data-source patches to the surviving Terraform — real but bounded boundary work.
36
+ resource "aws_vpc" "main" {
37
+ cidr_block = "10.0.0.0/16"
38
+ }
39
+
40
+ resource "aws_subnet" "a" {
41
+ vpc_id = aws_vpc.main.id
42
+ cidr_block = "10.0.1.0/24"
43
+ }
44
+
45
+ resource "aws_subnet" "b" {
46
+ vpc_id = aws_vpc.main.id
47
+ cidr_block = "10.0.2.0/24"
48
+ }
49
+
50
+ resource "aws_subnet" "c" {
51
+ vpc_id = aws_vpc.main.id
52
+ cidr_block = "10.0.3.0/24"
53
+ }
54
+
55
+ # Leave in Terraform: an unsupported provider with no native mapping, scored 0.
56
+ resource "random_pet" "suffix" {
57
+ length = 2
58
+ }
@@ -0,0 +1,64 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { adoptFromState, canAdoptFromState, supportedStateAdoptionTypes } from "./adopt-state";
3
+ import type { StateResource } from "./state";
4
+
5
+ describe("adoptFromState", () => {
6
+ test("maps a real S3 bucket state resource to native chant source", () => {
7
+ const resource: StateResource = {
8
+ type: "aws_s3_bucket",
9
+ name: "assets",
10
+ attributes: {
11
+ id: "myapp-assets-prod",
12
+ bucket: "myapp-assets-prod",
13
+ arn: "arn:aws:s3:::myapp-assets-prod",
14
+ tags: { Team: "web", Env: "prod" },
15
+ force_destroy: false,
16
+ },
17
+ };
18
+ const out = adoptFromState(resource)!;
19
+ expect(out.fileName).toBe("assets.ts");
20
+ expect(out.mapped).toBe(true);
21
+ expect(out.nativeType).toBe("AWS::S3::Bucket");
22
+
23
+ // Correct constructor + CloudFormation PascalCase props from real TF attrs.
24
+ expect(out.content).toContain('import { Bucket } from "@intentius/chant-lexicon-aws";');
25
+ expect(out.content).toContain("export const assets = new Bucket({");
26
+ expect(out.content).toContain('BucketName: "myapp-assets-prod"');
27
+ expect(out.content).toContain('{"Key":"Team","Value":"web"}');
28
+ expect(out.content).toContain('{"Key":"Env","Value":"prod"}');
29
+
30
+ // Unmapped attributes are preserved for reconciliation, not dropped.
31
+ expect(out.content).toContain("Unmapped Terraform attributes");
32
+ expect(out.content).toContain("force_destroy");
33
+ // Mapped keys are not repeated in the unmapped block.
34
+ expect(out.content).not.toContain('"bucket":');
35
+ });
36
+
37
+ test("maps a log group with retention", () => {
38
+ const out = adoptFromState({
39
+ type: "aws_cloudwatch_log_group",
40
+ name: "api",
41
+ attributes: { id: "/myapp/api", name: "/myapp/api", retention_in_days: 30, arn: "arn:...:log-group:/myapp/api" },
42
+ })!;
43
+ expect(out.content).toContain("new LogGroup({");
44
+ expect(out.content).toContain('LogGroupName: "/myapp/api"');
45
+ expect(out.content).toContain("RetentionInDays: 30");
46
+ });
47
+
48
+ test("canAdoptFromState gates on a known native constructor", () => {
49
+ expect(canAdoptFromState("aws_s3_bucket")).toBe(true);
50
+ expect(canAdoptFromState("random_pet")).toBe(false);
51
+ });
52
+
53
+ test("supportedStateAdoptionTypes lists the mappable types, sorted", () => {
54
+ const types = supportedStateAdoptionTypes();
55
+ expect(types).toContain("aws_s3_bucket");
56
+ expect(types).toContain("aws_sqs_queue");
57
+ expect(types).toEqual([...types].sort());
58
+ expect(types).not.toContain("aws_instance"); // not yet mapped
59
+ });
60
+
61
+ test("returns null for a type with no native constructor", () => {
62
+ expect(adoptFromState({ type: "random_pet", name: "x", attributes: { id: "abc" } })).toBeNull();
63
+ });
64
+ });
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Adopt a Terraform-managed resource into chant source from its `.tfstate`
3
+ * (#1009). This is the correct adoption source for carving OUT of Terraform:
4
+ * a TF resource is created through the provider API, not CloudFormation, so it
5
+ * is not in any CFN stack — but its resolved attributes ARE in the state file.
6
+ *
7
+ * chant AWS constructors take CloudFormation PascalCase properties (e.g.
8
+ * `new Bucket({ BucketName })`), while Terraform state uses the provider's
9
+ * snake_case attributes (`bucket`, `tags`). The mapping is driven by the single
10
+ * AWS carve-out table (`aws-resources.ts`) — the same table the advisor's tier
11
+ * map derives from, so every AWS type advise ranks can be emitted. Attributes
12
+ * without a mapping are preserved in a reference comment, never dropped.
13
+ */
14
+
15
+ import { AWS_CARVE_TYPES, AWS_LEXICON_IMPORT, awsCarveType, applyAwsMapper } from "./aws-resources";
16
+ import type { StateResource } from "./state";
17
+
18
+ export interface AdoptedSource {
19
+ fileName: string;
20
+ content: string;
21
+ /** True when at least one attribute was mapped to a native prop. */
22
+ mapped: boolean;
23
+ nativeType: string;
24
+ }
25
+
26
+ /** Is this Terraform type adoptable from state (has a native constructor)? */
27
+ export function canAdoptFromState(tfType: string): boolean {
28
+ return awsCarveType(tfType) !== undefined;
29
+ }
30
+
31
+ /** Terraform types that can currently be adopted from state, for user-facing hints. */
32
+ export function supportedStateAdoptionTypes(): string[] {
33
+ return AWS_CARVE_TYPES.map((t) => t.tfType).sort();
34
+ }
35
+
36
+ /**
37
+ * Render chant source for a state-adopted resource. Emits the native
38
+ * constructor with mapped properties, plus a reference comment listing the
39
+ * Terraform attributes that were not mapped so nothing is silently dropped.
40
+ */
41
+ export function adoptFromState(resource: StateResource): AdoptedSource | null {
42
+ const entry = awsCarveType(resource.type);
43
+ if (!entry) return null;
44
+
45
+ const { props, mappedKeys } = applyAwsMapper(entry, resource.attributes);
46
+ const unmapped: Record<string, unknown> = {};
47
+ for (const [k, v] of Object.entries(resource.attributes)) {
48
+ if (!mappedKeys.includes(k)) unmapped[k] = v;
49
+ }
50
+
51
+ const L: string[] = [];
52
+ L.push(`// Adopted from Terraform state: ${resource.type}.${resource.name} -> ${entry.nativeType}`);
53
+ L.push(`// Properties mapped from Terraform attributes (CloudFormation PascalCase).`);
54
+ L.push(`import { ${entry.ctor} } from "${AWS_LEXICON_IMPORT}";`);
55
+ L.push("");
56
+ L.push(`export const ${resource.name} = new ${entry.ctor}(${renderObject(props, 2)});`);
57
+ if (Object.keys(unmapped).length) {
58
+ L.push("");
59
+ L.push("/* Unmapped Terraform attributes (reconcile to native props before building):");
60
+ L.push(JSON.stringify(unmapped, null, 2));
61
+ L.push("*/");
62
+ }
63
+
64
+ return {
65
+ fileName: `${resource.name}.ts`,
66
+ content: L.join("\n") + "\n",
67
+ mapped: Object.keys(props).length > 0,
68
+ nativeType: entry.nativeType,
69
+ };
70
+ }
71
+
72
+ /** Render a JS object literal with stable key order and given indent. */
73
+ function renderObject(obj: Record<string, unknown>, indent: number): string {
74
+ const keys = Object.keys(obj);
75
+ if (keys.length === 0) return "{}";
76
+ const pad = " ".repeat(indent);
77
+ const body = keys.map((k) => `${pad}${k}: ${JSON.stringify(obj[k])},`).join("\n");
78
+ return `{\n${body}\n}`;
79
+ }