@intentius/chant 0.41.19 → 0.42.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (150) hide show
  1. package/dist/cli/build-params-cli.d.ts +7 -4
  2. package/dist/cli/build-params-cli.d.ts.map +1 -1
  3. package/dist/cli/commands/build.d.ts.map +1 -1
  4. package/dist/cli/commands/carve-apply.d.ts +9 -0
  5. package/dist/cli/commands/carve-apply.d.ts.map +1 -1
  6. package/dist/cli/commands/carve-bridge.d.ts +7 -0
  7. package/dist/cli/commands/carve-bridge.d.ts.map +1 -1
  8. package/dist/cli/commands/carve-emit.d.ts +7 -0
  9. package/dist/cli/commands/carve-emit.d.ts.map +1 -1
  10. package/dist/cli/commands/import.d.ts +16 -0
  11. package/dist/cli/commands/import.d.ts.map +1 -1
  12. package/dist/cli/commands/init.d.ts +2 -0
  13. package/dist/cli/commands/init.d.ts.map +1 -1
  14. package/dist/cli/handlers/carve-apply.d.ts.map +1 -1
  15. package/dist/cli/handlers/components.d.ts.map +1 -1
  16. package/dist/cli/handlers/explain.d.ts +11 -0
  17. package/dist/cli/handlers/explain.d.ts.map +1 -0
  18. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  19. package/dist/cli/handlers/misc.d.ts.map +1 -1
  20. package/dist/cli/main.d.ts.map +1 -1
  21. package/dist/cli/mcp/tools/explain.d.ts.map +1 -1
  22. package/dist/cli/registry.d.ts +4 -0
  23. package/dist/cli/registry.d.ts.map +1 -1
  24. package/dist/codegen/docs-rule-scanning.d.ts +11 -0
  25. package/dist/codegen/docs-rule-scanning.d.ts.map +1 -1
  26. package/dist/codegen/okf-lexicon.d.ts +36 -0
  27. package/dist/codegen/okf-lexicon.d.ts.map +1 -0
  28. package/dist/codegen/package.d.ts +2 -1
  29. package/dist/codegen/package.d.ts.map +1 -1
  30. package/dist/components/deploy-units.d.ts.map +1 -1
  31. package/dist/components/verbs/sbom.d.ts +1 -1
  32. package/dist/components/verbs/sbom.d.ts.map +1 -1
  33. package/dist/components/verbs/vuln-gate.d.ts +15 -4
  34. package/dist/components/verbs/vuln-gate.d.ts.map +1 -1
  35. package/dist/components/verbs/vuln-scan.d.ts +18 -6
  36. package/dist/components/verbs/vuln-scan.d.ts.map +1 -1
  37. package/dist/config.d.ts +12 -0
  38. package/dist/config.d.ts.map +1 -1
  39. package/dist/deep-observation.d.ts +13 -0
  40. package/dist/deep-observation.d.ts.map +1 -1
  41. package/dist/governance.d.ts +37 -0
  42. package/dist/governance.d.ts.map +1 -0
  43. package/dist/lexicon.d.ts +3 -0
  44. package/dist/lexicon.d.ts.map +1 -1
  45. package/dist/lifecycle/observe.d.ts.map +1 -1
  46. package/dist/lifecycle/snapshot.d.ts.map +1 -1
  47. package/dist/okf.d.ts +57 -0
  48. package/dist/okf.d.ts.map +1 -0
  49. package/dist/reconcile.d.ts +15 -0
  50. package/dist/reconcile.d.ts.map +1 -1
  51. package/dist/terraform/__fixtures__/build-graph.d.ts +13 -0
  52. package/dist/terraform/__fixtures__/build-graph.d.ts.map +1 -0
  53. package/dist/terraform/adopt-state.d.ts +33 -1
  54. package/dist/terraform/adopt-state.d.ts.map +1 -1
  55. package/dist/terraform/bridge.d.ts +7 -1
  56. package/dist/terraform/bridge.d.ts.map +1 -1
  57. package/dist/terraform/carve.d.ts +12 -0
  58. package/dist/terraform/carve.d.ts.map +1 -1
  59. package/dist/terraform/excise.d.ts +31 -0
  60. package/dist/terraform/excise.d.ts.map +1 -0
  61. package/dist/terraform/graduate.d.ts +12 -0
  62. package/dist/terraform/graduate.d.ts.map +1 -1
  63. package/dist/terraform/graph.d.ts +35 -5
  64. package/dist/terraform/graph.d.ts.map +1 -1
  65. package/dist/terraform/manifest.d.ts +89 -0
  66. package/dist/terraform/manifest.d.ts.map +1 -0
  67. package/dist/terraform/parse.d.ts +9 -4
  68. package/dist/terraform/parse.d.ts.map +1 -1
  69. package/dist/terraform/types.d.ts +7 -0
  70. package/dist/terraform/types.d.ts.map +1 -1
  71. package/dist/terraform/unified-diff.d.ts +11 -0
  72. package/dist/terraform/unified-diff.d.ts.map +1 -0
  73. package/package.json +1 -1
  74. package/src/__snapshots__/okf.test.ts.snap +381 -0
  75. package/src/cli/build-params-cli.test.ts +47 -3
  76. package/src/cli/build-params-cli.ts +22 -5
  77. package/src/cli/commands/build.test.ts +44 -0
  78. package/src/cli/commands/build.ts +15 -2
  79. package/src/cli/commands/carve-apply.test.ts +94 -2
  80. package/src/cli/commands/carve-apply.ts +62 -11
  81. package/src/cli/commands/carve-bridge.test.ts +82 -4
  82. package/src/cli/commands/carve-bridge.ts +79 -11
  83. package/src/cli/commands/carve-emit-state.test.ts +110 -1
  84. package/src/cli/commands/carve-emit.ts +184 -8
  85. package/src/cli/commands/import.ts +53 -5
  86. package/src/cli/commands/init.ts +1 -1
  87. package/src/cli/handlers/build.test.ts +3 -1
  88. package/src/cli/handlers/carve-apply.ts +1 -0
  89. package/src/cli/handlers/components.ts +4 -2
  90. package/src/cli/handlers/explain.test.ts +93 -0
  91. package/src/cli/handlers/explain.ts +60 -0
  92. package/src/cli/handlers/lifecycle.ts +9 -7
  93. package/src/cli/handlers/misc.ts +40 -1
  94. package/src/cli/handlers/run.test.ts +3 -1
  95. package/src/cli/main.ts +25 -5
  96. package/src/cli/mcp/server.test.ts +17 -0
  97. package/src/cli/mcp/tools/explain.ts +15 -3
  98. package/src/cli/registry.ts +4 -0
  99. package/src/codegen/__snapshots__/okf-lexicon.test.ts.snap +170 -0
  100. package/src/codegen/docs-rule-scanning.ts +35 -14
  101. package/src/codegen/okf-lexicon.test.ts +248 -0
  102. package/src/codegen/okf-lexicon.ts +303 -0
  103. package/src/codegen/package.ts +29 -4
  104. package/src/codegen/publish-order.test.ts +1 -1
  105. package/src/codegen/release-wiring.test.ts +7 -3
  106. package/src/components/config-defaults.test.ts +30 -0
  107. package/src/components/deploy-units.ts +3 -0
  108. package/src/components/verbs/__fixtures__/grype-with-kev-epss.json +1 -0
  109. package/src/components/verbs/__fixtures__/trivy-with-kev-epss.json +631 -0
  110. package/src/components/verbs/exploitability-roundtrip.test.ts +190 -0
  111. package/src/components/verbs/sbom.ts +1 -1
  112. package/src/components/verbs/vuln-gate.test.ts +144 -1
  113. package/src/components/verbs/vuln-gate.ts +61 -10
  114. package/src/components/verbs/vuln-scan.test.ts +94 -0
  115. package/src/components/verbs/vuln-scan.ts +44 -9
  116. package/src/config.test.ts +21 -0
  117. package/src/config.ts +18 -0
  118. package/src/deep-observation.test.ts +19 -0
  119. package/src/deep-observation.ts +32 -13
  120. package/src/governance.test.ts +72 -0
  121. package/src/governance.ts +54 -0
  122. package/src/lexicon.ts +3 -0
  123. package/src/lifecycle/observe.test.ts +18 -0
  124. package/src/lifecycle/observe.ts +10 -3
  125. package/src/lifecycle/snapshot.ts +5 -4
  126. package/src/okf.test.ts +169 -0
  127. package/src/okf.ts +308 -0
  128. package/src/reconcile.ts +32 -1
  129. package/src/terraform/__fixtures__/build-graph.ts +25 -0
  130. package/src/terraform/adopt-state.test.ts +34 -1
  131. package/src/terraform/adopt-state.ts +69 -2
  132. package/src/terraform/bridge.test.ts +38 -14
  133. package/src/terraform/bridge.ts +48 -14
  134. package/src/terraform/carve.test.ts +9 -7
  135. package/src/terraform/carve.ts +0 -0
  136. package/src/terraform/excise.test.ts +92 -0
  137. package/src/terraform/excise.ts +139 -0
  138. package/src/terraform/graduate.test.ts +73 -6
  139. package/src/terraform/graduate.ts +49 -0
  140. package/src/terraform/graph.test.ts +56 -10
  141. package/src/terraform/graph.ts +112 -45
  142. package/src/terraform/manifest.test.ts +105 -0
  143. package/src/terraform/manifest.ts +162 -0
  144. package/src/terraform/parse.test.ts +39 -1
  145. package/src/terraform/parse.ts +36 -9
  146. package/src/terraform/score.test.ts +8 -8
  147. package/src/terraform/state.test.ts +4 -4
  148. package/src/terraform/types.ts +7 -0
  149. package/src/terraform/unified-diff.test.ts +77 -0
  150. package/src/terraform/unified-diff.ts +146 -0
@@ -10,17 +10,47 @@
10
10
  * AWS carve-out table (`aws-resources.ts`) — the same table the advisor's tier
11
11
  * map derives from, so every AWS type advise ranks can be emitted. Attributes
12
12
  * without a mapping are preserved in a reference comment, never dropped.
13
+ *
14
+ * Deferred outbound inputs (#998): an attribute whose value the Terraform
15
+ * source read from a survivor (`vpc_id = aws_vpc.main.id`) is not a fact of
16
+ * the carved resource — it is a deploy-time input. Those props are emitted as
17
+ * `params.<name>` references instead of frozen literals, with the state's
18
+ * resolved value as the declared default (see `carve-emit.ts`'s scaffold).
13
19
  */
14
20
 
15
21
  import { AWS_CARVE_TYPES, AWS_LEXICON_IMPORT, awsCarveType, applyAwsMapper } from "./aws-resources";
16
22
  import type { StateResource } from "./state";
17
23
 
24
+ /** The core subpath the emitted source reads build parameters from. */
25
+ export const PARAMS_IMPORT = "@intentius/chant/params";
26
+
27
+ /**
28
+ * One deferred outbound input turned into a build parameter (#998). Derived
29
+ * from the boundary report's outbound edges + the state's resolved attributes
30
+ * in `carve-emit.ts`; consumed here (source substitution) and by the scaffold
31
+ * (`chant.config.ts` `buildParams` declaration).
32
+ */
33
+ export interface DeferredParam {
34
+ /** Build-parameter name, source-referenceable (`params.<name>`). */
35
+ name: string;
36
+ /** The carved resource's own Terraform attribute the value enters through. */
37
+ tfAttr: string;
38
+ /** The survivor the Terraform source read, e.g. `aws_vpc.main`. */
39
+ survivor: string;
40
+ /** Survivor attribute(s) read, e.g. `["id"]`. */
41
+ attrs: string[];
42
+ /** The state-resolved value — the parameter's declared default. */
43
+ default?: string | number | boolean;
44
+ }
45
+
18
46
  export interface AdoptedSource {
19
47
  fileName: string;
20
48
  content: string;
21
49
  /** True when at least one attribute was mapped to a native prop. */
22
50
  mapped: boolean;
23
51
  nativeType: string;
52
+ /** Deferred params actually substituted into the emitted props (#998). */
53
+ parameterized: string[];
24
54
  }
25
55
 
26
56
  /** Is this Terraform type adoptable from state (has a native constructor)? */
@@ -33,16 +63,41 @@ export function supportedStateAdoptionTypes(): string[] {
33
63
  return AWS_CARVE_TYPES.map((t) => t.tfType).sort();
34
64
  }
35
65
 
66
+ /** Marker for a prop whose value is a `params.<name>` reference, not a literal. */
67
+ class ParamRef {
68
+ constructor(
69
+ readonly name: string,
70
+ readonly type: "string" | "number" | "boolean",
71
+ ) {}
72
+ }
73
+
36
74
  /**
37
75
  * Render chant source for a state-adopted resource. Emits the native
38
76
  * constructor with mapped properties, plus a reference comment listing the
39
77
  * Terraform attributes that were not mapped so nothing is silently dropped.
78
+ *
79
+ * A mapped attribute named by a `DeferredParam` renders as a `params.<name>`
80
+ * reference (a real chant build parameter) instead of the state literal —
81
+ * the value came from a survivor, so it stays overridable per build.
40
82
  */
41
- export function adoptFromState(resource: StateResource): AdoptedSource | null {
83
+ export function adoptFromState(resource: StateResource, params: DeferredParam[] = []): AdoptedSource | null {
42
84
  const entry = awsCarveType(resource.type);
43
85
  if (!entry) return null;
44
86
 
45
87
  const { props, mappedKeys } = applyAwsMapper(entry, resource.attributes);
88
+
89
+ // Substitute deferred inputs: only plain (untransformed) field mappings can
90
+ // carry a parameter reference — a transform ran against the literal at emit
91
+ // time and cannot re-run at build. Everything else keeps the state literal.
92
+ const parameterized: string[] = [];
93
+ for (const param of params) {
94
+ const spec = entry.fields[param.tfAttr];
95
+ if (typeof spec !== "string" || !(spec in props)) continue;
96
+ const type = typeof param.default === "number" ? "number" : typeof param.default === "boolean" ? "boolean" : "string";
97
+ props[spec] = new ParamRef(param.name, type);
98
+ parameterized.push(param.name);
99
+ }
100
+
46
101
  const unmapped: Record<string, unknown> = {};
47
102
  for (const [k, v] of Object.entries(resource.attributes)) {
48
103
  if (!mappedKeys.includes(k)) unmapped[k] = v;
@@ -52,6 +107,10 @@ export function adoptFromState(resource: StateResource): AdoptedSource | null {
52
107
  L.push(`// Adopted from Terraform state: ${resource.type}.${resource.name} -> ${entry.nativeType}`);
53
108
  L.push(`// Properties mapped from Terraform attributes (CloudFormation PascalCase).`);
54
109
  L.push(`import { ${entry.ctor} } from "${AWS_LEXICON_IMPORT}";`);
110
+ if (parameterized.length) {
111
+ L.push(`// Deferred deploy-time input(s) — declared in chant.config.ts's buildParams.`);
112
+ L.push(`import { params } from "${PARAMS_IMPORT}";`);
113
+ }
55
114
  L.push("");
56
115
  L.push(`export const ${resource.name} = new ${entry.ctor}(${renderObject(props, 2)});`);
57
116
  if (Object.keys(unmapped).length) {
@@ -66,6 +125,7 @@ export function adoptFromState(resource: StateResource): AdoptedSource | null {
66
125
  content: L.join("\n") + "\n",
67
126
  mapped: Object.keys(props).length > 0,
68
127
  nativeType: entry.nativeType,
128
+ parameterized,
69
129
  };
70
130
  }
71
131
 
@@ -74,6 +134,13 @@ function renderObject(obj: Record<string, unknown>, indent: number): string {
74
134
  const keys = Object.keys(obj);
75
135
  if (keys.length === 0) return "{}";
76
136
  const pad = " ".repeat(indent);
77
- const body = keys.map((k) => `${pad}${k}: ${JSON.stringify(obj[k])},`).join("\n");
137
+ const body = keys
138
+ .map((k) => `${pad}${k}: ${renderValue(obj[k])},`)
139
+ .join("\n");
78
140
  return `{\n${body}\n}`;
79
141
  }
142
+
143
+ /** A prop value: a `params.<name>` reference (cast keeps the project tsc-clean) or a literal. */
144
+ function renderValue(value: unknown): string {
145
+ return value instanceof ParamRef ? `params.${value.name} as ${value.type}` : JSON.stringify(value);
146
+ }
@@ -1,5 +1,5 @@
1
1
  import { describe, test, expect } from "vitest";
2
- import { buildGraph } from "./graph";
2
+ import { buildFixtureGraph } from "./__fixtures__/build-graph";
3
3
  import { boundaryReport } from "./carve";
4
4
  import { generateBridge, type CarvedIdentity } from "./bridge";
5
5
  import type { Hcl2JsonTree } from "./types";
@@ -41,7 +41,7 @@ const identities = new Map<string, CarvedIdentity>([
41
41
 
42
42
  describe("generateBridge — inbound (data-source rewrite)", () => {
43
43
  test("adds a data source for the carved bucket", () => {
44
- const report = boundaryReport(buildGraph(workedExample), "aws_s3_bucket.assets")!;
44
+ const report = boundaryReport(buildFixtureGraph(workedExample), "aws_s3_bucket.assets")!;
45
45
  const plan = generateBridge(report, [{ path: "api.tf", content: API_TF }], identities);
46
46
 
47
47
  expect(plan.dataSources).toHaveLength(1);
@@ -52,7 +52,7 @@ describe("generateBridge — inbound (data-source rewrite)", () => {
52
52
  });
53
53
 
54
54
  test("rewrites survivor references to the data source", () => {
55
- const report = boundaryReport(buildGraph(workedExample), "aws_s3_bucket.assets")!;
55
+ const report = boundaryReport(buildFixtureGraph(workedExample), "aws_s3_bucket.assets")!;
56
56
  const plan = generateBridge(report, [{ path: "api.tf", content: API_TF }], identities);
57
57
 
58
58
  const rewrite = plan.rewrites.find((r) => r.path === "api.tf")!;
@@ -64,7 +64,7 @@ describe("generateBridge — inbound (data-source rewrite)", () => {
64
64
  });
65
65
 
66
66
  test("does not double-prefix an already-rewritten reference (idempotent)", () => {
67
- const report = boundaryReport(buildGraph(workedExample), "aws_s3_bucket.assets")!;
67
+ const report = boundaryReport(buildFixtureGraph(workedExample), "aws_s3_bucket.assets")!;
68
68
  const already = API_TF.replace(/aws_s3_bucket\.assets/g, "data.aws_s3_bucket.assets");
69
69
  const plan = generateBridge(report, [{ path: "api.tf", content: already }], identities);
70
70
  const rewrite = plan.rewrites.find((r) => r.path === "api.tf")!;
@@ -72,19 +72,39 @@ describe("generateBridge — inbound (data-source rewrite)", () => {
72
72
  expect(rewrite.rewritten).not.toContain("data.data.aws_s3_bucket");
73
73
  });
74
74
 
75
- test("does not touch the carved resource's own declaration (quoted labels)", () => {
76
- const report = boundaryReport(buildGraph(workedExample), "aws_s3_bucket.assets")!;
77
- const decl = `resource "aws_s3_bucket" "assets" {\n bucket = "myapp-assets-prod"\n}\n`;
75
+ test("excises the carved resource's own declaration (#998 — the data source replaces it)", () => {
76
+ const report = boundaryReport(buildFixtureGraph(workedExample), "aws_s3_bucket.assets")!;
77
+ const decl = `resource "aws_s3_bucket" "assets" {\n bucket = "myapp-assets-prod"\n}\n\nresource "aws_sns_topic" "alerts" {\n name = "a"\n}\n`;
78
78
  const plan = generateBridge(report, [{ path: "bucket.tf", content: decl }], identities);
79
79
  const rewrite = plan.rewrites.find((r) => r.path === "bucket.tf")!;
80
- // `resource "aws_s3_bucket" "assets"` uses quoted labels, not the dotted
81
- // token, so it is left alone.
82
- expect(rewrite.rewritten).toContain('resource "aws_s3_bucket" "assets"');
83
- expect(rewrite.changed).toBe(false);
80
+ // After `terraform state rm`, a block left behind would re-create the
81
+ // resource on the next apply — so the bridge removes it.
82
+ expect(rewrite.changed).toBe(true);
83
+ expect(rewrite.excised).toEqual(["aws_s3_bucket.assets"]);
84
+ expect(rewrite.rewritten).not.toContain('resource "aws_s3_bucket" "assets"');
85
+ expect(rewrite.rewritten).toContain('resource "aws_sns_topic" "alerts"');
86
+ expect(plan.excised).toEqual(["aws_s3_bucket.assets"]);
87
+ expect(plan.runbook).toContain("removes the carved block(s): aws_s3_bucket.assets");
88
+ });
89
+
90
+ test("a folded sub-resource's block is excised along with its parent", () => {
91
+ const withVersioning: Hcl2JsonTree = {
92
+ resource: {
93
+ ...workedExample.resource,
94
+ aws_s3_bucket_versioning: {
95
+ assets: [{ bucket: "${aws_s3_bucket.assets.id}", versioning_configuration: { status: "Enabled" } }],
96
+ },
97
+ },
98
+ };
99
+ const report = boundaryReport(buildFixtureGraph(withVersioning), "aws_s3_bucket.assets")!;
100
+ const decl = `resource "aws_s3_bucket" "assets" {\n bucket = "b"\n}\n\nresource "aws_s3_bucket_versioning" "assets" {\n bucket = aws_s3_bucket.assets.id\n}\n`;
101
+ const plan = generateBridge(report, [{ path: "bucket.tf", content: decl }], identities);
102
+ expect(plan.excised).toEqual(["aws_s3_bucket.assets", "aws_s3_bucket_versioning.assets"]);
103
+ expect(plan.rewrites[0].rewritten.trim()).toBe("");
84
104
  });
85
105
 
86
106
  test("unknown identity emits a TODO data source instead of a wrong value", () => {
87
- const report = boundaryReport(buildGraph(workedExample), "aws_s3_bucket.assets")!;
107
+ const report = boundaryReport(buildFixtureGraph(workedExample), "aws_s3_bucket.assets")!;
88
108
  const plan = generateBridge(report, [{ path: "api.tf", content: API_TF }], new Map());
89
109
  expect(plan.dataSources[0].hcl).toContain("# TODO");
90
110
  });
@@ -98,17 +118,21 @@ describe("generateBridge — outbound (deferred inputs)", () => {
98
118
  aws_sqs_queue: { dlq: [{ name: "d", redrive: "${aws_sns_topic.alerts.arn}" }] },
99
119
  },
100
120
  };
101
- const report = boundaryReport(buildGraph(tree), "aws_sqs_queue.dlq")!;
121
+ const report = boundaryReport(buildFixtureGraph(tree), "aws_sqs_queue.dlq")!;
102
122
  const plan = generateBridge(report, [], new Map());
103
123
  expect(plan.dataSources).toEqual([]); // no inbound → no survivor patch
104
124
  expect(plan.deferredInputs).toHaveLength(1);
105
125
  expect(plan.deferredInputs[0]).toMatchObject({ survivor: "aws_sns_topic.alerts", carved: "aws_sqs_queue.dlq" });
126
+ // The deferred input names the build param emit declares for it (#998).
127
+ expect(plan.deferredInputs[0].params).toEqual(["redrive"]);
128
+ expect(plan.deferredInputs[0].note).toContain("build param `redrive`");
129
+ expect(plan.deferredInputs[0].note).toContain("--param");
106
130
  });
107
131
  });
108
132
 
109
133
  describe("generateBridge — runbook", () => {
110
134
  test("is reversible and observe-first, with the state-rm and import steps", () => {
111
- const report = boundaryReport(buildGraph(workedExample), "aws_s3_bucket.assets")!;
135
+ const report = boundaryReport(buildFixtureGraph(workedExample), "aws_s3_bucket.assets")!;
112
136
  const plan = generateBridge(report, [{ path: "api.tf", content: API_TF }], identities);
113
137
  expect(plan.runbook).toContain("terraform state rm aws_s3_bucket.assets");
114
138
  expect(plan.runbook).toContain("does NOT destroy");
@@ -19,7 +19,8 @@
19
19
  * is reversible via `terraform import`.
20
20
  */
21
21
 
22
- import type { CarveReport } from "./carve";
22
+ import { deferredParamName, type CarveReport } from "./carve";
23
+ import { exciseResourceBlocks, type ExciseTarget } from "./excise";
23
24
 
24
25
  export interface CarvedIdentity {
25
26
  /** The HCL identity attribute, e.g. `bucket` or `name`. */
@@ -41,12 +42,16 @@ export interface FileRewrite {
41
42
  original: string;
42
43
  rewritten: string;
43
44
  changed: boolean;
45
+ /** Carved addresses whose own `resource` block was removed from this file (#998). */
46
+ excised: string[];
44
47
  }
45
48
 
46
49
  export interface DeferredInput {
47
50
  survivor: string;
48
51
  carved: string;
49
52
  attrs: string[];
53
+ /** Build-parameter name(s) the input becomes in the emitted project (#998). */
54
+ params: string[];
50
55
  note: string;
51
56
  }
52
57
 
@@ -55,6 +60,8 @@ export interface BridgePlan {
55
60
  dataSources: DataSourceBlock[];
56
61
  rewrites: FileRewrite[];
57
62
  deferredInputs: DeferredInput[];
63
+ /** Every carved address whose own block the rewrites remove, across files. */
64
+ excised: string[];
58
65
  runbook: string;
59
66
  }
60
67
 
@@ -99,33 +106,56 @@ export function generateBridge(
99
106
  hcl: dataSourceHcl(type, name, identities.get(address) ?? {}),
100
107
  }));
101
108
 
102
- // Rewrite every survivor reference to the carved resources into a data ref.
109
+ // Excise the carve set's own blocks (#998): after `terraform state rm`, a
110
+ // block left behind would re-create the resource on the next apply. Then
111
+ // rewrite every remaining survivor reference into a data ref.
112
+ const exciseTargets: ExciseTarget[] = report.carveSet
113
+ .filter((m) => !m.address.startsWith("module."))
114
+ .map((m) => {
115
+ const [type, ...rest] = m.address.split(".");
116
+ return { address: m.address, type, name: rest.join(".") };
117
+ });
103
118
  const rewrites: FileRewrite[] = files.map(({ path, content }) => {
104
- let rewritten = content;
119
+ const excision = exciseResourceBlocks(content, exciseTargets);
120
+ let rewritten = excision.content;
105
121
  for (const address of carvedWithInbound.keys()) {
106
122
  rewritten = rewritten.replace(referenceRegex(address), `data.${address}`);
107
123
  }
108
- return { path, original: content, rewritten, changed: rewritten !== content };
124
+ return { path, original: content, rewritten, changed: rewritten !== content, excised: excision.excised };
109
125
  });
126
+ const excised = rewrites.flatMap((r) => r.excised);
110
127
 
111
- const deferredInputs: DeferredInput[] = report.outbound.map((e) => ({
112
- survivor: e.survivor,
113
- carved: e.carved,
114
- attrs: e.attrs,
115
- note: `${e.carved} reads ${e.survivor}.${e.attrs.join(", ")} — supply this as a deploy-time input to the carved component at apply.`,
116
- }));
128
+ const deferredInputs: DeferredInput[] = report.outbound.map((e) => {
129
+ const params = (e.via ?? []).map(deferredParamName);
130
+ const wiring = params.length
131
+ ? `emit declares it as build param ${params.map((p) => `\`${p}\``).join(", ")} — override with --param at build`
132
+ : "supply this as a deploy-time input to the carved component at apply";
133
+ return {
134
+ survivor: e.survivor,
135
+ carved: e.carved,
136
+ attrs: e.attrs,
137
+ params,
138
+ note: `${e.carved} reads ${e.survivor}.${e.attrs.join(", ")} — ${wiring}.`,
139
+ };
140
+ });
117
141
 
118
142
  return {
119
143
  target: report.target,
120
144
  dataSources,
121
145
  rewrites,
122
146
  deferredInputs,
123
- runbook: buildRunbook(report, dataSources, deferredInputs),
147
+ excised,
148
+ runbook: buildRunbook(report, dataSources, deferredInputs, excised),
124
149
  };
125
150
  }
126
151
 
127
152
  /** The reversible, observe-first handoff runbook (#197). */
128
- function buildRunbook(report: CarveReport, dataSources: DataSourceBlock[], deferred: DeferredInput[]): string {
153
+ function buildRunbook(
154
+ report: CarveReport,
155
+ dataSources: DataSourceBlock[],
156
+ deferred: DeferredInput[],
157
+ excised: string[],
158
+ ): string {
129
159
  const carvedAddrs = report.carveSet.map((m) => m.address);
130
160
  const L: string[] = [];
131
161
  L.push(`# Carve-out: ${report.target} → chant [observe-first, reversible]`);
@@ -140,10 +170,14 @@ function buildRunbook(report: CarveReport, dataSources: DataSourceBlock[], defer
140
170
  L.push("");
141
171
  L.push("## 3. Confirm no destroy, then patch the survivors");
142
172
  L.push(" terraform plan # expect 0 to destroy; refs to the carved resource now error");
173
+ if (excised.length) {
174
+ L.push(` # the generated bridge patch removes the carved block(s): ${excised.join(", ")}`);
175
+ L.push(" # (without this, the next apply would re-create what state rm released)");
176
+ }
143
177
  if (dataSources.length) {
144
- L.push(" # apply the generated bridge patch — it adds these data sources and rewires references:");
178
+ L.push(" # it also adds these data sources and rewires references:");
145
179
  for (const ds of dataSources) L.push(` # data.${ds.type}.${ds.name} (for ${ds.address})`);
146
- } else {
180
+ } else if (!excised.length) {
147
181
  L.push(" # no inbound edges — no survivor patch needed.");
148
182
  }
149
183
  L.push(" terraform plan # expect: in-place updates to the survivors only");
@@ -1,5 +1,5 @@
1
1
  import { describe, test, expect } from "vitest";
2
- import { buildGraph } from "./graph";
2
+ import { buildFixtureGraph } from "./__fixtures__/build-graph";
3
3
  import { resolveCarveSet, boundaryReport } from "./carve";
4
4
  import type { Hcl2JsonTree } from "./types";
5
5
 
@@ -27,7 +27,7 @@ const workedExample: Hcl2JsonTree = {
27
27
 
28
28
  describe("resolveCarveSet", () => {
29
29
  test("selected node plus its folded sub-resources", () => {
30
- const set = resolveCarveSet(buildGraph(workedExample), "aws_s3_bucket.assets");
30
+ const set = resolveCarveSet(buildFixtureGraph(workedExample), "aws_s3_bucket.assets");
31
31
  expect(set.map((m) => m.address).sort()).toEqual([
32
32
  "aws_s3_bucket.assets",
33
33
  "aws_s3_bucket_versioning.assets",
@@ -37,13 +37,13 @@ describe("resolveCarveSet", () => {
37
37
  });
38
38
 
39
39
  test("empty for an unknown address", () => {
40
- expect(resolveCarveSet(buildGraph(workedExample), "aws_s3_bucket.nope")).toEqual([]);
40
+ expect(resolveCarveSet(buildFixtureGraph(workedExample), "aws_s3_bucket.nope")).toEqual([]);
41
41
  });
42
42
  });
43
43
 
44
44
  describe("boundaryReport", () => {
45
45
  test("classifies the Lambda inbound edge; folds the versioning internal edge away", () => {
46
- const report = boundaryReport(buildGraph(workedExample), "aws_s3_bucket.assets")!;
46
+ const report = boundaryReport(buildFixtureGraph(workedExample), "aws_s3_bucket.assets")!;
47
47
  expect(report.target).toBe("aws_s3_bucket.assets");
48
48
  expect(report.peelability).toBe(88);
49
49
  expect(report.reversible).toBe(true);
@@ -56,6 +56,7 @@ describe("boundaryReport", () => {
56
56
  survivor: "aws_lambda_function.api",
57
57
  carved: "aws_s3_bucket.assets",
58
58
  attrs: ["arn", "bucket"],
59
+ via: ["environment"],
59
60
  bridge: "tf-data-source",
60
61
  required: "immediately",
61
62
  },
@@ -71,7 +72,7 @@ describe("boundaryReport", () => {
71
72
  },
72
73
  };
73
74
  // Carve the queue: it depends on the topic (a survivor) → outbound/deferred.
74
- const report = boundaryReport(buildGraph(tree), "aws_sqs_queue.dlq")!;
75
+ const report = boundaryReport(buildFixtureGraph(tree), "aws_sqs_queue.dlq")!;
75
76
  expect(report.inbound).toEqual([]);
76
77
  expect(report.outbound).toEqual([
77
78
  {
@@ -79,6 +80,7 @@ describe("boundaryReport", () => {
79
80
  survivor: "aws_sns_topic.alerts",
80
81
  carved: "aws_sqs_queue.dlq",
81
82
  attrs: ["arn"],
83
+ via: ["redrive"],
82
84
  bridge: "deferred-input",
83
85
  required: "at-apply",
84
86
  },
@@ -87,11 +89,11 @@ describe("boundaryReport", () => {
87
89
 
88
90
  test("unsupported type is flagged in diagnostics", () => {
89
91
  const tree: Hcl2JsonTree = { resource: { random_pet: { n: [{ length: 2 }] } } };
90
- const report = boundaryReport(buildGraph(tree), "random_pet.n")!;
92
+ const report = boundaryReport(buildFixtureGraph(tree), "random_pet.n")!;
91
93
  expect(report.diagnostics.join(" ")).toMatch(/no known native mapping/i);
92
94
  });
93
95
 
94
96
  test("null for an unknown target", () => {
95
- expect(boundaryReport(buildGraph(workedExample), "aws_s3_bucket.nope")).toBeNull();
97
+ expect(boundaryReport(buildFixtureGraph(workedExample), "aws_s3_bucket.nope")).toBeNull();
96
98
  });
97
99
  });
Binary file
@@ -0,0 +1,92 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { exciseResourceBlocks, type ExciseTarget } from "./excise";
3
+
4
+ const bucket: ExciseTarget = { address: "aws_s3_bucket.assets", type: "aws_s3_bucket", name: "assets" };
5
+ const versioning: ExciseTarget = {
6
+ address: "aws_s3_bucket_versioning.assets",
7
+ type: "aws_s3_bucket_versioning",
8
+ name: "assets",
9
+ };
10
+
11
+ describe("exciseResourceBlocks", () => {
12
+ test("removes the target block, leaves the rest byte-for-byte", () => {
13
+ const tf = `# the bucket
14
+ resource "aws_s3_bucket" "assets" {
15
+ bucket = "myapp-assets-prod"
16
+ tags = { Team = "web" }
17
+ }
18
+
19
+ resource "aws_lambda_function" "api" {
20
+ function_name = "myapp-api"
21
+ }
22
+ `;
23
+ const r = exciseResourceBlocks(tf, [bucket]);
24
+ expect(r.excised).toEqual(["aws_s3_bucket.assets"]);
25
+ expect(r.content).toBe(`# the bucket
26
+ resource "aws_lambda_function" "api" {
27
+ function_name = "myapp-api"
28
+ }
29
+ `);
30
+ });
31
+
32
+ test("removes several targets (a folded sub-resource travels with its parent)", () => {
33
+ const tf = `resource "aws_s3_bucket" "assets" {
34
+ bucket = "b"
35
+ }
36
+
37
+ resource "aws_s3_bucket_versioning" "assets" {
38
+ bucket = aws_s3_bucket.assets.id
39
+ versioning_configuration {
40
+ status = "Enabled"
41
+ }
42
+ }
43
+
44
+ resource "aws_sns_topic" "alerts" {
45
+ name = "a"
46
+ }
47
+ `;
48
+ const r = exciseResourceBlocks(tf, [bucket, versioning]);
49
+ expect(r.excised).toEqual(["aws_s3_bucket.assets", "aws_s3_bucket_versioning.assets"]);
50
+ expect(r.content).toBe(`resource "aws_sns_topic" "alerts" {\n name = "a"\n}\n`);
51
+ });
52
+
53
+ test("a same-name block of a different type stays", () => {
54
+ const tf = `resource "aws_s3_bucket" "assets" { bucket = "b" }
55
+ resource "aws_s3_bucket_versioning" "assets" { bucket = "b" }
56
+ `;
57
+ const r = exciseResourceBlocks(tf, [bucket]);
58
+ expect(r.excised).toEqual(["aws_s3_bucket.assets"]);
59
+ expect(r.content).toContain('resource "aws_s3_bucket_versioning" "assets"');
60
+ });
61
+
62
+ test("braces inside strings, interpolations, comments, and heredocs do not fool the scanner", () => {
63
+ const tf = `resource "aws_s3_bucket" "assets" {
64
+ bucket = "b"
65
+ # a } in a comment
66
+ policy = jsonencode({ Statement = [{ Effect = "Allow" }] })
67
+ note = "literal } and \${var.m["k}"]} too"
68
+ user_data = <<EOF
69
+ if true; then echo "}"; fi
70
+ EOF
71
+ }
72
+ resource "aws_sns_topic" "alerts" { name = "a" }
73
+ `;
74
+ const r = exciseResourceBlocks(tf, [bucket]);
75
+ expect(r.excised).toEqual(["aws_s3_bucket.assets"]);
76
+ expect(r.content).toBe(`resource "aws_sns_topic" "alerts" { name = "a" }\n`);
77
+ });
78
+
79
+ test("no target present → the file comes back unchanged", () => {
80
+ const tf = `resource "aws_sns_topic" "alerts" { name = "a" }\n`;
81
+ const r = exciseResourceBlocks(tf, [bucket]);
82
+ expect(r.excised).toEqual([]);
83
+ expect(r.content).toBe(tf);
84
+ });
85
+
86
+ test("an unbalanced block is left alone rather than corrupted", () => {
87
+ const tf = `resource "aws_s3_bucket" "assets" {\n bucket = "b"\n`;
88
+ const r = exciseResourceBlocks(tf, [bucket]);
89
+ expect(r.excised).toEqual([]);
90
+ expect(r.content).toBe(tf);
91
+ });
92
+ });
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Excision of a carved resource's own `resource "<type>" "<name>"` block from
3
+ * the surviving Terraform source (#998 — closes the "auto-excise" limitation).
4
+ *
5
+ * After `terraform state rm`, the block left behind would re-CREATE the
6
+ * resource on the next apply — the one edit the bridge previously left to
7
+ * hand-work. This removes the carve set's own blocks as part of the survivor
8
+ * rewrite, so the one bridge `.patch` (#1589) carries the whole handoff edit.
9
+ *
10
+ * Pure text transform. The scanner tracks brace depth outside of strings,
11
+ * comments, template interpolations, and heredocs — enough for real `.tf`
12
+ * files without pulling in the wasm parser for what is a subtractive edit.
13
+ */
14
+
15
+ export interface ExciseTarget {
16
+ /** Terraform address, e.g. `aws_s3_bucket.assets`. */
17
+ address: string;
18
+ type: string;
19
+ name: string;
20
+ }
21
+
22
+ export interface ExciseResult {
23
+ content: string;
24
+ /** Addresses whose block was found and removed, in file order. */
25
+ excised: string[];
26
+ }
27
+
28
+ const HEADER = /^\s*resource\s+"([^"]+)"\s+"([^"]+)"\s*\{/;
29
+
30
+ /**
31
+ * Remove the targets' own `resource` blocks from one `.tf` file's text. A
32
+ * comment line directly above a removed block stays (it may describe more
33
+ * than the block); the blank line the removal leaves behind is collapsed.
34
+ */
35
+ export function exciseResourceBlocks(content: string, targets: ExciseTarget[]): ExciseResult {
36
+ if (targets.length === 0) return { content, excised: [] };
37
+ const wanted = new Map(targets.map((t) => [`${t.type}.${t.name}`, t.address]));
38
+ const lines = content.split("\n");
39
+ const excised: string[] = [];
40
+ const out: string[] = [];
41
+
42
+ let i = 0;
43
+ while (i < lines.length) {
44
+ const m = lines[i].match(HEADER);
45
+ const key = m ? `${m[1]}.${m[2]}` : undefined;
46
+ if (!m || !wanted.has(key!)) {
47
+ out.push(lines[i]);
48
+ i++;
49
+ continue;
50
+ }
51
+
52
+ const end = blockEnd(lines, i);
53
+ if (end === -1) {
54
+ // Unbalanced block — leave the file alone rather than corrupt it.
55
+ out.push(lines[i]);
56
+ i++;
57
+ continue;
58
+ }
59
+ excised.push(wanted.get(key!)!);
60
+ i = end + 1;
61
+ // Swallow the separator blank line so the removal leaves no doubled gap.
62
+ if (i < lines.length && lines[i].trim() === "") i++;
63
+ }
64
+
65
+ return { content: out.join("\n"), excised };
66
+ }
67
+
68
+ /** Index of the line closing the block opened on `start`, or -1 if unbalanced. */
69
+ function blockEnd(lines: string[], start: number): number {
70
+ // Context stack: block/object braces, strings, `${` interpolations, block
71
+ // comments. The header's `{` pushes the first "brace"; the block closes when
72
+ // the stack empties.
73
+ const stack: Array<"brace" | "string" | "interp" | "comment"> = [];
74
+ let opened = false;
75
+ let heredoc: string | null = null;
76
+
77
+ for (let ln = start; ln < lines.length; ln++) {
78
+ const line = lines[ln];
79
+ if (heredoc !== null) {
80
+ if (line.trim() === heredoc) heredoc = null;
81
+ continue;
82
+ }
83
+
84
+ for (let c = 0; c < line.length; c++) {
85
+ const top = stack[stack.length - 1];
86
+ const ch = line[c];
87
+ const next = line[c + 1];
88
+
89
+ if (top === "comment") {
90
+ if (ch === "*" && next === "/") {
91
+ stack.pop();
92
+ c++;
93
+ }
94
+ continue;
95
+ }
96
+
97
+ if (top === "string") {
98
+ if (ch === "\\") {
99
+ c++;
100
+ } else if (ch === "$" && next === "{" && line[c - 1] !== "$") {
101
+ stack.push("interp");
102
+ c++;
103
+ } else if (ch === '"') {
104
+ stack.pop();
105
+ }
106
+ continue;
107
+ }
108
+
109
+ // Code position (top of file block, a nested brace, or inside `${...}`).
110
+ if (ch === "#" || (ch === "/" && next === "/")) break; // line comment
111
+ if (ch === "/" && next === "*") {
112
+ stack.push("comment");
113
+ c++;
114
+ continue;
115
+ }
116
+ if (ch === '"') {
117
+ stack.push("string");
118
+ continue;
119
+ }
120
+ if (ch === "<" && next === "<") {
121
+ const hd = line.slice(c).match(/^<<-?([A-Za-z_][A-Za-z0-9_-]*)/);
122
+ if (hd) {
123
+ heredoc = hd[1];
124
+ break;
125
+ }
126
+ }
127
+ if (ch === "{") {
128
+ stack.push("brace");
129
+ opened = true;
130
+ continue;
131
+ }
132
+ if (ch === "}") {
133
+ if (top === "brace" || top === "interp") stack.pop();
134
+ if (opened && stack.length === 0) return ln;
135
+ }
136
+ }
137
+ }
138
+ return -1;
139
+ }