@flow-as-code/tf: Terraform/OpenTofu emitter

One-way in v1: FlowDoc in, HCL out. Reverse (HCL to FlowDoc) is deferred; it requires HCL evaluation and is a later provider-era feature.

examples/promote-across-environments/ is this emitter run twice over one document with two address maps, which is what the per-environment story looks like end to end.

Output shape (per flow set)

Rules

Corrections found by running the emitter (2026-08-31)

A single shared local.flow_refs does not survive a set that emits a module another document in the set invokes. The module alias address lives in the map, and the resources rendered from the map depend on it, so both Terraform and OpenTofu reject the cycle:

Error: Cycle: awscc_connect_contact_flow_module_version..., local.flow_refs (expand), ...
        

Addresses that point at resources the emitter itself renders are therefore emitted per document, as merge(local.flow_refs, { ... }), and only user-supplied addresses live in the shared local. local.flow_refs remains the scaffold the user edits.

Terraform identifiers may not begin with a digit, but FlowDoc names may (SLUG_PATTERN permits it). A name like 2fa-line produced resource "aws_connect_contact_flow" "2fa_line", which OpenTofu rejects with "Invalid resource name". Such names are prefixed with an underscore. Collision-free, because no slug contains one.

How the gated tests check a template render (2026-09-04)

The reference-implementation check used to drive tofu console through a pipe. On the first CI run that ever reached it, every call hung for its full timeout. opentofu/setup-opentofu@v1 installs a wrapper by default (input tofu_wrapper, default true) that runs the binary through @actions/exec without passing options.input, so the child's stdin is a pipe nothing ever writes to or closes; the console evaluates a piped script only at EOF, and EOF never arrived. The version was not the variable: with that stdio the console hangs on OpenTofu 1.7.0 and 1.12.6 alike, and with a pipe that closes both answer in about 30ms.

tofuEvaluateString in packages/tf/src/__fixtures__/tofu.ts replaces it. It writes the expression into an output block, runs tofu apply -auto-approve -input=false (the directory has no providers, so the apply is offline, needs no credentials, and writes local state into a temp dir) and reads the value back with tofu output -raw. No REPL, nothing reading stdin, identical on both versions. The value still crosses the CLI boundary base64-encoded: that is what makes it byte-exact for awkward content, which conformance/emit-tf/hostile-text exists to prove.