@intentius/chant 0.44.6 → 0.44.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/dist/cli/commands/carve-bridge.d.ts.map +1 -1
  2. package/dist/cli/commands/carve-emit.d.ts +3 -1
  3. package/dist/cli/commands/carve-emit.d.ts.map +1 -1
  4. package/dist/cli/commands/carve.d.ts +7 -2
  5. package/dist/cli/commands/carve.d.ts.map +1 -1
  6. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  7. package/dist/terraform/adopt-state.d.ts +18 -1
  8. package/dist/terraform/adopt-state.d.ts.map +1 -1
  9. package/dist/terraform/aws-resources.d.ts +29 -0
  10. package/dist/terraform/aws-resources.d.ts.map +1 -1
  11. package/dist/terraform/bridge.d.ts +8 -0
  12. package/dist/terraform/bridge.d.ts.map +1 -1
  13. package/dist/terraform/carve.d.ts +8 -3
  14. package/dist/terraform/carve.d.ts.map +1 -1
  15. package/dist/terraform/graph.d.ts +10 -4
  16. package/dist/terraform/graph.d.ts.map +1 -1
  17. package/dist/terraform/parse.d.ts.map +1 -1
  18. package/dist/terraform/score.d.ts +11 -0
  19. package/dist/terraform/score.d.ts.map +1 -1
  20. package/dist/terraform/types.d.ts +11 -0
  21. package/dist/terraform/types.d.ts.map +1 -1
  22. package/package.json +1 -1
  23. package/src/cli/commands/carve-bridge.test.ts +32 -0
  24. package/src/cli/commands/carve-bridge.ts +3 -0
  25. package/src/cli/commands/carve-emit-state.test.ts +107 -0
  26. package/src/cli/commands/carve-emit.ts +37 -5
  27. package/src/cli/commands/carve.test.ts +39 -1
  28. package/src/cli/commands/carve.ts +8 -2
  29. package/src/cli/handlers/lifecycle.test.ts +27 -0
  30. package/src/cli/handlers/lifecycle.ts +3 -0
  31. package/src/terraform/__fixtures__/advise.test.ts +6 -4
  32. package/src/terraform/adopt-state.test.ts +64 -0
  33. package/src/terraform/adopt-state.ts +52 -2
  34. package/src/terraform/aws-resources.test.ts +102 -1
  35. package/src/terraform/aws-resources.ts +144 -2
  36. package/src/terraform/bridge.test.ts +30 -0
  37. package/src/terraform/bridge.ts +19 -1
  38. package/src/terraform/carve.test.ts +34 -0
  39. package/src/terraform/carve.ts +0 -0
  40. package/src/terraform/graph.test.ts +45 -0
  41. package/src/terraform/graph.ts +35 -16
  42. package/src/terraform/parse.ts +4 -1
  43. package/src/terraform/score.test.ts +25 -0
  44. package/src/terraform/score.ts +27 -4
  45. package/src/terraform/types.ts +11 -0
@@ -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: {
@@ -6,11 +6,18 @@
6
6
  * score = 100
7
7
  * - 12 * inbound # survivors that depend on this → each a TF data-source patch
8
8
  * - 4 * outbound # this depends on survivors → a deferred deploy-time input
9
+ * - 4 * outputs # an output block reads this → a one-line rewrite (#1638)
9
10
  * - 15 * (tier - 1) # native-spec map: tier1=0, tier2=-15, tier3=-30
10
11
  * - 10 * has_dynamic # count / for_each / data present
11
12
  * - 3 * (instances - 1) # state-expanded instance count
12
13
  * clamp 0..100 (unsupported provider/type → 0)
13
14
  *
15
+ * An `output` block referencing the target is a real inbound dependency — the
16
+ * surviving plan errors on the dangling reference — so it cannot score as
17
+ * free. It is not a data-source patch either: bridging it rewrites one
18
+ * expression in a block that manages no infrastructure, the same order of work
19
+ * as recording an outbound deferred input. Hence 4, not 12.
20
+ *
14
21
  * Sub-resources that inline into a parent (see `FOLDS_INTO`) are folded into the
15
22
  * parent's carve set: they are removed from the ranking and their edge to the
16
23
  * parent is not counted as inbound — inlining them is free, not boundary work.
@@ -23,8 +30,11 @@ import type { TfGraph, TfNode } from "./types";
23
30
  export type PeelabilityBand = "clean leaf" | "carvable w/ edits" | "leave in Terraform";
24
31
 
25
32
  export interface PeelabilityBreakdown {
33
+ /** Inbound edges from resource/module blocks — a data-source patch each. */
26
34
  inbound: number;
27
35
  outbound: number;
36
+ /** Inbound edges from `output` blocks — a one-line rewrite each (#1638). */
37
+ outputs: number;
28
38
  tier: 1 | 2 | 3 | null;
29
39
  hasDynamic: boolean;
30
40
  instances: number;
@@ -32,6 +42,7 @@ export interface PeelabilityBreakdown {
32
42
  penalties: {
33
43
  inbound: number;
34
44
  outbound: number;
45
+ outputs: number;
35
46
  tier: number;
36
47
  dynamic: number;
37
48
  instances: number;
@@ -82,8 +93,11 @@ function computeFolds(graph: TfGraph): { folded: Set<string>; childrenOf: Map<st
82
93
  }
83
94
 
84
95
  function scoreNode(node: TfNode, graph: TfGraph, foldedChildren: Set<string>): Peelability {
85
- // Inbound excludes edges from this node's own folded sub-resources.
86
- const inbound = inboundEdges(graph, node.address).filter((e) => !foldedChildren.has(e.from)).length;
96
+ // Inbound excludes edges from this node's own folded sub-resources, and
97
+ // counts output blocks separately they cost less to bridge (#1638).
98
+ const inboundAll = inboundEdges(graph, node.address).filter((e) => !foldedChildren.has(e.from));
99
+ const inbound = inboundAll.filter((e) => e.fromKind !== "output").length;
100
+ const outputs = inboundAll.length - inbound;
87
101
  const outbound = outboundEdges(graph, node.address).length;
88
102
 
89
103
  const tierInfo = node.kind === "module" ? { tier: MODULE_TIER, mapsTo: undefined } : resolveTier(node.type!);
@@ -99,10 +113,11 @@ function scoreNode(node: TfNode, graph: TfGraph, foldedChildren: Set<string>): P
99
113
  breakdown: {
100
114
  inbound,
101
115
  outbound,
116
+ outputs,
102
117
  tier: null,
103
118
  hasDynamic: node.hasDynamic,
104
119
  instances: node.instances,
105
- penalties: { inbound: 0, outbound: 0, tier: 0, dynamic: 0, instances: 0 },
120
+ penalties: { inbound: 0, outbound: 0, outputs: 0, tier: 0, dynamic: 0, instances: 0 },
106
121
  },
107
122
  };
108
123
  }
@@ -110,12 +125,19 @@ function scoreNode(node: TfNode, graph: TfGraph, foldedChildren: Set<string>): P
110
125
  const penalties = {
111
126
  inbound: -12 * inbound,
112
127
  outbound: -4 * outbound,
128
+ outputs: -4 * outputs,
113
129
  tier: -15 * (tier - 1),
114
130
  dynamic: node.hasDynamic ? -10 : 0,
115
131
  instances: -3 * Math.max(0, node.instances - 1),
116
132
  };
117
133
  const score = clamp(
118
- 100 + penalties.inbound + penalties.outbound + penalties.tier + penalties.dynamic + penalties.instances,
134
+ 100 +
135
+ penalties.inbound +
136
+ penalties.outbound +
137
+ penalties.outputs +
138
+ penalties.tier +
139
+ penalties.dynamic +
140
+ penalties.instances,
119
141
  );
120
142
 
121
143
  return {
@@ -127,6 +149,7 @@ function scoreNode(node: TfNode, graph: TfGraph, foldedChildren: Set<string>): P
127
149
  breakdown: {
128
150
  inbound,
129
151
  outbound,
152
+ outputs,
130
153
  tier,
131
154
  hasDynamic: node.hasDynamic,
132
155
  instances: node.instances,
@@ -46,6 +46,15 @@ export interface TfNode {
46
46
  export interface TfEdge {
47
47
  from: string;
48
48
  to: string;
49
+ /**
50
+ * The kind of block the reference came from, when it is not one of the
51
+ * graph's own nodes. `"output"` marks a root-module `output` block (#1638):
52
+ * a real dependency — `terraform plan` errors on the dangling reference the
53
+ * moment the carve lands — but a cheaper one to bridge than a resource's,
54
+ * since the patch is a single expression edit in a block that manages
55
+ * nothing. Absent means the reference came from a resource or module node.
56
+ */
57
+ fromKind?: "output";
49
58
  /** The attribute path(s) referenced, e.g. `["id", "arn"]`. */
50
59
  attrs: string[];
51
60
  /**
@@ -72,5 +81,7 @@ export interface Hcl2JsonTree {
72
81
  resource?: Record<string, Record<string, unknown[]>>;
73
82
  module?: Record<string, unknown[]>;
74
83
  data?: Record<string, Record<string, unknown[]>>;
84
+ /** Root-module `output` blocks. Not nodes — they reference, they are not carvable (#1638). */
85
+ output?: Record<string, unknown[]>;
75
86
  [k: string]: unknown;
76
87
  }