@intentius/chant 0.44.5 → 0.44.7

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 (42) 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 +64 -2
  5. package/dist/cli/commands/carve.d.ts.map +1 -1
  6. package/dist/terraform/adopt-state.d.ts +18 -1
  7. package/dist/terraform/adopt-state.d.ts.map +1 -1
  8. package/dist/terraform/aws-resources.d.ts +29 -0
  9. package/dist/terraform/aws-resources.d.ts.map +1 -1
  10. package/dist/terraform/bridge.d.ts +8 -0
  11. package/dist/terraform/bridge.d.ts.map +1 -1
  12. package/dist/terraform/carve.d.ts +8 -3
  13. package/dist/terraform/carve.d.ts.map +1 -1
  14. package/dist/terraform/graph.d.ts +10 -4
  15. package/dist/terraform/graph.d.ts.map +1 -1
  16. package/dist/terraform/parse.d.ts.map +1 -1
  17. package/dist/terraform/score.d.ts +11 -0
  18. package/dist/terraform/score.d.ts.map +1 -1
  19. package/dist/terraform/types.d.ts +11 -0
  20. package/dist/terraform/types.d.ts.map +1 -1
  21. package/package.json +1 -1
  22. package/src/cli/commands/carve-bridge.test.ts +32 -0
  23. package/src/cli/commands/carve-bridge.ts +3 -0
  24. package/src/cli/commands/carve-emit-state.test.ts +107 -0
  25. package/src/cli/commands/carve-emit.ts +37 -5
  26. package/src/cli/commands/carve.test.ts +82 -3
  27. package/src/cli/commands/carve.ts +81 -6
  28. package/src/terraform/__fixtures__/advise.test.ts +114 -1
  29. package/src/terraform/adopt-state.test.ts +64 -0
  30. package/src/terraform/adopt-state.ts +52 -2
  31. package/src/terraform/aws-resources.test.ts +102 -1
  32. package/src/terraform/aws-resources.ts +144 -2
  33. package/src/terraform/bridge.test.ts +30 -0
  34. package/src/terraform/bridge.ts +19 -1
  35. package/src/terraform/carve.test.ts +34 -0
  36. package/src/terraform/carve.ts +0 -0
  37. package/src/terraform/graph.test.ts +45 -0
  38. package/src/terraform/graph.ts +35 -16
  39. package/src/terraform/parse.ts +4 -1
  40. package/src/terraform/score.test.ts +25 -0
  41. package/src/terraform/score.ts +27 -4
  42. package/src/terraform/types.ts +11 -0
@@ -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 = {
@@ -8,6 +8,8 @@
8
8
  * Bridge: add a `data` source for the (now chant-managed) resource and
9
9
  * rewrite the survivor's `type.name.attr` references to `data.type.name.attr`.
10
10
  * Required immediately, or `terraform plan` errors on the dangling ref.
11
+ * An `output` block reading the carved resource is such a survivor (#1638):
12
+ * same data source, same textual rewrite, applied to the output's value.
11
13
  *
12
14
  * - outbound edge → the carved resource read a value from a survivor. That
13
15
  * value must enter chant from outside synthesis, as a deploy-time input.
@@ -62,6 +64,12 @@ export interface BridgePlan {
62
64
  deferredInputs: DeferredInput[];
63
65
  /** Every carved address whose own block the rewrites remove, across files. */
64
66
  excised: string[];
67
+ /**
68
+ * `output` blocks whose value the rewrites repoint at a data source (#1638),
69
+ * as `output.<name>`. They are inbound edges like any other — listed
70
+ * separately only because the patch is a one-line expression edit.
71
+ */
72
+ outputRewrites: string[];
65
73
  runbook: string;
66
74
  }
67
75
 
@@ -139,13 +147,19 @@ export function generateBridge(
139
147
  };
140
148
  });
141
149
 
150
+ const outputRewrites = report.inbound
151
+ .filter((e) => e.bridge === "tf-output-rewrite")
152
+ .map((e) => e.survivor)
153
+ .sort();
154
+
142
155
  return {
143
156
  target: report.target,
144
157
  dataSources,
145
158
  rewrites,
146
159
  deferredInputs,
147
160
  excised,
148
- runbook: buildRunbook(report, dataSources, deferredInputs, excised),
161
+ outputRewrites,
162
+ runbook: buildRunbook(report, dataSources, deferredInputs, excised, outputRewrites),
149
163
  };
150
164
  }
151
165
 
@@ -155,6 +169,7 @@ function buildRunbook(
155
169
  dataSources: DataSourceBlock[],
156
170
  deferred: DeferredInput[],
157
171
  excised: string[],
172
+ outputRewrites: string[],
158
173
  ): string {
159
174
  const carvedAddrs = report.carveSet.map((m) => m.address);
160
175
  const L: string[] = [];
@@ -180,6 +195,9 @@ function buildRunbook(
180
195
  } else if (!excised.length) {
181
196
  L.push(" # no inbound edges — no survivor patch needed.");
182
197
  }
198
+ if (outputRewrites.length) {
199
+ L.push(` # and repoints these output block(s) at the data source: ${outputRewrites.join(", ")}`);
200
+ }
183
201
  L.push(" terraform plan # expect: in-place updates to the survivors only");
184
202
  L.push(" terraform apply");
185
203
  L.push("");
@@ -87,6 +87,40 @@ describe("boundaryReport", () => {
87
87
  ]);
88
88
  });
89
89
 
90
+ test("an output block is an inbound edge with its own bridge kind (#1638)", () => {
91
+ const tree: Hcl2JsonTree = {
92
+ resource: { aws_s3_bucket: { assets: [{ bucket: "b" }] } },
93
+ output: { assets_bucket: [{ value: "${aws_s3_bucket.assets.bucket}" }] },
94
+ };
95
+ const report = boundaryReport(buildFixtureGraph(tree), "aws_s3_bucket.assets")!;
96
+ expect(report.inbound).toEqual([
97
+ {
98
+ direction: "inbound",
99
+ survivor: "output.assets_bucket",
100
+ carved: "aws_s3_bucket.assets",
101
+ attrs: ["bucket"],
102
+ via: ["value"],
103
+ bridge: "tf-output-rewrite",
104
+ required: "immediately",
105
+ },
106
+ ]);
107
+ expect(report.peelability).toBe(96); // the cheaper output weight, not 88
108
+ });
109
+
110
+ test("an output on a folded sub-resource is still the parent's boundary work", () => {
111
+ const tree: Hcl2JsonTree = {
112
+ resource: {
113
+ aws_s3_bucket: { assets: [{ bucket: "b" }] },
114
+ aws_s3_bucket_versioning: { assets: [{ bucket: "${aws_s3_bucket.assets.id}" }] },
115
+ },
116
+ output: { versioning_id: [{ value: "${aws_s3_bucket_versioning.assets.id}" }] },
117
+ };
118
+ const report = boundaryReport(buildFixtureGraph(tree), "aws_s3_bucket.assets")!;
119
+ expect(report.inbound.map((e) => [e.survivor, e.carved, e.bridge])).toEqual([
120
+ ["output.versioning_id", "aws_s3_bucket_versioning.assets", "tf-output-rewrite"],
121
+ ]);
122
+ });
123
+
90
124
  test("unsupported type is flagged in diagnostics", () => {
91
125
  const tree: Hcl2JsonTree = { resource: { random_pet: { n: [{ length: 2 }] } } };
92
126
  const report = boundaryReport(buildFixtureGraph(tree), "random_pet.n")!;
Binary file
@@ -157,6 +157,51 @@ describe("buildGraph", () => {
157
157
  expect(byAddr["aws_s3_bucket.assets"].identity).toBe("myapp-assets-prod"); // flat attr unaffected
158
158
  });
159
159
 
160
+ test("an output block referencing a resource is an inbound edge, tagged as an output (#1638)", () => {
161
+ const tree: Hcl2JsonTree = {
162
+ resource: { aws_s3_bucket: { assets: [{ bucket: "x" }] } },
163
+ output: {
164
+ assets_bucket: [{ value: "${aws_s3_bucket.assets.bucket}" }],
165
+ assets_pair: [{ value: ["${aws_s3_bucket.assets.arn}", "${aws_s3_bucket.assets.id}"], description: "both" }],
166
+ },
167
+ };
168
+ const g = buildFixtureGraph(tree);
169
+
170
+ // An output is a referrer, never a node — nothing carves an output.
171
+ expect(g.nodes.map((n) => n.address)).toEqual(["aws_s3_bucket.assets"]);
172
+ expect(inboundEdges(g, "aws_s3_bucket.assets")).toEqual([
173
+ {
174
+ from: "output.assets_bucket",
175
+ to: "aws_s3_bucket.assets",
176
+ attrs: ["bucket"],
177
+ via: ["value"],
178
+ fromKind: "output",
179
+ },
180
+ {
181
+ from: "output.assets_pair",
182
+ to: "aws_s3_bucket.assets",
183
+ attrs: ["arn", "id"],
184
+ via: ["value"],
185
+ fromKind: "output",
186
+ },
187
+ ]);
188
+ // Nothing can depend on an output in turn.
189
+ expect(outboundEdges(g, "output.assets_bucket").map((e) => e.to)).toEqual(["aws_s3_bucket.assets"]);
190
+ expect(inboundEdges(g, "output.assets_bucket")).toEqual([]);
191
+ });
192
+
193
+ test("an output referencing a var or a data source contributes no edge", () => {
194
+ const tree: Hcl2JsonTree = {
195
+ data: { aws_ami: { ubuntu: [{ owners: ["1"] }] } },
196
+ resource: { aws_s3_bucket: { assets: [{ bucket: "x" }] } },
197
+ output: {
198
+ name: [{ value: "${var.name}" }],
199
+ ami: [{ value: "${data.aws_ami.ubuntu.id}" }],
200
+ },
201
+ };
202
+ expect(buildFixtureGraph(tree).edges).toEqual([]);
203
+ });
204
+
160
205
  test("var / local references are not edges", () => {
161
206
  const tree: Hcl2JsonTree = {
162
207
  resource: {
@@ -81,9 +81,9 @@ export function refFromAccessor(accessor: string): RawRef | null {
81
81
  }
82
82
 
83
83
  /**
84
- * Every string value carrying an interpolation across the tree's resource and
85
- * module blocks — exactly the expressions `parse.ts` must resolve through the
86
- * AST before `buildGraph` can classify them.
84
+ * Every string value carrying an interpolation across the tree's resource,
85
+ * module and output blocks — exactly the expressions `parse.ts` must resolve
86
+ * through the AST before `buildGraph` can classify them.
87
87
  */
88
88
  export function collectExpressions(tree: Hcl2JsonTree): string[] {
89
89
  const exprs = new Set<string>();
@@ -98,6 +98,7 @@ export function collectExpressions(tree: Hcl2JsonTree): string[] {
98
98
  };
99
99
  for (const named of Object.values(tree.resource ?? {})) for (const blocks of Object.values(named)) visit(blocks);
100
100
  for (const blocks of Object.values(tree.module ?? {})) visit(blocks);
101
+ for (const blocks of Object.values(tree.output ?? {})) visit(blocks);
101
102
  return [...exprs].sort();
102
103
  }
103
104
 
@@ -167,6 +168,12 @@ function literalIdentity(block: unknown, type: string): string | undefined {
167
168
  * marks the referring node dynamic. An edge is recorded only when its target
168
169
  * resolves to a known resource/module node — references to `var`/`local`/data
169
170
  * are dropped.
171
+ *
172
+ * `output` blocks are not nodes either — nothing carves an output — but they
173
+ * do reference, and a reference to a carved resource breaks the surviving plan
174
+ * exactly like a resource's does. So an output contributes an edge tagged
175
+ * `fromKind: "output"` from the pseudo-address `output.<name>` (#1638),
176
+ * which the scorer weights lower and `carve bridge` patches.
170
177
  */
171
178
  export function buildGraph(tree: Hcl2JsonTree, exprRefs: ExpressionRefs): TfGraph {
172
179
  const nodes: TfNode[] = [];
@@ -216,22 +223,34 @@ export function buildGraph(tree: Hcl2JsonTree, exprRefs: ExpressionRefs): TfGrap
216
223
  });
217
224
  }
218
225
 
226
+ // Output blocks: referrers without being nodes (#1638). Their pseudo-address
227
+ // never joins `known`, so nothing can depend on an output in turn.
228
+ const outputRefs = new Map<string, RawRef[]>();
229
+ for (const [name, blocks] of Object.entries(tree.output ?? {})) {
230
+ const block = Array.isArray(blocks) ? blocks[0] : blocks;
231
+ outputRefs.set(`output.${name}`, refsInBlock(block, exprRefs));
232
+ }
233
+
219
234
  // Edges: keep only references that resolve to a known node.
220
235
  const known = new Set(nodes.map((n) => n.address));
221
236
  const edges: TfEdge[] = [];
222
- for (const [from, refs] of rawRefsByNode) {
223
- const byTarget = new Map<string, { attrs: Set<string>; via: Set<string> }>();
224
- for (const ref of refs) {
225
- if (ref.address === from || !known.has(ref.address)) continue;
226
- if (!byTarget.has(ref.address)) byTarget.set(ref.address, { attrs: new Set(), via: new Set() });
227
- const target = byTarget.get(ref.address)!;
228
- if (ref.attr) target.attrs.add(ref.attr);
229
- if (ref.via) target.via.add(ref.via);
230
- }
231
- for (const [to, { attrs, via }] of byTarget) {
232
- edges.push({ from, to, attrs: [...attrs].sort(), via: [...via].sort() });
237
+ const collect = (source: Map<string, RawRef[]>, fromKind?: "output"): void => {
238
+ for (const [from, refs] of source) {
239
+ const byTarget = new Map<string, { attrs: Set<string>; via: Set<string> }>();
240
+ for (const ref of refs) {
241
+ if (ref.address === from || !known.has(ref.address)) continue;
242
+ if (!byTarget.has(ref.address)) byTarget.set(ref.address, { attrs: new Set(), via: new Set() });
243
+ const target = byTarget.get(ref.address)!;
244
+ if (ref.attr) target.attrs.add(ref.attr);
245
+ if (ref.via) target.via.add(ref.via);
246
+ }
247
+ for (const [to, { attrs, via }] of byTarget) {
248
+ edges.push({ from, to, attrs: [...attrs].sort(), via: [...via].sort(), ...(fromKind ? { fromKind } : {}) });
249
+ }
233
250
  }
234
- }
251
+ };
252
+ collect(rawRefsByNode);
253
+ collect(outputRefs, "output");
235
254
 
236
255
  // Code-point ordering (not localeCompare) so output is locale-independent and
237
256
  // punctuation sorts predictably (`.` < `_`).
@@ -242,7 +261,7 @@ export function buildGraph(tree: Hcl2JsonTree, exprRefs: ExpressionRefs): TfGrap
242
261
  };
243
262
  }
244
263
 
245
- /** Edges where other nodes depend on `address` (each → a surviving-TF data-source patch). */
264
+ /** Edges where something in the surviving Terraform depends on `address`. */
246
265
  export function inboundEdges(graph: TfGraph, address: string): TfEdge[] {
247
266
  return graph.edges.filter((e) => e.to === address);
248
267
  }
@@ -68,7 +68,7 @@ async function resolveExpressionRefs(hcl2json: Hcl2Json, tree: Hcl2JsonTree): Pr
68
68
  return refs;
69
69
  }
70
70
 
71
- /** Deep-merge hcl2json trees across files (resource/module/data namespaces). */
71
+ /** Deep-merge hcl2json trees across files (resource/module/data/output namespaces). */
72
72
  function mergeTrees(into: Hcl2JsonTree, next: Hcl2JsonTree): void {
73
73
  for (const section of ["resource", "data"] as const) {
74
74
  const src = next[section];
@@ -79,6 +79,9 @@ function mergeTrees(into: Hcl2JsonTree, next: Hcl2JsonTree): void {
79
79
  }
80
80
  }
81
81
  if (next.module) into.module = { ...(into.module ?? {}), ...next.module };
82
+ // Outputs usually live in their own file (#1638) — merge them, or the graph
83
+ // never sees the estate's outputs.tf at all.
84
+ if (next.output) into.output = { ...(into.output ?? {}), ...next.output };
82
85
  }
83
86
 
84
87
  /** List every `.tf` file directly under `dir` (non-recursive; matches Terraform's own module scoping). */
@@ -115,6 +115,31 @@ describe("scoreEstate — penalties", () => {
115
115
  expect(map["aws_sns_topic.alerts"].score).toBe(88); // 100 - 12*1 inbound
116
116
  });
117
117
 
118
+ test("an output block reading a resource costs 4, not 12 (#1638)", () => {
119
+ const tree: Hcl2JsonTree = {
120
+ resource: { aws_s3_bucket: { assets: [{ bucket: "b" }] } },
121
+ output: { assets_bucket: [{ value: "${aws_s3_bucket.assets.bucket}" }] },
122
+ };
123
+ const bucket = byAddress(scoreEstate(buildFixtureGraph(tree)))["aws_s3_bucket.assets"];
124
+ expect(bucket.score).toBe(96); // 100 - 4*1 output
125
+ expect(bucket.breakdown.outputs).toBe(1);
126
+ expect(bucket.breakdown.inbound).toBe(0); // not counted as a data-source patch
127
+ expect(bucket.breakdown.penalties.outputs).toBe(-4);
128
+ });
129
+
130
+ test("an output and a resource reference are counted apart", () => {
131
+ const tree: Hcl2JsonTree = {
132
+ resource: {
133
+ aws_s3_bucket: { assets: [{ bucket: "b" }] },
134
+ aws_lambda_function: { api: [{ environment: { variables: { B: "${aws_s3_bucket.assets.bucket}" } } }] },
135
+ },
136
+ output: { assets_arn: [{ value: "${aws_s3_bucket.assets.arn}" }] },
137
+ };
138
+ const bucket = byAddress(scoreEstate(buildFixtureGraph(tree)))["aws_s3_bucket.assets"];
139
+ expect(bucket.breakdown).toMatchObject({ inbound: 1, outputs: 1 });
140
+ expect(bucket.score).toBe(84); // 100 - 12 - 4
141
+ });
142
+
118
143
  test("results are ranked most-peelable first", () => {
119
144
  const tree: Hcl2JsonTree = {
120
145
  resource: {