@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
@@ -17,9 +17,15 @@ import { basename, join, resolve } from "path";
17
17
  import { parseTerraformDir, Hcl2JsonNotInstalled } from "../../terraform/parse";
18
18
  import { boundaryReport, deferredParamName, type CarveReport } from "../../terraform/carve";
19
19
  import { resolveTier } from "../../terraform/tier-map";
20
- import { readStateResource } from "../../terraform/state";
20
+ import { readStateResource, type StateResource } from "../../terraform/state";
21
21
  import { writeCarveManifest, type CarveManifest } from "../../terraform/manifest";
22
- import { adoptFromState, canAdoptFromState, supportedStateAdoptionTypes, type DeferredParam } from "../../terraform/adopt-state";
22
+ import {
23
+ adoptFromState,
24
+ canAdoptFromState,
25
+ supportedStateAdoptionTypes,
26
+ type DeferredParam,
27
+ type FoldedContribution,
28
+ } from "../../terraform/adopt-state";
23
29
  import { getChantVersion } from "./init";
24
30
  import type { LexiconPlugin, ResourceSelector } from "../../lexicon";
25
31
  import type { ImportResult, LiveImportOptions } from "./import";
@@ -67,6 +73,8 @@ export interface CarveEmitResult {
67
73
  scaffolded?: string[];
68
74
  /** Deferred outbound inputs declared as build parameters in the emitted project (#998). */
69
75
  params?: DeferredParam[];
76
+ /** Folded sub-resources and the props each joined into the emitted parent (#1637). */
77
+ folded?: FoldedContribution[];
70
78
  /** The persisted carve state manifest bridge/apply compose with. */
71
79
  manifestPath?: string;
72
80
  }
@@ -125,7 +133,13 @@ export async function carveEmit(opts: CarveEmitOptions, deps: CarveEmitDeps): Pr
125
133
  // value the carved block read enters the emitted project as a declared
126
134
  // param, defaulted to the value the state resolved.
127
135
  const params = deferredParams(report, stateResource.attributes);
128
- const adopted = adoptFromState(stateResource, params);
136
+ // The carve set's folded sub-resources come out of the same state file:
137
+ // their mappable attributes belong in the parent's emitted props (#1637).
138
+ const folded = report.carveSet
139
+ .filter((m) => m.foldedInto)
140
+ .map((m) => readStateResource(opts.statePath!, m.address))
141
+ .filter((r): r is StateResource => r !== null);
142
+ const adopted = adoptFromState(stateResource, params, folded);
129
143
  if (!adopted) return { ok: false, error: `Could not adopt ${opts.select} from state.` };
130
144
 
131
145
  // The output dir is a buildable chant project: source in src/, plus the
@@ -139,7 +153,16 @@ export async function carveEmit(opts: CarveEmitOptions, deps: CarveEmitDeps): Pr
139
153
  const scaffolded = scaffoldProject(outDir, lexicon, params);
140
154
 
141
155
  const manifestPath = persistManifest(outDir, opts, report, tfType, "tfstate", [outPath], params);
142
- return { ok: true, report, source: "tfstate", emittedFiles: [outPath], scaffolded, manifestPath, params };
156
+ return {
157
+ ok: true,
158
+ report,
159
+ source: "tfstate",
160
+ emittedFiles: [outPath],
161
+ scaffolded,
162
+ manifestPath,
163
+ params,
164
+ folded: adopted.folded,
165
+ };
143
166
  }
144
167
 
145
168
  // ── Adoption path 2: live import (cloud→code) ──
@@ -335,7 +358,16 @@ export function formatCarveEmit(result: CarveEmitResult): string {
335
358
  lines.push(` State manifest: ${result.manifestPath} — carve bridge/apply pick the target up from here.`);
336
359
  }
337
360
  if (r.carveSet.length > 1) {
338
- const folded = r.carveSet.filter((m) => m.foldedInto).map((m) => m.address);
361
+ // Say what each fold actually contributed — announcing the fold without the
362
+ // properties was the bug behind #1637.
363
+ const contributed = new Map((result.folded ?? []).map((f) => [f.address, f.props]));
364
+ const folded = r.carveSet
365
+ .filter((m) => m.foldedInto)
366
+ .map((m) => {
367
+ const props = contributed.get(m.address);
368
+ if (props === undefined) return m.address;
369
+ return props.length ? `${m.address} (${props.join(", ")})` : `${m.address} (nothing mappable)`;
370
+ });
339
371
  lines.push(` Folded in: ${folded.join(", ")}`);
340
372
  }
341
373
  lines.push("");
@@ -2,7 +2,7 @@ import { describe, test, expect } from "vitest";
2
2
  import { mkdtempSync, writeFileSync, rmSync, readFileSync } from "fs";
3
3
  import { tmpdir } from "os";
4
4
  import { join } from "path";
5
- import { carveAdvise, carveJson, formatCarveReport } from "./carve";
5
+ import { carveAdvise, carveJson, formatCarveReport, CARVE_REPORT_VERSION } from "./carve";
6
6
  import { loadHcl2json } from "../../terraform/parse";
7
7
 
8
8
  let parserAvailable = false;
@@ -78,12 +78,65 @@ describe("carveAdvise", () => {
78
78
 
79
79
  // report file is valid JSON with the advisory banner + band counts
80
80
  const payload = JSON.parse(readFileSync(reportFile, "utf-8"));
81
+ expect(payload.version).toBe(CARVE_REPORT_VERSION);
81
82
  expect(payload.advisory).toContain("read-only");
82
83
  expect(payload.bands["clean leaf"]).toBeGreaterThanOrEqual(1);
83
84
  expect(payload.count).toBe(r.results!.length);
85
+
86
+ // The written report carries the edge lists, not just the counts (#1636).
87
+ const written = payload.resources.find((x: { address: string }) => x.address === "aws_s3_bucket.assets");
88
+ expect(written.boundary.inbound).toEqual([
89
+ {
90
+ direction: "inbound",
91
+ survivor: "aws_lambda_function.api",
92
+ carved: "aws_s3_bucket.assets",
93
+ attrs: ["bucket"],
94
+ via: ["environment"],
95
+ bridge: "tf-data-source",
96
+ required: "immediately",
97
+ },
98
+ ]);
99
+ expect(written.boundary.outbound).toEqual([]);
84
100
  });
85
101
  });
86
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
+
87
140
  test("formatCarveReport groups by band and never suggests a mutation", async () => {
88
141
  if (!parserAvailable) return;
89
142
  await withEstate(async (dir) => {
@@ -96,9 +149,35 @@ describe("carveAdvise", () => {
96
149
  });
97
150
  });
98
151
 
99
- test("carveJson carries the read-only advisory banner", () => {
100
- const payload = carveJson({ ok: true, from: "x", results: [] }) as { advisory: string; count: number };
152
+ test("carveJson carries the read-only advisory banner and the schema version", () => {
153
+ const payload = carveJson({ ok: true, from: "x", results: [] });
101
154
  expect(payload.advisory).toContain("read-only");
102
155
  expect(payload.count).toBe(0);
156
+ expect(payload.version).toBe(1);
157
+ });
158
+
159
+ test("carveJson omits boundary entirely with no graph — 'none' and 'not reported' differ", () => {
160
+ const payload = carveJson({
161
+ ok: true,
162
+ from: "x",
163
+ results: [
164
+ {
165
+ address: "aws_vpc.main",
166
+ kind: "resource",
167
+ score: 100,
168
+ band: "clean leaf",
169
+ breakdown: {
170
+ inbound: 0,
171
+ outbound: 0,
172
+ outputs: 0,
173
+ tier: 1,
174
+ hasDynamic: false,
175
+ instances: 1,
176
+ penalties: { inbound: 0, outbound: 0, outputs: 0, tier: 0, dynamic: 0, instances: 0 },
177
+ },
178
+ },
179
+ ],
180
+ });
181
+ expect(payload.resources[0]).not.toHaveProperty("boundary");
103
182
  });
104
183
  });
@@ -10,6 +10,8 @@
10
10
  import { existsSync, statSync, writeFileSync } from "fs";
11
11
  import { parseTerraformDir, Hcl2JsonNotInstalled } from "../../terraform/parse";
12
12
  import { scoreEstate, type Peelability, type PeelabilityBand } from "../../terraform/score";
13
+ import { boundaryReport, type BoundaryEdge } from "../../terraform/carve";
14
+ import type { TfGraph } from "../../terraform/types";
13
15
 
14
16
  export interface CarveAdviseOptions {
15
17
  /** Terraform estate directory (from `--from`). */
@@ -25,6 +27,12 @@ export interface CarveAdviseResult {
25
27
  error?: string;
26
28
  from?: string;
27
29
  results?: Peelability[];
30
+ /**
31
+ * The parsed dependency graph the scores came from. Kept so the JSON report
32
+ * can carry the boundary edge lists (#1636) rather than only their counts.
33
+ * Not part of the JSON payload — `carveJson` derives from it.
34
+ */
35
+ graph?: TfGraph;
28
36
  }
29
37
 
30
38
  const BAND_ORDER: PeelabilityBand[] = ["clean leaf", "carvable w/ edits", "leave in Terraform"];
@@ -40,36 +48,102 @@ export async function carveAdvise(opts: CarveAdviseOptions): Promise<CarveAdvise
40
48
  return { ok: false, error: `State file not found: ${opts.statePath}` };
41
49
  }
42
50
 
51
+ let graph: TfGraph;
43
52
  let results: Peelability[];
44
53
  try {
45
- results = scoreEstate(await parseTerraformDir(opts.from, { statePath: opts.statePath }));
54
+ graph = await parseTerraformDir(opts.from, { statePath: opts.statePath });
55
+ results = scoreEstate(graph);
46
56
  } catch (err) {
47
57
  if (err instanceof Hcl2JsonNotInstalled) return { ok: false, error: err.message };
48
58
  return { ok: false, error: `Failed to parse Terraform in ${opts.from}: ${err instanceof Error ? err.message : String(err)}` };
49
59
  }
50
60
 
61
+ const result: CarveAdviseResult = { ok: true, from: opts.from, results, graph };
51
62
  if (opts.reportFile) {
52
- writeFileSync(opts.reportFile, JSON.stringify(carveJson({ ok: true, from: opts.from, results }), null, 2));
63
+ writeFileSync(opts.reportFile, JSON.stringify(carveJson(result), null, 2));
53
64
  }
54
65
 
55
- return { ok: true, from: opts.from, results };
66
+ return result;
56
67
  }
57
68
 
58
- /** The `--json` / `--report` payload. */
59
- export function carveJson(result: CarveAdviseResult): unknown {
69
+ /**
70
+ * The schema version of the `--json` / `--report` payload (#1636).
71
+ *
72
+ * The report is a cross-tool contract — behold renders it as a graph — so it
73
+ * says which shape it is. The promise attached to this number:
74
+ *
75
+ * - **Additive within a version.** New top-level fields, new per-resource
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.
79
+ * - **A removal, a rename, or a changed meaning bumps it.** So does narrowing
80
+ * a field's type (an optional becoming required is additive; the reverse is
81
+ * not).
82
+ * - A reader that does not know the version it is handed should refuse the
83
+ * report rather than half-read it.
84
+ */
85
+ export const CARVE_REPORT_VERSION = 1;
86
+
87
+ /** One ranked resource in the JSON report: its score, plus the boundary its carve would cut. */
88
+ export interface CarveJsonResource extends Peelability {
89
+ /**
90
+ * Every dependency edge carving this resource would cut (#1636), in chant's
91
+ * own `BoundaryEdge` shape — the same classification the emit/bridge path
92
+ * runs on. `inbound` edges need a Terraform `data`-source patch the moment
93
+ * the carve lands; `outbound` edges become deploy-time inputs, deferred
94
+ * until apply.
95
+ *
96
+ * The lists are the *carve set's* boundary, so a folded sub-resource never
97
+ * appears as an endpoint: it carves with its parent, and the parent's edges
98
+ * stand in for it. Edges internal to the carve set are not boundary work and
99
+ * are not listed.
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
+ *
105
+ * Present (possibly with two empty lists) whenever the graph was available;
106
+ * absent means this chant did not compute it — "none" and "not reported" are
107
+ * different claims. `breakdown.inbound`/`outbound` keep the counts.
108
+ */
109
+ boundary?: { inbound: BoundaryEdge[]; outbound: BoundaryEdge[] };
110
+ }
111
+
112
+ /** The `chant carve advise --json` / `--report` payload. Versioned; see {@link CARVE_REPORT_VERSION}. */
113
+ export interface CarveJsonReport {
114
+ version: number;
115
+ from?: string;
116
+ advisory: string;
117
+ count: number;
118
+ /** Band name -> how many resources landed in it. */
119
+ bands: Record<string, number>;
120
+ resources: CarveJsonResource[];
121
+ }
122
+
123
+ /** Build the `--json` / `--report` payload. */
124
+ export function carveJson(result: CarveAdviseResult): CarveJsonReport {
60
125
  const results = result.results ?? [];
61
126
  const counts = Object.fromEntries(
62
127
  BAND_ORDER.map((b) => [b, results.filter((r) => r.band === b).length]),
63
128
  );
64
129
  return {
130
+ version: CARVE_REPORT_VERSION,
65
131
  from: result.from,
66
132
  advisory: "read-only — emits nothing, patches nothing, touches no live resource",
67
133
  count: results.length,
68
134
  bands: counts,
69
- resources: results,
135
+ resources: results.map((r) => withBoundary(r, result.graph)),
70
136
  };
71
137
  }
72
138
 
139
+ /** A scored resource plus its carve boundary, when the graph is at hand (#1636). */
140
+ function withBoundary(r: Peelability, graph?: TfGraph): CarveJsonResource {
141
+ if (!graph) return r;
142
+ const report = boundaryReport(graph, r.address);
143
+ if (!report) return r;
144
+ return { ...r, boundary: { inbound: report.inbound, outbound: report.outbound } };
145
+ }
146
+
73
147
  /** Human-readable banded, ranked summary. */
74
148
  export function formatCarveReport(result: CarveAdviseResult): string {
75
149
  const results = result.results ?? [];
@@ -115,6 +189,7 @@ function reasons(r: Peelability): string {
115
189
  if (b.tier === null) return "no known native mapping (unsupported provider/type)";
116
190
  const parts: string[] = [];
117
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)`);
118
193
  if (b.outbound) parts.push(`${b.outbound} outbound (deferred input each)`);
119
194
  if (b.tier > 1) parts.push(`tier ${b.tier} map`);
120
195
  if (b.hasDynamic) parts.push("count/for_each/data present");
@@ -1,6 +1,12 @@
1
1
  import { describe, test, expect } from "vitest";
2
2
  import { join } from "path";
3
- import { carveAdvise, formatCarveReport } from "../../cli/commands/carve";
3
+ import {
4
+ carveAdvise,
5
+ carveJson,
6
+ formatCarveReport,
7
+ CARVE_REPORT_VERSION,
8
+ type CarveJsonResource,
9
+ } from "../../cli/commands/carve";
4
10
  import { loadHcl2json } from "../parse";
5
11
 
6
12
  /**
@@ -49,3 +55,110 @@ describe("carve advise against the sample estate", () => {
49
55
  expect(text).not.toMatch(/state rm|apply|destroy/i);
50
56
  });
51
57
  });
58
+
59
+ /**
60
+ * The JSON payload as a cross-tool contract (#1636): a schema version, and the
61
+ * boundary edge LISTS beside the counts. behold's carve lens renders these —
62
+ * the counts alone can't be paired back into edges (this estate has 4 inbound
63
+ * and 4 outbound, which admits several matchings), so the lists are the thing
64
+ * that makes a predicted diff drawable.
65
+ */
66
+ describe("carve advise --json against the sample estate", () => {
67
+ test("is a versioned report whose resources carry their boundary edges", async () => {
68
+ if (!parserAvailable) return;
69
+ const report = carveJson(await carveAdvise({ from: ESTATE }));
70
+
71
+ expect(report.version).toBe(CARVE_REPORT_VERSION);
72
+ expect(report.count).toBe(8);
73
+ expect(report.resources).toHaveLength(8);
74
+
75
+ const byAddr = Object.fromEntries(report.resources.map((x) => [x.address, x]));
76
+
77
+ // The VPC's three subnets: each one a data-source patch to surviving TF.
78
+ expect(byAddr["aws_vpc.main"].boundary!.inbound).toEqual([
79
+ { direction: "inbound", survivor: "aws_subnet.a", carved: "aws_vpc.main", attrs: ["id"], via: ["vpc_id"], bridge: "tf-data-source", required: "immediately" },
80
+ { direction: "inbound", survivor: "aws_subnet.b", carved: "aws_vpc.main", attrs: ["id"], via: ["vpc_id"], bridge: "tf-data-source", required: "immediately" },
81
+ { direction: "inbound", survivor: "aws_subnet.c", carved: "aws_vpc.main", attrs: ["id"], via: ["vpc_id"], bridge: "tf-data-source", required: "immediately" },
82
+ ]);
83
+ expect(byAddr["aws_vpc.main"].boundary!.outbound).toEqual([]);
84
+
85
+ // The same cut, seen from the other end: the subnet's carve defers a value.
86
+ expect(byAddr["aws_subnet.a"].boundary!.outbound).toEqual([
87
+ { direction: "outbound", survivor: "aws_vpc.main", carved: "aws_subnet.a", attrs: ["id"], via: ["vpc_id"], bridge: "deferred-input", required: "at-apply" },
88
+ ]);
89
+
90
+ // A clean leaf nothing touches reports two empty lists, not a missing key:
91
+ // "none" is a claim the advisor is willing to make.
92
+ expect(byAddr["aws_cloudwatch_log_group.api"].boundary).toEqual({ inbound: [], outbound: [] });
93
+
94
+ // The Lambda reads two of the bucket's attributes — one edge, both attrs.
95
+ expect(byAddr["aws_lambda_function.api"].boundary!.outbound).toEqual([
96
+ { direction: "outbound", survivor: "aws_s3_bucket.assets", carved: "aws_lambda_function.api", attrs: ["arn", "bucket"], via: ["environment"], bridge: "deferred-input", required: "at-apply" },
97
+ ]);
98
+ });
99
+
100
+ test("every edge endpoint is a real Terraform address in the estate", async () => {
101
+ if (!parserAvailable) return;
102
+ const report = carveJson(await carveAdvise({ from: ESTATE }));
103
+ const ranked = new Set(report.resources.map((r) => r.address));
104
+
105
+ for (const edge of allEdges(report.resources)) {
106
+ // The carved side is always ranked — it is the resource reporting it.
107
+ expect(ranked.has(edge.carved)).toBe(true);
108
+ // The survivor side is a real address too. Every survivor in this estate
109
+ // is itself carvable, so it is ranked; in general a survivor may be an
110
+ // unranked address (a folded sub-resource's parent is never one, but an
111
+ // unsupported type is), which is why this checks the estate's own set.
112
+ expect(ranked.has(edge.survivor)).toBe(true);
113
+ expect(edge.survivor).not.toBe(edge.carved);
114
+ }
115
+ });
116
+
117
+ test("a folded sub-resource is never an edge endpoint — it carves with its parent", async () => {
118
+ if (!parserAvailable) return;
119
+ const report = carveJson(await carveAdvise({ from: ESTATE }));
120
+ const endpoints = allEdges(report.resources).flatMap((e) => [e.survivor, e.carved]);
121
+
122
+ // `aws_s3_bucket_versioning.assets` references its bucket, but that edge is
123
+ // internal to the bucket's carve set: not boundary work, and not an endpoint.
124
+ expect(endpoints).not.toContain("aws_s3_bucket_versioning.assets");
125
+ expect(report.resources.map((r) => r.address)).not.toContain("aws_s3_bucket_versioning.assets");
126
+ });
127
+
128
+ test("the counts and the edge lists tell the same story", async () => {
129
+ if (!parserAvailable) return;
130
+ const report = carveJson(await carveAdvise({ from: ESTATE }));
131
+
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.
137
+ for (const r of report.resources) {
138
+ expect([r.address, r.boundary!.inbound.length]).toEqual([r.address, r.breakdown.inbound + r.breakdown.outputs]);
139
+ expect([r.address, r.boundary!.outbound.length]).toEqual([r.address, r.breakdown.outbound]);
140
+ }
141
+
142
+ const inbound = report.resources.reduce((s, r) => s + r.breakdown.inbound, 0);
143
+ const outbound = report.resources.reduce((s, r) => s + r.breakdown.outbound, 0);
144
+ expect([inbound, outbound]).toEqual([4, 4]);
145
+
146
+ // Each cut is reported from both ends — inbound from the depended-on side,
147
+ // outbound from the depending side — so the two totals are the same set of
148
+ // edges seen twice. Keyed in dependency direction, that is 4 distinct cuts.
149
+ const cuts = new Set(
150
+ allEdges(report.resources).map((e) =>
151
+ e.direction === "inbound" ? `${e.survivor} -> ${e.carved}` : `${e.carved} -> ${e.survivor}`,
152
+ ),
153
+ );
154
+ expect([...cuts].sort()).toEqual([
155
+ "aws_lambda_function.api -> aws_s3_bucket.assets",
156
+ "aws_subnet.a -> aws_vpc.main",
157
+ "aws_subnet.b -> aws_vpc.main",
158
+ "aws_subnet.c -> aws_vpc.main",
159
+ ]);
160
+ });
161
+ });
162
+
163
+ const allEdges = (resources: CarveJsonResource[]) =>
164
+ resources.flatMap((r) => [...(r.boundary?.inbound ?? []), ...(r.boundary?.outbound ?? [])]);
@@ -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