@flow-as-code/tf 0.1.0

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.
package/src/emit.ts ADDED
@@ -0,0 +1,532 @@
1
+ /*
2
+ * Copyright 2026 The flow-as-code Authors
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+ // The Terraform/OpenTofu emitter. FlowDoc in, HCL plus .tftpl out, per
6
+ // docs/03-tf-emitter.md. One-way in v1: there is no HCL to FlowDoc direction.
7
+ //
8
+ // `emitTf` is pure: it takes documents and returns a path -> content map, so the
9
+ // goldens in conformance/emit-tf compare bytes with no filesystem involved.
10
+ // `writeTf` (write.ts) is the thin wrapper that puts them on disk.
11
+ //
12
+ // Verified provider arguments (checked 2026-08-31 against the provider docs and
13
+ // against `tofu validate` with hashicorp/aws 6.62.0 and hashicorp/awscc 1.99.0,
14
+ // see src/validate.test.ts):
15
+ // https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/connect_contact_flow
16
+ // https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/connect_contact_flow_module
17
+ // https://registry.terraform.io/providers/hashicorp/awscc/latest/docs/resources/connect_contact_flow_module_version
18
+ // https://registry.terraform.io/providers/hashicorp/awscc/latest/docs/resources/connect_contact_flow_module_alias
19
+
20
+ import {
21
+ type FlowDoc,
22
+ PACKAGE_NAMES,
23
+ type RefEntry,
24
+ SLUG_PATTERN,
25
+ collectRefs,
26
+ parseToken,
27
+ } from "@flow-as-code/core";
28
+ import { type HclLine, arg, blank, block, comment, objectArg, quote, renderFile } from "./hcl.js";
29
+ import { renderTemplate } from "./template.js";
30
+
31
+ /**
32
+ * The oldest core version the emitted configuration promises to work on, per
33
+ * docs/03-tf-emitter.md ("emit nothing Terraform 1.8+/OpenTofu 1.7+ do not both
34
+ * support"). It lands in every `versions.tf.example` as `required_version`, and
35
+ * the emit-tf CI job is asserted to run the gated suite against it, so the
36
+ * promise and the thing that checks the promise cannot drift apart.
37
+ */
38
+ export const CORE_VERSION_FLOOR = "1.7.0";
39
+
40
+ /** Emission refused. Every problem found is listed, not just the first. */
41
+ export class EmitTfError extends Error {
42
+ readonly problems: readonly string[];
43
+
44
+ constructor(problems: readonly string[]) {
45
+ super(`Cannot emit terraform:\n - ${problems.join("\n - ")}`);
46
+ this.name = "EmitTfError";
47
+ this.problems = problems;
48
+ }
49
+ }
50
+
51
+ export interface EmitTfOptions {
52
+ /**
53
+ * Reference to terraform address expression, for references this emitter
54
+ * cannot resolve from the document set itself. A key is a token
55
+ * (`${cdref:queue:front-desk}`), the token body (`queue:front-desk`,
56
+ * `module:survey@prod`), or the generated variable name
57
+ * (`queue_front_desk_arn`); the three are tried in that order. Values are HCL
58
+ * expressions such as `aws_connect_queue.front_desk.arn`, never literal ARNs.
59
+ * Entries matching no reference in the set are ignored.
60
+ */
61
+ addressMap?: Record<string, string>;
62
+ /**
63
+ * Expression for the `instance_id` argument. Defaults to
64
+ * `var.connect_instance_id`, in which case variables.tf declaring that
65
+ * variable is emitted too. Pass something else (`aws_connect_instance.main.id`)
66
+ * to wire the flows into an instance you already manage; no variables.tf is
67
+ * emitted then.
68
+ */
69
+ instanceIdExpression?: string;
70
+ }
71
+
72
+ export interface EmitTfResult {
73
+ /** Relative POSIX path to file content, insertion-ordered by sorted path. */
74
+ files: Record<string, string>;
75
+ }
76
+
77
+ /** Where a reference's address came from. */
78
+ type RefSource = "set" | "map" | "missing";
79
+
80
+ interface Resolved {
81
+ entry: RefEntry;
82
+ variable: string;
83
+ expression: string;
84
+ source: RefSource;
85
+ /** Address map key ignored because the document set resolves this itself. */
86
+ shadowed?: string;
87
+ }
88
+
89
+ const DEFAULT_INSTANCE_ID_EXPRESSION = "var.connect_instance_id";
90
+ const INSTANCE_ID_VARIABLE = "connect_instance_id";
91
+
92
+ const GENERATED_BY = [
93
+ `Generated by ${PACKAGE_NAMES.tf}. Do not edit by hand: edit the`,
94
+ "FlowDoc and re-emit. See docs/03-tf-emitter.md.",
95
+ ];
96
+
97
+ /** Slug to HCL identifier: hyphens are the only character needing a change. */
98
+ /**
99
+ * A slug as a Terraform identifier. Slugs may begin with a digit (@flow-as-code/core's
100
+ * SLUG_PATTERN allows it) but Terraform identifiers may not: OpenTofu rejects
101
+ * `resource "aws_connect_contact_flow" "2fa_line"` with "Invalid resource
102
+ * name", so a leading digit is prefixed. Deterministic and collision-free
103
+ * against other slugs, since no slug can contain an underscore.
104
+ */
105
+ const ident = (slug: string): string => {
106
+ const underscored = slug.replaceAll("-", "_");
107
+ return /^[0-9]/.test(underscored) ? `_${underscored}` : underscored;
108
+ };
109
+
110
+ /** `<type>_<name>_arn`, with the module alias folded in. Stable by contract. */
111
+ function variableName(entry: RefEntry): string {
112
+ const alias = entry.alias === undefined ? "" : `_${ident(entry.alias)}`;
113
+ return `${entry.type}_${ident(entry.name)}${alias}_arn`;
114
+ }
115
+
116
+ /** The bare identifier emitted when no address is known. Fails `validate`. */
117
+ const placeholder = (variable: string): string =>
118
+ `TODO_MISSING_ADDRESS_${variable.replace(/_arn$/, "")}`;
119
+
120
+ /** `queue:front-desk`, `module:survey@prod`: the token without its wrapper. */
121
+ const refKey = (entry: RefEntry): string =>
122
+ `${entry.type}:${entry.name}${entry.alias === undefined ? "" : `@${entry.alias}`}`;
123
+
124
+ const resourceType = (doc: FlowDoc): string =>
125
+ doc.kind === "module" ? "aws_connect_contact_flow_module" : "aws_connect_contact_flow";
126
+
127
+ /**
128
+ * Name of the locals entry a document's content is rendered with. Documents
129
+ * that reference something this set emits get their own, because putting those
130
+ * addresses in the shared map would make the map depend on resources the map
131
+ * itself feeds: terraform and tofu both refuse that as a dependency cycle
132
+ * (proved by the module-set case in src/validate.test.ts). Everything else
133
+ * shares `local.flow_refs`.
134
+ */
135
+ const REFS_LOCAL = "flow_refs";
136
+ const refsLocalFor = (doc: FlowDoc): string =>
137
+ `${doc.kind === "module" ? "module" : "flow"}_refs_${ident(doc.name)}`;
138
+
139
+ /** A flow and a module may share a name, so a document key carries both. */
140
+ const docKey = (doc: FlowDoc): string => `${doc.kind}:${doc.name}`;
141
+
142
+ const byString = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0);
143
+
144
+ /**
145
+ * An address expression is HCL the emitter pastes into `flow_refs.tf`
146
+ * unevaluated, so it is checked for the things that would corrupt the file or
147
+ * the deploy. Literal ARNs are rejected outright: references stay references
148
+ * (CLAUDE.md, docs/01-flowdoc-spec.md).
149
+ */
150
+ function checkExpression(label: string, value: string, problems: string[]): void {
151
+ if (value.trim() === "") {
152
+ problems.push(`${label} is empty`);
153
+ return;
154
+ }
155
+ if (/arn:aws/i.test(value)) {
156
+ problems.push(`${label} is a literal ARN (${value}); map to a terraform address instead`);
157
+ }
158
+ if (/[\n\r]/.test(value))
159
+ problems.push(`${label} spans multiple lines (${JSON.stringify(value)})`);
160
+ if (/#|\/\/|\/\*/.test(value)) problems.push(`${label} contains a comment marker (${value})`);
161
+ }
162
+
163
+ /** Documents in emission order, with the structural problems reported first. */
164
+ function checkDocs(docs: readonly FlowDoc[]): FlowDoc[] {
165
+ const problems: string[] = [];
166
+ if (docs.length === 0) problems.push("no documents to emit");
167
+
168
+ const seen = new Map<string, string>();
169
+ for (const doc of docs) {
170
+ if (!SLUG_PATTERN.test(doc.name)) {
171
+ problems.push(`document name "${doc.name}" is not a slug`);
172
+ continue;
173
+ }
174
+ // `type` accepts every ConnectType except MODULE, and the module resource
175
+ // takes no `type` at all, so kind and connectType have to agree.
176
+ if (doc.kind === "module" && doc.connectType !== "MODULE") {
177
+ problems.push(`module "${doc.name}" has connectType ${doc.connectType}, expected MODULE`);
178
+ }
179
+ if (doc.kind === "flow" && doc.connectType === "MODULE") {
180
+ problems.push(`flow "${doc.name}" has connectType MODULE; emit it as kind "module"`);
181
+ }
182
+ const address = `${resourceType(doc)}.${ident(doc.name)}`;
183
+ const prior = seen.get(address);
184
+ if (prior !== undefined) problems.push(`two documents both emit ${address}`);
185
+ else seen.set(address, doc.name);
186
+ }
187
+
188
+ if (problems.length > 0) throw new EmitTfError(problems);
189
+ // Kind breaks the tie, because a flow and a module may share a name and the
190
+ // order must not depend on the order the caller passed them in.
191
+ return [...docs].sort((a, b) => byString(a.name, b.name) || byString(a.kind, b.kind));
192
+ }
193
+
194
+ /**
195
+ * Every reference in the set, resolved. Documents in the set resolve their own
196
+ * references: a `${cdref:module:x@prod}` whose module x is emitted here points
197
+ * at the alias resource emitted with it, and a `${cdref:flow:y}` whose flow y is
198
+ * emitted here points at that flow. Everything else comes from the address map,
199
+ * and what the map does not cover gets a placeholder that fails `validate`.
200
+ */
201
+ function resolveRefs(
202
+ docs: readonly FlowDoc[],
203
+ addressMap: Record<string, string>,
204
+ problems: string[],
205
+ ): Resolved[] {
206
+ const flows = new Set(docs.filter((d) => d.kind === "flow").map((d) => d.name));
207
+ const modules = new Set(docs.filter((d) => d.kind === "module").map((d) => d.name));
208
+
209
+ const entries = new Map<string, RefEntry>();
210
+ for (const doc of docs) {
211
+ for (const entry of collectRefs(doc.content)) entries.set(entry.token, entry);
212
+ }
213
+
214
+ const byVariable = new Map<string, string>();
215
+ const resolved: Resolved[] = [];
216
+ for (const entry of [...entries.values()].sort((a, b) => byString(a.token, b.token))) {
217
+ const variable = variableName(entry);
218
+ const clash = byVariable.get(variable);
219
+ if (clash !== undefined) {
220
+ problems.push(`${entry.token} and ${clash} both map to local.flow_refs.${variable}`);
221
+ continue;
222
+ }
223
+ byVariable.set(variable, entry.token);
224
+
225
+ const key = refKey(entry);
226
+ const supplied = addressMap[entry.token] ?? addressMap[key] ?? addressMap[variable];
227
+ if (supplied !== undefined) checkExpression(`address for ${entry.token}`, supplied, problems);
228
+
229
+ let inSet: string | undefined;
230
+ if (entry.type === "module" && entry.alias !== undefined && modules.has(entry.name)) {
231
+ inSet = `awscc_connect_contact_flow_module_alias.${ident(entry.name)}_${ident(entry.alias)}.contact_flow_module_alias_arn`;
232
+ } else if (entry.type === "flow" && flows.has(entry.name)) {
233
+ inSet = `aws_connect_contact_flow.${ident(entry.name)}.arn`;
234
+ }
235
+
236
+ if (inSet !== undefined) {
237
+ const shadowed = supplied === undefined ? undefined : key;
238
+ resolved.push({
239
+ entry,
240
+ variable,
241
+ expression: inSet,
242
+ source: "set",
243
+ ...(shadowed ? { shadowed } : {}),
244
+ });
245
+ } else if (supplied !== undefined) {
246
+ resolved.push({ entry, variable, expression: supplied, source: "map" });
247
+ } else {
248
+ resolved.push({ entry, variable, expression: placeholder(variable), source: "missing" });
249
+ }
250
+ }
251
+ return resolved;
252
+ }
253
+
254
+ /** Aliases each emitted module needs, derived from the references to it. */
255
+ function aliasesByModule(docs: readonly FlowDoc[]): Map<string, string[]> {
256
+ const modules = new Set(docs.filter((d) => d.kind === "module").map((d) => d.name));
257
+ const aliases = new Map<string, Set<string>>();
258
+ for (const name of modules) aliases.set(name, new Set());
259
+ for (const doc of docs) {
260
+ for (const entry of collectRefs(doc.content)) {
261
+ if (entry.type !== "module" || entry.alias === undefined) continue;
262
+ aliases.get(entry.name)?.add(entry.alias);
263
+ }
264
+ }
265
+ return new Map([...aliases].map(([name, set]) => [name, [...set].sort(byString)]));
266
+ }
267
+
268
+ const AWS_FLOW_DOC =
269
+ "https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/connect_contact_flow";
270
+ const AWS_MODULE_DOC =
271
+ "https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/connect_contact_flow_module";
272
+ const AWSCC_VERSION_DOC =
273
+ "https://registry.terraform.io/providers/hashicorp/awscc/latest/docs/resources/connect_contact_flow_module_version";
274
+ const AWSCC_ALIAS_DOC =
275
+ "https://registry.terraform.io/providers/hashicorp/awscc/latest/docs/resources/connect_contact_flow_module_alias";
276
+ const CREATE_FLOW_VERSION_DOC =
277
+ "https://docs.aws.amazon.com/connect/latest/APIReference/API_CreateContactFlowVersion.html";
278
+
279
+ function flowsTf(
280
+ docs: readonly FlowDoc[],
281
+ instanceId: string,
282
+ inSetByDoc: ReadonlyMap<string, Resolved[]>,
283
+ ): string {
284
+ const aliases = aliasesByModule(docs);
285
+ const hasModule = docs.some((d) => d.kind === "module");
286
+ const lines: HclLine[] = GENERATED_BY.map(comment);
287
+ lines.push(
288
+ comment(""),
289
+ comment("Flow content is rendered from flows/<name>.flow.tftpl with the"),
290
+ comment("addresses in flow_refs.tf. Provider, credential, and backend"),
291
+ comment("configuration is yours; versions.tf.example lists what this needs."),
292
+ comment(""),
293
+ comment(AWS_FLOW_DOC),
294
+ comment(AWS_MODULE_DOC),
295
+ );
296
+ if (hasModule) {
297
+ lines.push(
298
+ comment(""),
299
+ comment("Modules carry versions and aliases; full flows do not."),
300
+ comment('CreateContactFlowVersion "only supports creating versions for'),
301
+ comment('flows of type Campaign", so a flow updates its content in place'),
302
+ comment("while a module pins content behind an alias."),
303
+ comment(CREATE_FLOW_VERSION_DOC),
304
+ );
305
+ }
306
+
307
+ for (const doc of docs) {
308
+ const refs = (inSetByDoc.get(docKey(doc)) ?? []).length > 0 ? refsLocalFor(doc) : REFS_LOCAL;
309
+ const template = `templatefile("\${path.module}/flows/${doc.name}.flow.tftpl", local.${refs})`;
310
+ const body: HclLine[] = [arg("instance_id", instanceId), arg("name", quote(doc.name))];
311
+ // `type` exists on the flow resource only; the module resource has none.
312
+ if (doc.kind === "flow") body.push(arg("type", quote(doc.connectType)));
313
+ body.push(arg("content", template));
314
+
315
+ lines.push(blank(), block("resource", [resourceType(doc), ident(doc.name)], body));
316
+ if (doc.kind !== "module") continue;
317
+
318
+ const self = `aws_connect_contact_flow_module.${ident(doc.name)}.arn`;
319
+ lines.push(
320
+ blank(),
321
+ comment("Snapshot of the module content as it stands when this resource is"),
322
+ comment("created. Replace it after a content change so the aliases below"),
323
+ comment("serve the new content."),
324
+ comment(AWSCC_VERSION_DOC),
325
+ block(
326
+ "resource",
327
+ ["awscc_connect_contact_flow_module_version", ident(doc.name)],
328
+ [arg("contact_flow_module_id", self)],
329
+ ),
330
+ );
331
+ for (const alias of aliases.get(doc.name) ?? []) {
332
+ lines.push(
333
+ blank(),
334
+ comment(AWSCC_ALIAS_DOC),
335
+ block(
336
+ "resource",
337
+ ["awscc_connect_contact_flow_module_alias", `${ident(doc.name)}_${ident(alias)}`],
338
+ [
339
+ arg("contact_flow_module_id", self),
340
+ arg(
341
+ "contact_flow_module_version",
342
+ `awscc_connect_contact_flow_module_version.${ident(doc.name)}.version`,
343
+ ),
344
+ arg("name", quote(alias)),
345
+ ],
346
+ ),
347
+ );
348
+ }
349
+ }
350
+ return renderFile(lines);
351
+ }
352
+
353
+ function flowRefsTf(
354
+ docs: readonly FlowDoc[],
355
+ resolved: readonly Resolved[],
356
+ inSetByDoc: ReadonlyMap<string, Resolved[]>,
357
+ ): string {
358
+ const lines: HclLine[] = GENERATED_BY.map(comment);
359
+ lines.push(
360
+ comment(""),
361
+ comment("One entry per reference token in the emitted flow set, named"),
362
+ comment("<type>_<name>_arn with hyphens as underscores and the module alias"),
363
+ comment("folded in. Values are terraform addresses, never literal ARNs."),
364
+ );
365
+
366
+ const shared: HclLine[] = [];
367
+ for (const ref of resolved) {
368
+ // Addresses of resources this set emits are deliberately left out of the
369
+ // shared map; see refsLocalFor and the per-document entries below.
370
+ if (ref.source === "set") continue;
371
+ if (ref.source === "missing") {
372
+ shared.push(
373
+ comment(`TODO: no terraform address for ${ref.entry.token}.`),
374
+ comment(`Add one to the address map under key "${refKey(ref.entry)}"`),
375
+ comment("and re-emit. Until then this placeholder is an undeclared"),
376
+ comment("reference, so `terraform validate` and `tofu validate` fail."),
377
+ );
378
+ }
379
+ shared.push(arg(ref.variable, ref.expression));
380
+ }
381
+ if (shared.length === 0) {
382
+ shared.push(comment("No reference resolves through this map."));
383
+ }
384
+
385
+ const body: HclLine[] = [objectArg(REFS_LOCAL, shared)];
386
+ for (const doc of docs) {
387
+ const own = inSetByDoc.get(docKey(doc)) ?? [];
388
+ if (own.length === 0) continue;
389
+ const entries: HclLine[] = [];
390
+ for (const ref of own) {
391
+ if (ref.shadowed !== undefined) {
392
+ entries.push(
393
+ comment(`Address map entry "${ref.shadowed}" ignored: this set emits`),
394
+ comment("the resource itself, so the reference resolves to it."),
395
+ );
396
+ }
397
+ entries.push(arg(ref.variable, ref.expression));
398
+ }
399
+ body.push(
400
+ blank(),
401
+ comment(`${doc.name} references a resource this set emits. Its addresses`),
402
+ comment("go in a local of their own: in the shared map they would make"),
403
+ comment("that map depend on resources rendered from it, which both tools"),
404
+ comment("report as a dependency cycle."),
405
+ objectArg(refsLocalFor(doc), entries, {
406
+ open: `merge(local.${REFS_LOCAL}, {`,
407
+ close: "})",
408
+ }),
409
+ );
410
+ }
411
+
412
+ lines.push(blank(), block("locals", [], body));
413
+ return renderFile(lines);
414
+ }
415
+
416
+ function variablesTf(): string {
417
+ const lines: HclLine[] = GENERATED_BY.map(comment);
418
+ lines.push(
419
+ blank(),
420
+ block(
421
+ "variable",
422
+ [INSTANCE_ID_VARIABLE],
423
+ [
424
+ arg(
425
+ "description",
426
+ quote("Identifier of the Amazon Connect instance that holds these flows."),
427
+ ),
428
+ arg("type", "string"),
429
+ ],
430
+ ),
431
+ );
432
+ return renderFile(lines);
433
+ }
434
+
435
+ function versionsExample(needsAwscc: boolean): string {
436
+ const providers: HclLine[] = [
437
+ objectArg("aws", [arg("source", quote("hashicorp/aws")), arg("version", quote(">= 5.0"))]),
438
+ ];
439
+ if (needsAwscc) {
440
+ providers.push(
441
+ blank(),
442
+ objectArg("awscc", [
443
+ arg("source", quote("hashicorp/awscc")),
444
+ arg("version", quote(">= 1.74")),
445
+ ]),
446
+ );
447
+ }
448
+
449
+ const lines: HclLine[] = GENERATED_BY.map(comment);
450
+ lines.push(
451
+ comment(""),
452
+ comment("Example only. This file is not loaded by terraform or tofu, which"),
453
+ comment("read *.tf; copy what you need into your own configuration. No"),
454
+ comment("provider credentials and no backend configuration are emitted."),
455
+ comment(""),
456
+ comment("The version constraint below is what both tools understand:"),
457
+ comment("this output uses nothing newer than Terraform 1.8 / OpenTofu 1.7."),
458
+ );
459
+ if (needsAwscc) {
460
+ lines.push(
461
+ comment(""),
462
+ comment("awscc is needed for the module version and alias resources; it"),
463
+ comment("carries them and the aws provider does not."),
464
+ );
465
+ }
466
+ lines.push(
467
+ blank(),
468
+ block(
469
+ "terraform",
470
+ [],
471
+ [
472
+ arg("required_version", quote(`>= ${CORE_VERSION_FLOOR}`)),
473
+ blank(),
474
+ block("required_providers", [], providers),
475
+ ],
476
+ ),
477
+ );
478
+ return renderFile(lines);
479
+ }
480
+
481
+ /**
482
+ * Emits a Terraform/OpenTofu configuration for a set of FlowDocs. Pure and
483
+ * deterministic: the same documents and options produce byte-identical files,
484
+ * and paths come back sorted.
485
+ */
486
+ export function emitTf(docs: readonly FlowDoc[], options: EmitTfOptions = {}): EmitTfResult {
487
+ const ordered = checkDocs(docs);
488
+ const problems: string[] = [];
489
+
490
+ const instanceId = options.instanceIdExpression ?? DEFAULT_INSTANCE_ID_EXPRESSION;
491
+ if (options.instanceIdExpression !== undefined) {
492
+ checkExpression("instanceIdExpression", options.instanceIdExpression, problems);
493
+ }
494
+
495
+ const addressMap = options.addressMap ?? {};
496
+ for (const key of Object.keys(addressMap)) {
497
+ // A key in token form has to be a token we could actually have produced.
498
+ if (key.startsWith("${") && parseToken(key) === undefined) {
499
+ problems.push(`address map key "${key}" looks like a token but does not parse as one`);
500
+ }
501
+ }
502
+
503
+ const resolved = resolveRefs(ordered, addressMap, problems);
504
+ if (problems.length > 0) throw new EmitTfError(problems);
505
+
506
+ // Which of each document's own references resolve to resources this set
507
+ // emits. They are what the per-document locals hold; see refsLocalFor.
508
+ const bySet = new Map(resolved.filter((r) => r.source === "set").map((r) => [r.entry.token, r]));
509
+ const inSetByDoc = new Map<string, Resolved[]>(
510
+ ordered.map((doc) => [
511
+ docKey(doc),
512
+ collectRefs(doc.content)
513
+ .map((entry) => bySet.get(entry.token))
514
+ .filter((r): r is Resolved => r !== undefined),
515
+ ]),
516
+ );
517
+
518
+ const variables = new Map(resolved.map((r) => [r.entry.token, r.variable]));
519
+ const files: Record<string, string> = {
520
+ "flow_refs.tf": flowRefsTf(ordered, resolved, inSetByDoc),
521
+ "flows.tf": flowsTf(ordered, instanceId, inSetByDoc),
522
+ "versions.tf.example": versionsExample(ordered.some((d) => d.kind === "module")),
523
+ };
524
+ for (const doc of ordered) {
525
+ files[`flows/${doc.name}.flow.tftpl`] = renderTemplate(doc, variables);
526
+ }
527
+ if (options.instanceIdExpression === undefined) files["variables.tf"] = variablesTf();
528
+
529
+ return {
530
+ files: Object.fromEntries(Object.entries(files).sort(([a], [b]) => byString(a, b))),
531
+ };
532
+ }
package/src/hcl.ts ADDED
@@ -0,0 +1,107 @@
1
+ /*
2
+ * Copyright 2026 The flow-as-code Authors
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+ // A very small HCL writer. It writes the handful of shapes this emitter needs
6
+ // (comments, `key = expression` arguments, and nested blocks) in the layout
7
+ // `terraform fmt` / `tofu fmt` produce, so emitted files are fmt-clean and stay
8
+ // byte-stable across runs.
9
+ //
10
+ // fmt aligns the `=` of a maximal run of consecutive argument lines, and a
11
+ // comment or a blank line ends the run. That rule is reproduced in `render`
12
+ // below and is asserted against a real `tofu fmt` in the gated validate tests.
13
+
14
+ /** One line of an HCL body. */
15
+ export type HclLine =
16
+ | { kind: "arg"; key: string; value: string }
17
+ | { kind: "objectArg"; key: string; body: HclLine[]; open?: string; close?: string }
18
+ | { kind: "block"; type: string; labels: string[]; body: HclLine[] }
19
+ | { kind: "comment"; text: string }
20
+ | { kind: "blank" };
21
+
22
+ export const arg = (key: string, value: string): HclLine => ({ kind: "arg", key, value });
23
+ export const comment = (text: string): HclLine => ({ kind: "comment", text });
24
+ export const blank = (): HclLine => ({ kind: "blank" });
25
+ export const block = (type: string, labels: string[], body: HclLine[]): HclLine => ({
26
+ kind: "block",
27
+ type,
28
+ labels,
29
+ body,
30
+ });
31
+ /**
32
+ * `key = { ... }`: an object value, not a block. `open` and `close` wrap the
33
+ * body in something else, such as a `merge(...)` call around the braces.
34
+ */
35
+ export const objectArg = (
36
+ key: string,
37
+ body: HclLine[],
38
+ wrap?: { open: string; close: string },
39
+ ): HclLine => ({ kind: "objectArg", key, body, ...wrap });
40
+
41
+ /**
42
+ * A double-quoted HCL string with no template introducer left live. A quoted
43
+ * string in .tf is itself a template, so `${` and `%{` are escaped here too.
44
+ * The `$` replacements are functions because a string replacement would give
45
+ * `$` its substitution meaning and undo the escape.
46
+ */
47
+ export function quote(value: string): string {
48
+ const escaped = value
49
+ .replaceAll("\\", "\\\\")
50
+ .replaceAll('"', '\\"')
51
+ .replaceAll("\n", "\\n")
52
+ .replaceAll("\r", "\\r")
53
+ .replaceAll("\t", "\\t")
54
+ .replaceAll("${", () => "$${")
55
+ .replaceAll("%{", () => "%%{");
56
+ return `"${escaped}"`;
57
+ }
58
+
59
+ /**
60
+ * Renders body lines at the given indent, aligning the `=` within each run of
61
+ * consecutive argument lines the way fmt does.
62
+ */
63
+ export function render(lines: readonly HclLine[], indent = ""): string[] {
64
+ const out: string[] = [];
65
+ for (let i = 0; i < lines.length; i += 1) {
66
+ const line = lines[i];
67
+ if (line === undefined) continue;
68
+ if (line.kind === "blank") {
69
+ out.push("");
70
+ continue;
71
+ }
72
+ if (line.kind === "comment") {
73
+ out.push(`${indent}# ${line.text}`.trimEnd());
74
+ continue;
75
+ }
76
+ if (line.kind === "block") {
77
+ const labels = line.labels.map((l) => ` ${quote(l)}`).join("");
78
+ out.push(`${indent}${line.type}${labels} {`);
79
+ out.push(...render(line.body, `${indent} `));
80
+ out.push(`${indent}}`);
81
+ continue;
82
+ }
83
+ if (line.kind === "objectArg") {
84
+ out.push(`${indent}${line.key} = ${line.open ?? "{"}`);
85
+ out.push(...render(line.body, `${indent} `));
86
+ out.push(`${indent}${line.close ?? "}"}`);
87
+ continue;
88
+ }
89
+ // An argument: gather the whole run so the `=` columns line up.
90
+ const run: { key: string; value: string }[] = [];
91
+ let j = i;
92
+ for (; j < lines.length; j += 1) {
93
+ const next = lines[j];
94
+ if (next === undefined || next.kind !== "arg") break;
95
+ run.push({ key: next.key, value: next.value });
96
+ }
97
+ const width = Math.max(...run.map((a) => a.key.length));
98
+ for (const a of run) out.push(`${indent}${a.key.padEnd(width)} = ${a.value}`);
99
+ i = j - 1;
100
+ }
101
+ return out;
102
+ }
103
+
104
+ /** A whole file: rendered lines plus the trailing newline fmt insists on. */
105
+ export function renderFile(lines: readonly HclLine[]): string {
106
+ return `${render(lines).join("\n").replace(/\n+$/, "")}\n`;
107
+ }
package/src/index.ts ADDED
@@ -0,0 +1,11 @@
1
+ /*
2
+ * Copyright 2026 The flow-as-code Authors
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+ // Public surface of @flow-as-code/tf. FlowDoc in, Terraform/OpenTofu out, per
6
+ // docs/03-tf-emitter.md. One-way in v1: HCL back to FlowDoc is a provider-era
7
+ // feature and is not attempted here.
8
+
9
+ export { EmitTfError, type EmitTfOptions, type EmitTfResult, emitTf } from "./emit.js";
10
+ export { escapeTemplateText, renderTemplate } from "./template.js";
11
+ export { writeTf } from "./write.js";