@intentius/chant 0.44.6 → 0.44.8

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 (45) hide show
  1. package/dist/cli/commands/carve-bridge.d.ts.map +1 -1
  2. package/dist/cli/commands/carve-emit.d.ts +3 -1
  3. package/dist/cli/commands/carve-emit.d.ts.map +1 -1
  4. package/dist/cli/commands/carve.d.ts +7 -2
  5. package/dist/cli/commands/carve.d.ts.map +1 -1
  6. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  7. package/dist/terraform/adopt-state.d.ts +18 -1
  8. package/dist/terraform/adopt-state.d.ts.map +1 -1
  9. package/dist/terraform/aws-resources.d.ts +29 -0
  10. package/dist/terraform/aws-resources.d.ts.map +1 -1
  11. package/dist/terraform/bridge.d.ts +8 -0
  12. package/dist/terraform/bridge.d.ts.map +1 -1
  13. package/dist/terraform/carve.d.ts +8 -3
  14. package/dist/terraform/carve.d.ts.map +1 -1
  15. package/dist/terraform/graph.d.ts +10 -4
  16. package/dist/terraform/graph.d.ts.map +1 -1
  17. package/dist/terraform/parse.d.ts.map +1 -1
  18. package/dist/terraform/score.d.ts +11 -0
  19. package/dist/terraform/score.d.ts.map +1 -1
  20. package/dist/terraform/types.d.ts +11 -0
  21. package/dist/terraform/types.d.ts.map +1 -1
  22. package/package.json +1 -1
  23. package/src/cli/commands/carve-bridge.test.ts +32 -0
  24. package/src/cli/commands/carve-bridge.ts +3 -0
  25. package/src/cli/commands/carve-emit-state.test.ts +107 -0
  26. package/src/cli/commands/carve-emit.ts +37 -5
  27. package/src/cli/commands/carve.test.ts +39 -1
  28. package/src/cli/commands/carve.ts +8 -2
  29. package/src/cli/handlers/lifecycle.test.ts +27 -0
  30. package/src/cli/handlers/lifecycle.ts +3 -0
  31. package/src/terraform/__fixtures__/advise.test.ts +6 -4
  32. package/src/terraform/adopt-state.test.ts +64 -0
  33. package/src/terraform/adopt-state.ts +52 -2
  34. package/src/terraform/aws-resources.test.ts +102 -1
  35. package/src/terraform/aws-resources.ts +144 -2
  36. package/src/terraform/bridge.test.ts +30 -0
  37. package/src/terraform/bridge.ts +19 -1
  38. package/src/terraform/carve.test.ts +34 -0
  39. package/src/terraform/carve.ts +0 -0
  40. package/src/terraform/graph.test.ts +45 -0
  41. package/src/terraform/graph.ts +35 -16
  42. package/src/terraform/parse.ts +4 -1
  43. package/src/terraform/score.test.ts +25 -0
  44. package/src/terraform/score.ts +27 -4
  45. package/src/terraform/types.ts +11 -0
@@ -100,6 +100,43 @@ describe("carveAdvise", () => {
100
100
  });
101
101
  });
102
102
 
103
+ test("an output block in its own file counts against the score it reads (#1638)", async () => {
104
+ if (!parserAvailable) return;
105
+ const dir = mkdtempSync(join(tmpdir(), "chant-carve-outputs-"));
106
+ try {
107
+ writeFileSync(join(dir, "main.tf"), ESTATE);
108
+ // Outputs almost always live in their own file — the merge across files
109
+ // has to carry them, or the graph never sees the estate's outputs.tf.
110
+ writeFileSync(
111
+ join(dir, "outputs.tf"),
112
+ `output "assets_bucket" {\n value = aws_s3_bucket.assets.bucket\n}\n`,
113
+ );
114
+ const r = await carveAdvise({ from: dir });
115
+ expect(r.ok).toBe(true);
116
+
117
+ // Outputs are not carve candidates.
118
+ expect((r.results ?? []).map((x) => x.address)).not.toContain("output.assets_bucket");
119
+
120
+ const bucket = r.results!.find((x) => x.address === "aws_s3_bucket.assets")!;
121
+ expect(bucket.breakdown).toMatchObject({ inbound: 1, outputs: 1 });
122
+ expect(bucket.score).toBe(84); // 88 with the Lambda alone, minus 4 for the output
123
+ expect(formatCarveReport(r)).toContain("1 output block(s) reading it (one-line rewrite each)");
124
+
125
+ const written = carveJson(r).resources.find((x) => x.address === "aws_s3_bucket.assets")!;
126
+ expect(written.boundary!.inbound).toContainEqual({
127
+ direction: "inbound",
128
+ survivor: "output.assets_bucket",
129
+ carved: "aws_s3_bucket.assets",
130
+ attrs: ["bucket"],
131
+ via: ["value"],
132
+ bridge: "tf-output-rewrite",
133
+ required: "immediately",
134
+ });
135
+ } finally {
136
+ rmSync(dir, { recursive: true, force: true });
137
+ }
138
+ });
139
+
103
140
  test("formatCarveReport groups by band and never suggests a mutation", async () => {
104
141
  if (!parserAvailable) return;
105
142
  await withEstate(async (dir) => {
@@ -132,10 +169,11 @@ describe("carveAdvise", () => {
132
169
  breakdown: {
133
170
  inbound: 0,
134
171
  outbound: 0,
172
+ outputs: 0,
135
173
  tier: 1,
136
174
  hasDynamic: false,
137
175
  instances: 1,
138
- penalties: { inbound: 0, outbound: 0, tier: 0, dynamic: 0, instances: 0 },
176
+ penalties: { inbound: 0, outbound: 0, outputs: 0, tier: 0, dynamic: 0, instances: 0 },
139
177
  },
140
178
  },
141
179
  ],
@@ -73,8 +73,9 @@ export async function carveAdvise(opts: CarveAdviseOptions): Promise<CarveAdvise
73
73
  * says which shape it is. The promise attached to this number:
74
74
  *
75
75
  * - **Additive within a version.** New top-level fields, new per-resource
76
- * fields, and new entries in an existing list may appear in any release. A
77
- * reader must ignore keys it does not know.
76
+ * fields, new kinds of entry in an existing list, and new values in an
77
+ * open-ended enum (a `bridge` kind, say) may appear in any release. A
78
+ * reader must ignore keys and values it does not know.
78
79
  * - **A removal, a rename, or a changed meaning bumps it.** So does narrowing
79
80
  * a field's type (an optional becoming required is additive; the reverse is
80
81
  * not).
@@ -97,6 +98,10 @@ export interface CarveJsonResource extends Peelability {
97
98
  * stand in for it. Edges internal to the carve set are not boundary work and
98
99
  * are not listed.
99
100
  *
101
+ * An inbound edge's survivor can be an `output.<name>` pseudo-address
102
+ * (#1638), carrying `bridge: "tf-output-rewrite"` and `via: ["value"]`. It
103
+ * is counted in `breakdown.outputs`, not `breakdown.inbound`.
104
+ *
100
105
  * Present (possibly with two empty lists) whenever the graph was available;
101
106
  * absent means this chant did not compute it — "none" and "not reported" are
102
107
  * different claims. `breakdown.inbound`/`outbound` keep the counts.
@@ -184,6 +189,7 @@ function reasons(r: Peelability): string {
184
189
  if (b.tier === null) return "no known native mapping (unsupported provider/type)";
185
190
  const parts: string[] = [];
186
191
  if (b.inbound) parts.push(`${b.inbound} inbound (data-source patch each)`);
192
+ if (b.outputs) parts.push(`${b.outputs} output block(s) reading it (one-line rewrite each)`);
187
193
  if (b.outbound) parts.push(`${b.outbound} outbound (deferred input each)`);
188
194
  if (b.tier > 1) parts.push(`tier ${b.tier} map`);
189
195
  if (b.hasDynamic) parts.push("count/for_each/data present");
@@ -677,6 +677,33 @@ describe("runLifecyclePlan", () => {
677
677
  expect(stdoutBuf.join("\n")).toContain("bucket");
678
678
  });
679
679
 
680
+ // #1620 — the resolved read address rides plan entries the same way it rides
681
+ // the live diff. Regression: the plan path dropped the observation's queried
682
+ // map, so only unobserved rows ever carried an address while the docs
683
+ // promised it per-entry.
684
+ test("--json carries the observation's queried address on observed entries", async () => {
685
+ buildMock.mockResolvedValue(makeBuildResult({ k8s: ["web"] }));
686
+ const plugins: LexiconPlugin[] = [
687
+ createMockPlugin({
688
+ name: "k8s",
689
+ describeResources: async () => ({
690
+ observation: "v1" as const,
691
+ resources: { web: meta({ type: "K8s::Apps::Deployment" }) },
692
+ queried: { web: "/apis/apps/v1/namespaces/default/deployments/web" },
693
+ }),
694
+ }),
695
+ ];
696
+ const exit = await runLifecyclePlan({
697
+ args: makeArgs({ path: "plan", extraPositional: "prod", json: true }),
698
+ plugins,
699
+ serializers: plugins.map((p) => p.serializer),
700
+ });
701
+ expect(exit).toBe(0);
702
+ const plan = JSON.parse(stdoutBuf.join("\n"));
703
+ const web = plan.entries.find((e: { name: string }) => e.name === "web");
704
+ expect(web.queried).toBe("/apis/apps/v1/namespaces/default/deployments/web");
705
+ });
706
+
680
707
  // #1166 — plan is always a live read (no `--live` flag of its own), so a
681
708
  // declared environment endpoint applies here exactly as it does for
682
709
  // `chant graph --live` / `chant lifecycle diff --live`.
@@ -1178,6 +1178,9 @@ export async function runLifecyclePlan(ctx: CommandContext): Promise<number> {
1178
1178
  observedNow: observed.resources,
1179
1179
  observedThen,
1180
1180
  unobserved: observed.unobserved,
1181
+ // The resolved read address rides every entry, not just unobserved
1182
+ // ones — the diff path already passes it (#1620); plan lost it.
1183
+ queried: observed.queried,
1181
1184
  });
1182
1185
  merged.entries.push(...cs.entries);
1183
1186
  checked++;
@@ -129,11 +129,13 @@ describe("carve advise --json against the sample estate", () => {
129
129
  if (!parserAvailable) return;
130
130
  const report = carveJson(await carveAdvise({ from: ESTATE }));
131
131
 
132
- // `breakdown.inbound`/`outbound` are what the score was computed from and
133
- // stay for backward compatibility. They must agree with the lists, or the
134
- // arithmetic a reader prints beside the drawn edges is a lie.
132
+ // `breakdown.inbound`/`outbound`/`outputs` are what the score was computed
133
+ // from and stay for backward compatibility. They must agree with the lists,
134
+ // or the arithmetic a reader prints beside the drawn edges is a lie. The
135
+ // inbound list holds both resource and output survivors (#1638), so it is
136
+ // the two counts together.
135
137
  for (const r of report.resources) {
136
- expect([r.address, r.boundary!.inbound.length]).toEqual([r.address, r.breakdown.inbound]);
138
+ expect([r.address, r.boundary!.inbound.length]).toEqual([r.address, r.breakdown.inbound + r.breakdown.outputs]);
137
139
  expect([r.address, r.boundary!.outbound.length]).toEqual([r.address, r.breakdown.outbound]);
138
140
  }
139
141
 
@@ -78,6 +78,70 @@ describe("adoptFromState", () => {
78
78
  expect(out.content).toContain('FunctionName: "myapp-api"');
79
79
  });
80
80
 
81
+ test("folded sub-resources join the parent's emitted properties (#1637)", () => {
82
+ const bucket: StateResource = {
83
+ type: "aws_s3_bucket",
84
+ name: "assets",
85
+ attributes: {
86
+ id: "myapp-assets-prod",
87
+ bucket: "myapp-assets-prod",
88
+ versioning: [{ enabled: true, mfa_delete: false }],
89
+ server_side_encryption_configuration: [
90
+ { rule: [{ apply_server_side_encryption_by_default: [{ sse_algorithm: "AES256" }], bucket_key_enabled: false }] },
91
+ ],
92
+ },
93
+ };
94
+ const out = adoptFromState(bucket, [], [
95
+ {
96
+ type: "aws_s3_bucket_versioning",
97
+ name: "assets",
98
+ attributes: { bucket: "myapp-assets-prod", versioning_configuration: [{ status: "Enabled" }], mfa: null },
99
+ },
100
+ {
101
+ type: "aws_s3_bucket_public_access_block",
102
+ name: "assets",
103
+ attributes: {
104
+ bucket: "myapp-assets-prod",
105
+ block_public_acls: true,
106
+ block_public_policy: true,
107
+ ignore_public_acls: true,
108
+ restrict_public_buckets: true,
109
+ },
110
+ },
111
+ ])!;
112
+
113
+ expect(out.content).toContain('VersioningConfiguration: {"Status":"Enabled"}');
114
+ expect(out.content).toContain(
115
+ 'PublicAccessBlockConfiguration: {"BlockPublicAcls":true,"BlockPublicPolicy":true,"IgnorePublicAcls":true,"RestrictPublicBuckets":true}',
116
+ );
117
+ // The bucket's own in-state SSE block lands as BucketEncryption.
118
+ expect(out.content).toContain('BucketEncryption: {"ServerSideEncryptionConfiguration"');
119
+ // The source says what each fold contributed.
120
+ expect(out.content).toContain("// Folded in aws_s3_bucket_versioning.assets -> VersioningConfiguration");
121
+ expect(out.content).toContain(
122
+ "// Folded in aws_s3_bucket_public_access_block.assets -> PublicAccessBlockConfiguration",
123
+ );
124
+ expect(out.folded).toEqual([
125
+ { address: "aws_s3_bucket_versioning.assets", props: ["VersioningConfiguration"] },
126
+ { address: "aws_s3_bucket_public_access_block.assets", props: ["PublicAccessBlockConfiguration"] },
127
+ ]);
128
+ // Only the genuinely unmappable leftover is in the comment.
129
+ expect(out.content).toContain('"aws_s3_bucket_versioning.assets"');
130
+ expect(out.content).toContain('"mfa": null');
131
+ });
132
+
133
+ test("a folded sub-resource with no mapping yet is reported, not dropped", () => {
134
+ const out = adoptFromState(
135
+ { type: "aws_s3_bucket", name: "assets", attributes: { bucket: "b" } },
136
+ [],
137
+ [{ type: "aws_s3_bucket_policy", name: "assets", attributes: { bucket: "b", policy: '{"Statement":[]}' } }],
138
+ )!;
139
+ expect(out.folded).toEqual([{ address: "aws_s3_bucket_policy.assets", props: [] }]);
140
+ expect(out.content).toContain("// Folded in aws_s3_bucket_policy.assets -> nothing mappable");
141
+ expect(out.content).toContain('"aws_s3_bucket_policy.assets"');
142
+ expect(out.content).toContain('"policy"');
143
+ });
144
+
81
145
  test("canAdoptFromState gates on a known native constructor", () => {
82
146
  expect(canAdoptFromState("aws_s3_bucket")).toBe(true);
83
147
  expect(canAdoptFromState("random_pet")).toBe(false);
@@ -18,7 +18,14 @@
18
18
  * resolved value as the declared default (see `carve-emit.ts`'s scaffold).
19
19
  */
20
20
 
21
- import { AWS_CARVE_TYPES, AWS_LEXICON_IMPORT, awsCarveType, applyAwsMapper } from "./aws-resources";
21
+ import {
22
+ AWS_CARVE_TYPES,
23
+ AWS_LEXICON_IMPORT,
24
+ awsCarveType,
25
+ applyAwsMapper,
26
+ applyAwsFold,
27
+ unmappedFoldAttrs,
28
+ } from "./aws-resources";
22
29
  import type { StateResource } from "./state";
23
30
 
24
31
  /** The core subpath the emitted source reads build parameters from. */
@@ -43,6 +50,14 @@ export interface DeferredParam {
43
50
  default?: string | number | boolean;
44
51
  }
45
52
 
53
+ /** What one folded sub-resource contributed to the parent's emitted props (#1637). */
54
+ export interface FoldedContribution {
55
+ /** The sub-resource's Terraform address, e.g. `aws_s3_bucket_versioning.assets`. */
56
+ address: string;
57
+ /** CFN properties it added to the parent, e.g. `["VersioningConfiguration"]`. */
58
+ props: string[];
59
+ }
60
+
46
61
  export interface AdoptedSource {
47
62
  fileName: string;
48
63
  content: string;
@@ -51,6 +66,8 @@ export interface AdoptedSource {
51
66
  nativeType: string;
52
67
  /** Deferred params actually substituted into the emitted props (#998). */
53
68
  parameterized: string[];
69
+ /** Folded sub-resources and the props each one joined into the parent (#1637). */
70
+ folded: FoldedContribution[];
54
71
  }
55
72
 
56
73
  /** Is this Terraform type adoptable from state (has a native constructor)? */
@@ -79,13 +96,38 @@ class ParamRef {
79
96
  * A mapped attribute named by a `DeferredParam` renders as a `params.<name>`
80
97
  * reference (a real chant build parameter) instead of the state literal —
81
98
  * the value came from a survivor, so it stays overridable per build.
99
+ *
100
+ * `folded` carries the carve set's sub-resources (`aws_s3_bucket_versioning`
101
+ * and friends), read from the same state file. Their mappable attributes join
102
+ * the parent's props (#1637) — a fold that only announced itself and left the
103
+ * emitted resource without the versioning or public-access block the Terraform
104
+ * declared was a silent loss of configuration. A sub-resource's setting wins
105
+ * over the parent's own legacy in-state block: it is the one the config
106
+ * actually declares.
82
107
  */
83
- export function adoptFromState(resource: StateResource, params: DeferredParam[] = []): AdoptedSource | null {
108
+ export function adoptFromState(
109
+ resource: StateResource,
110
+ params: DeferredParam[] = [],
111
+ folded: StateResource[] = [],
112
+ ): AdoptedSource | null {
84
113
  const entry = awsCarveType(resource.type);
85
114
  if (!entry) return null;
86
115
 
87
116
  const { props, mappedKeys } = applyAwsMapper(entry, resource.attributes);
88
117
 
118
+ const contributions: FoldedContribution[] = [];
119
+ const foldedUnmapped: Record<string, Record<string, unknown>> = {};
120
+ for (const sub of folded) {
121
+ const address = `${sub.type}.${sub.name}`;
122
+ const fold = applyAwsFold(sub.type, sub.attributes);
123
+ // No fold mapping for this sub-resource type: it still carves with the
124
+ // parent, so report its attributes rather than dropping them on the floor.
125
+ const rest = fold ? fold.unmapped : unmappedFoldAttrs(sub.attributes);
126
+ if (fold) Object.assign(props, fold.props);
127
+ if (Object.keys(rest).length) foldedUnmapped[address] = rest;
128
+ contributions.push({ address, props: Object.keys(fold?.props ?? {}) });
129
+ }
130
+
89
131
  // Substitute deferred inputs: only plain (untransformed) field mappings can
90
132
  // carry a parameter reference — a transform ran against the literal at emit
91
133
  // time and cannot re-run at build. Everything else keeps the state literal.
@@ -102,10 +144,17 @@ export function adoptFromState(resource: StateResource, params: DeferredParam[]
102
144
  for (const [k, v] of Object.entries(resource.attributes)) {
103
145
  if (!mappedKeys.includes(k)) unmapped[k] = v;
104
146
  }
147
+ // A folded sub-resource's leftovers are keyed by its address, so the comment
148
+ // says which block a stray attribute came from.
149
+ for (const [address, attrs] of Object.entries(foldedUnmapped)) unmapped[address] = attrs;
105
150
 
106
151
  const L: string[] = [];
107
152
  L.push(`// Adopted from Terraform state: ${resource.type}.${resource.name} -> ${entry.nativeType}`);
108
153
  L.push(`// Properties mapped from Terraform attributes (CloudFormation PascalCase).`);
154
+ for (const c of contributions) {
155
+ const into = c.props.length ? c.props.join(", ") : "nothing mappable — see the reference comment below";
156
+ L.push(`// Folded in ${c.address} -> ${into}`);
157
+ }
109
158
  L.push(`import { ${entry.ctor} } from "${AWS_LEXICON_IMPORT}";`);
110
159
  if (parameterized.length) {
111
160
  L.push(`// Deferred deploy-time input(s) — declared in chant.config.ts's buildParams.`);
@@ -126,6 +175,7 @@ export function adoptFromState(resource: StateResource, params: DeferredParam[]
126
175
  mapped: Object.keys(props).length > 0,
127
176
  nativeType: entry.nativeType,
128
177
  parameterized,
178
+ folded: contributions,
129
179
  };
130
180
  }
131
181
 
@@ -1,5 +1,5 @@
1
1
  import { describe, test, expect } from "vitest";
2
- import { AWS_CARVE_TYPES, awsCarveType, applyAwsMapper } from "./aws-resources";
2
+ import { AWS_CARVE_TYPES, AWS_FOLD_MAPPERS, awsCarveType, applyAwsMapper, applyAwsFold } from "./aws-resources";
3
3
  import { TIER_MAP, FOLDS_INTO, IDENTITY_ATTR } from "./tier-map";
4
4
  import { canAdoptFromState } from "./adopt-state";
5
5
 
@@ -100,6 +100,107 @@ describe("applyAwsMapper", () => {
100
100
  });
101
101
  });
102
102
 
103
+ describe("folded sub-resource mappers (#1637)", () => {
104
+ test("every fold mapper is for a type that actually folds", () => {
105
+ for (const tfType of Object.keys(AWS_FOLD_MAPPERS)) {
106
+ expect(FOLDS_INTO[tfType]).toBeDefined();
107
+ }
108
+ });
109
+
110
+ test("versioning becomes the parent's VersioningConfiguration", () => {
111
+ const fold = applyAwsFold("aws_s3_bucket_versioning", {
112
+ id: "my-bucket",
113
+ bucket: "my-bucket",
114
+ versioning_configuration: [{ status: "Enabled", mfa_delete: "" }],
115
+ expected_bucket_owner: "",
116
+ })!;
117
+ expect(fold.props).toEqual({ VersioningConfiguration: { Status: "Enabled" } });
118
+ // The parent link is not content; the leftover attribute still reports.
119
+ expect(fold.unmapped).toEqual({ expected_bucket_owner: "" });
120
+ });
121
+
122
+ test("a status CloudFormation cannot spell maps nothing and consumes nothing", () => {
123
+ const fold = applyAwsFold("aws_s3_bucket_versioning", {
124
+ bucket: "b",
125
+ versioning_configuration: [{ status: "Disabled" }],
126
+ })!;
127
+ expect(fold.props).toEqual({});
128
+ expect(fold.unmapped).toEqual({ versioning_configuration: [{ status: "Disabled" }] });
129
+ });
130
+
131
+ test("the public access block becomes the parent's PublicAccessBlockConfiguration", () => {
132
+ const fold = applyAwsFold("aws_s3_bucket_public_access_block", {
133
+ id: "my-bucket",
134
+ bucket: "my-bucket",
135
+ block_public_acls: true,
136
+ block_public_policy: true,
137
+ ignore_public_acls: true,
138
+ restrict_public_buckets: false,
139
+ })!;
140
+ expect(fold.props).toEqual({
141
+ PublicAccessBlockConfiguration: {
142
+ BlockPublicAcls: true,
143
+ BlockPublicPolicy: true,
144
+ IgnorePublicAcls: true,
145
+ RestrictPublicBuckets: false,
146
+ },
147
+ });
148
+ expect(fold.unmapped).toEqual({});
149
+ });
150
+
151
+ test("the SSE sub-resource becomes BucketEncryption, KMS key included", () => {
152
+ const fold = applyAwsFold("aws_s3_bucket_server_side_encryption_configuration", {
153
+ bucket: "my-bucket",
154
+ rule: [
155
+ {
156
+ apply_server_side_encryption_by_default: [{ sse_algorithm: "aws:kms", kms_master_key_id: "arn:aws:kms:k" }],
157
+ bucket_key_enabled: true,
158
+ },
159
+ ],
160
+ })!;
161
+ expect(fold.props).toEqual({
162
+ BucketEncryption: {
163
+ ServerSideEncryptionConfiguration: [
164
+ { ServerSideEncryptionByDefault: { SSEAlgorithm: "aws:kms", KMSMasterKeyID: "arn:aws:kms:k" }, BucketKeyEnabled: true },
165
+ ],
166
+ },
167
+ });
168
+ });
169
+
170
+ test("a sub-resource type with no mapping yet returns null (the caller reports it)", () => {
171
+ expect(applyAwsFold("aws_s3_bucket_policy", { policy: "{}" })).toBeNull();
172
+ });
173
+
174
+ test("the bucket's own in-state versioning and SSE blocks map too", () => {
175
+ const { props, mappedKeys } = applyAwsMapper(awsCarveType("aws_s3_bucket")!, {
176
+ bucket: "my-bucket",
177
+ versioning: [{ enabled: true, mfa_delete: false }],
178
+ server_side_encryption_configuration: [
179
+ { rule: [{ apply_server_side_encryption_by_default: [{ sse_algorithm: "AES256", kms_master_key_id: "" }], bucket_key_enabled: false }] },
180
+ ],
181
+ });
182
+ expect(props.VersioningConfiguration).toEqual({ Status: "Enabled" });
183
+ expect(props.BucketEncryption).toEqual({
184
+ ServerSideEncryptionConfiguration: [
185
+ { ServerSideEncryptionByDefault: { SSEAlgorithm: "AES256" }, BucketKeyEnabled: false },
186
+ ],
187
+ });
188
+ expect(mappedKeys).toContain("versioning");
189
+ });
190
+
191
+ test("an unversioned bucket's empty blocks map nothing and stay reported", () => {
192
+ const { props, mappedKeys } = applyAwsMapper(awsCarveType("aws_s3_bucket")!, {
193
+ bucket: "my-bucket",
194
+ versioning: [{ enabled: false, mfa_delete: false }],
195
+ server_side_encryption_configuration: [],
196
+ });
197
+ expect(props).not.toHaveProperty("VersioningConfiguration");
198
+ expect(props).not.toHaveProperty("BucketEncryption");
199
+ expect(mappedKeys).not.toContain("versioning");
200
+ expect(mappedKeys).not.toContain("server_side_encryption_configuration");
201
+ });
202
+ });
203
+
103
204
  describe("tier + identity coverage maps (#998)", () => {
104
205
  test("kubernetes provider types rank, with _v1 aliases sharing the entry", () => {
105
206
  expect(TIER_MAP.kubernetes_manifest).toEqual({ tier: 1, mapsTo: "k8s:manifest" });
@@ -46,10 +46,64 @@ const asJson = (v: unknown): unknown => {
46
46
  };
47
47
  const json = (prop: string): FieldSpec => ({ prop, transform: asJson });
48
48
 
49
+ /**
50
+ * Terraform state renders a nested block as a one-element list. Take that
51
+ * entry (or the object itself, if the provider wrote it unwrapped).
52
+ */
53
+ function firstBlock(value: unknown): Record<string, unknown> | undefined {
54
+ const candidate = Array.isArray(value) ? value[0] : value;
55
+ return candidate && typeof candidate === "object" ? (candidate as Record<string, unknown>) : undefined;
56
+ }
57
+
58
+ /**
59
+ * A list of TF server-side-encryption `rule` blocks → the CFN `BucketEncryption`
60
+ * property. Shared by the bucket's own (deprecated, still state-resident)
61
+ * `server_side_encryption_configuration` block and by the modern
62
+ * `aws_s3_bucket_server_side_encryption_configuration` sub-resource, whose
63
+ * `rule` list has the same shape.
64
+ */
65
+ function sseRulesToCfn(rules: unknown): unknown {
66
+ if (!Array.isArray(rules)) return undefined;
67
+ const cfnRules: Array<Record<string, unknown>> = [];
68
+ for (const raw of rules) {
69
+ if (!raw || typeof raw !== "object") continue;
70
+ const rule = raw as Record<string, unknown>;
71
+ const out: Record<string, unknown> = {};
72
+ const byDefault = firstBlock(rule.apply_server_side_encryption_by_default);
73
+ const algorithm = byDefault?.sse_algorithm;
74
+ if (typeof algorithm === "string" && algorithm) {
75
+ const sse: Record<string, unknown> = { SSEAlgorithm: algorithm };
76
+ const kmsKey = byDefault?.kms_master_key_id;
77
+ if (typeof kmsKey === "string" && kmsKey) sse.KMSMasterKeyID = kmsKey;
78
+ out.ServerSideEncryptionByDefault = sse;
79
+ }
80
+ if (typeof rule.bucket_key_enabled === "boolean") out.BucketKeyEnabled = rule.bucket_key_enabled;
81
+ if (Object.keys(out).length) cfnRules.push(out);
82
+ }
83
+ return cfnRules.length ? { ServerSideEncryptionConfiguration: cfnRules } : undefined;
84
+ }
85
+
86
+ /**
87
+ * The bucket's own deprecated `versioning` block, as state still carries it.
88
+ * Only an enabled bucket says anything CloudFormation needs: a bucket that
89
+ * never had versioning has no `VersioningConfiguration` at all, so `enabled =
90
+ * false` maps to nothing and the block stays in the unmapped comment.
91
+ */
92
+ function legacyVersioningToCfn(value: unknown): unknown {
93
+ const block = firstBlock(value);
94
+ return block?.enabled === true ? { Status: "Enabled" } : undefined;
95
+ }
96
+
49
97
  export const AWS_CARVE_TYPES: AwsCarveType[] = [
50
98
  // ── Storage & data ──
51
99
  { tfType: "aws_s3_bucket", tier: 1, nativeType: "AWS::S3::Bucket", ctor: "Bucket", identityAttr: "bucket",
52
- fields: { bucket: "BucketName" }, tags: true },
100
+ fields: {
101
+ bucket: "BucketName",
102
+ // The provider still resolves these two into the bucket's own state, even
103
+ // when the config declares them through sub-resources (#1637).
104
+ server_side_encryption_configuration: { prop: "BucketEncryption", transform: (v) => sseRulesToCfn(firstBlock(v)?.rule) },
105
+ versioning: { prop: "VersioningConfiguration", transform: legacyVersioningToCfn },
106
+ }, tags: true },
53
107
  { tfType: "aws_dynamodb_table", tier: 2, nativeType: "AWS::DynamoDB::Table", ctor: "Table", identityAttr: "name",
54
108
  fields: { name: "TableName", billing_mode: "BillingMode" }, tags: true },
55
109
  { tfType: "aws_efs_file_system", tier: 1, nativeType: "AWS::EFS::FileSystem", ctor: "EFSFileSystem",
@@ -267,6 +321,91 @@ export function awsCarveType(tfType: string): AwsCarveType | undefined {
267
321
  return BY_TYPE.get(tfType);
268
322
  }
269
323
 
324
+ /**
325
+ * How a folded sub-resource (see `FOLDS_INTO`) joins its parent's emitted
326
+ * properties (#1637). Terraform splits configuration the CloudFormation shape
327
+ * keeps inside the parent resource, so the fold is not just a carve-set
328
+ * membership claim: the sub-resource's attributes have to land in the parent's
329
+ * props, or the emitted resource silently loses what the Terraform declared.
330
+ */
331
+ export interface AwsFoldMapper {
332
+ /** Sub-resource attributes this mapper reads. Anything else stays unmapped. */
333
+ consumes: string[];
334
+ /** The parent CFN properties this sub-resource contributes. */
335
+ map: (attrs: Record<string, unknown>) => Record<string, unknown>;
336
+ }
337
+
338
+ /** Identity and parent-link attributes: never content, never reported unmapped. */
339
+ const FOLD_LINK_ATTRS = new Set(["id", "arn", "bucket"]);
340
+
341
+ export const AWS_FOLD_MAPPERS: Record<string, AwsFoldMapper> = {
342
+ aws_s3_bucket_versioning: {
343
+ consumes: ["versioning_configuration"],
344
+ map: (attrs) => {
345
+ const status = firstBlock(attrs.versioning_configuration)?.status;
346
+ // CFN takes Enabled/Suspended only; TF's third state ("Disabled", write-once
347
+ // buckets) has no CloudFormation spelling and stays in the comment.
348
+ return status === "Enabled" || status === "Suspended" ? { VersioningConfiguration: { Status: status } } : {};
349
+ },
350
+ },
351
+ aws_s3_bucket_public_access_block: {
352
+ consumes: ["block_public_acls", "block_public_policy", "ignore_public_acls", "restrict_public_buckets"],
353
+ map: (attrs) => {
354
+ const config: Record<string, unknown> = {};
355
+ const flags: Array<[string, string]> = [
356
+ ["block_public_acls", "BlockPublicAcls"],
357
+ ["block_public_policy", "BlockPublicPolicy"],
358
+ ["ignore_public_acls", "IgnorePublicAcls"],
359
+ ["restrict_public_buckets", "RestrictPublicBuckets"],
360
+ ];
361
+ for (const [tfAttr, prop] of flags) {
362
+ if (typeof attrs[tfAttr] === "boolean") config[prop] = attrs[tfAttr];
363
+ }
364
+ return Object.keys(config).length ? { PublicAccessBlockConfiguration: config } : {};
365
+ },
366
+ },
367
+ aws_s3_bucket_server_side_encryption_configuration: {
368
+ consumes: ["rule"],
369
+ map: (attrs) => {
370
+ const encryption = sseRulesToCfn(attrs.rule);
371
+ return encryption ? { BucketEncryption: encryption } : {};
372
+ },
373
+ },
374
+ };
375
+
376
+ /**
377
+ * Apply a folded sub-resource's state attributes to its parent's properties.
378
+ *
379
+ * Returns the props it contributes plus the attributes that stay genuinely
380
+ * unmappable (which the emitted source preserves in its reference comment). A
381
+ * mapper that produced nothing consumes nothing — the caller reports the whole
382
+ * sub-resource rather than claiming a fold that did not happen. `null` means
383
+ * this sub-resource type has no fold mapping at all.
384
+ */
385
+ export function applyAwsFold(
386
+ tfType: string,
387
+ attrs: Record<string, unknown>,
388
+ ): { props: Record<string, unknown>; unmapped: Record<string, unknown> } | null {
389
+ const mapper = AWS_FOLD_MAPPERS[tfType];
390
+ if (!mapper) return null;
391
+ const props = mapper.map(attrs);
392
+ const consumed = new Set(Object.keys(props).length ? mapper.consumes : []);
393
+ return { props, unmapped: unmappedFoldAttrs(attrs, consumed) };
394
+ }
395
+
396
+ /** A folded sub-resource's attributes minus what was consumed and its parent link. */
397
+ export function unmappedFoldAttrs(
398
+ attrs: Record<string, unknown>,
399
+ consumed: ReadonlySet<string> = new Set(),
400
+ ): Record<string, unknown> {
401
+ const rest: Record<string, unknown> = {};
402
+ for (const [key, value] of Object.entries(attrs)) {
403
+ if (consumed.has(key) || FOLD_LINK_ATTRS.has(key)) continue;
404
+ rest[key] = value;
405
+ }
406
+ return rest;
407
+ }
408
+
270
409
  /** TF `tags` map → CloudFormation `Tags` list of {Key, Value}. */
271
410
  function tagsToCfn(tags: unknown): Array<{ Key: string; Value: unknown }> | undefined {
272
411
  if (!tags || typeof tags !== "object" || Array.isArray(tags)) return undefined;
@@ -291,8 +430,11 @@ export function applyAwsMapper(
291
430
  if (typeof spec === "string") {
292
431
  props[spec] = value;
293
432
  } else {
433
+ // A transform that declines (undefined) has mapped nothing — the attribute
434
+ // stays in the unmapped report rather than being claimed and dropped.
294
435
  const t = spec.transform(value);
295
- if (t !== undefined) props[spec.prop] = t;
436
+ if (t === undefined) continue;
437
+ props[spec.prop] = t;
296
438
  }
297
439
  mappedKeys.push(tfAttr);
298
440
  }
@@ -110,6 +110,36 @@ describe("generateBridge — inbound (data-source rewrite)", () => {
110
110
  });
111
111
  });
112
112
 
113
+ describe("generateBridge — output blocks (#1638)", () => {
114
+ const withOutput: Hcl2JsonTree = {
115
+ resource: { aws_s3_bucket: { assets: [{ bucket: "myapp-assets-prod" }] } },
116
+ output: { assets_bucket: [{ value: "${aws_s3_bucket.assets.bucket}" }] },
117
+ };
118
+ const OUTPUTS_TF = `output "assets_bucket" {\n value = aws_s3_bucket.assets.bucket\n}\n`;
119
+
120
+ test("an output-only dependency still gets a data source and a rewrite", () => {
121
+ const report = boundaryReport(buildFixtureGraph(withOutput), "aws_s3_bucket.assets")!;
122
+ const plan = generateBridge(report, [{ path: "outputs.tf", content: OUTPUTS_TF }], identities);
123
+
124
+ // Before #1638 the graph could not see the output, so this was an unpatched
125
+ // dependency: no data source, no rewrite, a broken plan at handoff.
126
+ expect(plan.dataSources.map((d) => d.address)).toEqual(["aws_s3_bucket.assets"]);
127
+ expect(plan.outputRewrites).toEqual(["output.assets_bucket"]);
128
+
129
+ const rewrite = plan.rewrites.find((r) => r.path === "outputs.tf")!;
130
+ expect(rewrite.changed).toBe(true);
131
+ expect(rewrite.rewritten).toContain("value = data.aws_s3_bucket.assets.bucket");
132
+ expect(plan.runbook).toContain("repoints these output block(s) at the data source: output.assets_bucket");
133
+ });
134
+
135
+ test("no outputs → nothing listed", () => {
136
+ const report = boundaryReport(buildFixtureGraph(workedExample), "aws_s3_bucket.assets")!;
137
+ const plan = generateBridge(report, [{ path: "api.tf", content: API_TF }], identities);
138
+ expect(plan.outputRewrites).toEqual([]);
139
+ expect(plan.runbook).not.toContain("repoints these output block(s) at the data source");
140
+ });
141
+ });
142
+
113
143
  describe("generateBridge — outbound (deferred inputs)", () => {
114
144
  test("records outbound edges as deferred deploy-time inputs", () => {
115
145
  const tree: Hcl2JsonTree = {