Releases: bsmr/wintermute
Release list
Wintermute 0.3.7 — tagless + multi-type type switch
Wintermute 0.3.7 closes the type-switch story — the seventh feature step of the
0.3.x transpiler line. It adds the two remaining clean forms and a correctness
fold.
- Tagless form
switch M.(type)(no alias) — detection-only cases in both
the value form (case M of …) and the receive form (receive …). A struct
case matches the tag with wildcard fields ({ping, _}); a primitive case
guards a throwaway variable (_X when is_integer(_X)). The body references
neither the value nor its fields (there is no variable). - Multi-type cases
case Ping, Pong:— one Go clause expands to several
Erlang clauses (one per listed type) sharing a duplicated body. Fields are
never bound (Go keepsvat the interface type in a multi-type case), so
v.Fieldthere is rejected loudly rather than emitted as an unbound or
silently-outer-bound variable. Barev(the whole value) is bound via the
0.3.6 whole-alias machinery (V = {ping, _}/V when is_integer(V)). - Nested-alias fold —
bodyUsesBareAliasnow stops at a nested type switch
reusing the enclosing alias name, fixing a false "collides with an
already-bound name" rejection (the 0.3.6 Copilot-gate MINOR).
default: stays REQUIRED for the value form (tagless and multi-type alike) — a
value matching no case falls through in Go, which a total Erlang case cannot
express. As a side effect of the multi-type refactor, a single primitive case
whose body never uses the alias now guards a throwaway _X instead of an unused
V, incidentally removing the erlc "unused variable" warning that clause used to
produce.
Still rejected loudly (0.3.8+): wider primitive types (int32, float32,
byte, []byte, named types) — Erlang has no fixed-width integers, so they
would collapse onto the same guard as int/float64/string and make two
statically-distinct Go cases share one guard: a silent mis-transpile. Also
deferred: nil cases, an init statement, and gen_server callback completion.
Built subagent-driven/TDD: four tasks (implementer + spec/quality review each) +
a whole-branch opus review + the Copilot release gate. The gate caught a real
silent mis-transpile the internal reviews (including opus) missed: the multi-type
v.Field reject only matched a literal v.Field, so deriving a new variable from
the value first — directly (W := v; W.Field) or threaded through a call
(X := f(v); X.Field) — bypassed it and lowered the later field access to a bare
field name that silently resolved to an unrelated outer binding. The fold rejects
binding any variable derived from the matched value in a multi-type case (the RHS
is scanned transitively), so the alias stays usable only as a whole value.
Wintermute 0.3.6 — non-struct type-switch cases
Wintermute 0.3.6 widens the type switch with non-struct (primitive) cases and
whole-alias V — the sixth feature step of the 0.3.x transpiler line.
- Non-struct cases → Erlang type guards.
case int:/string:/bool:/
float64:lower toV when is_integer(V)/is_binary(V)/is_boolean(V)
/is_float(V). They land in the shared clause builder, so both the value
form (case X of) and the receive form (receive) get them. A guarded
non-struct clause in a receive is a selective-receive clause (it blocks on a
non-match, never falls through). - Whole-alias V. The switch alias binds the whole matched value: always for
a primitive case (the guard needs the named variable), for a struct case when
the body uses bareV(Erlang alias patternV = {tag, Fields}), and for the
default:when its body uses bareV(catch-all bindingV -> Body, since
Go's default binds the alias to the whole original value). A field-only struct
case is unchanged from 0.3.5 ({tag, Fields} ->, no alias binding). default:stays REQUIRED for the value form. Unchanged from 0.3.5: a value
matching no case falls through in Go, which a total Erlangcasecannot
express, so a default-less value switch is rejected.
Safety: the four guards and atom-tagged struct tuples are pairwise disjoint, so
Erlang's runtime first-match order coincides with Go's static per-case
exclusivity — no fall-through-vs-crash divergence. The default catch-all is
always emitted last regardless of source order. seenTag dedup extends to
primitives (a duplicate case int: is rejected).
Still rejected loudly (0.3.7+): multi-type cases (case Ping, Pong:), the
tagless form (switch M.(type)), wider primitive types (int32, float32,
byte, []byte, named types), and nil cases.
Built subagent-driven/TDD: three tasks (implementer + spec/quality review each) +
a whole-branch opus review. The Task-1 review caught a real silent mis-transpile
(a bare alias in a default: clause would have emitted an unbound Erlang
variable); it was folded into the correct catch-all binding before merge. The
whole-branch silent-mis-transpile hunt found nothing further.
Wintermute 0.3.5 — plain-value type switch
Generalize the type switch from the otp.Receive() operand (0.3.4) to any value
operand: switch V := X.(type) now lowers to an Erlang case X of {tag, Field…} -> body; … end, the value-branching counterpart to the receive dispatch.
- Struct-typed cases only, reusing the shared clause builder (tagged tuple
{tag, Field…}, per-clause bound-field scope). A newemitTypeSwitchentry
point dispatches on the operand:otp.Receive()→receive, any other value →
case X of. - Default required for the value form: a value matching no case falls through
in Go (ordinary control flow), which a total Erlangcasecannot express, so a
default-less value switch is rejected rather than silently mis-transpiled (the
default becomes the trailing_ ->). The receive form keeps default optional (a
selective receive blocks on a non-match — it never falls through). - Operand and alias are uppercase Erlang variables (
V := M.(type),M any); a
lowercase operand is rejected, never silently emitted as an atom. V.Fieldaccess resolves to the bound field variable; a bare aliasVis
rejected (whole-value passing deferred).terminates()distinguishes the two forms: a value type switch terminates only
with a default (a valuecasefalls through in Go without one), so a
default-carrying one may be a bare-ifthen-branch; a receive terminates without
a default (it blocks, never falling through).- Runnable rung:
testdata/typeswitch/classify.gotranspiles, compiles with
erlc, and RUNS —{ping, 1}and{pong, 2}each fire their clause.
Core is a behaviour-preserving refactor: the 0.3.4 receive clause loop was
extracted into emitTypeSwitchClauses, now shared by both the receive and the
value wrapper.
Deferred to 0.3.6+ (all error loudly): non-struct cases (→ guards), multi-type
cases, whole-alias V, tagless switch, init statement.
Wintermute 0.3.4 — type-switch receive
Fourth step of the 0.3.x transpiler-language line: the type switch over a
received message. Go's other type-branching form now maps to Erlang's central
process construct — a multi-clause receive.
-
switch v := otp.Receive().(type)→ multi-clausereceive— a process
waits for a message and dispatches on its type:switch v := otp.Receive().(type) { case Ping: otp.Print(v.Data) case Pong: otp.Send(v.To, v.Payload) default: otp.Print("unknown") }
→
receive {ping, Data} -> io:format("~p~n", [Data]); {pong, To, Payload} -> To ! Payload; _ -> io:format("unknown~n", []) end
Only struct-typed cases are supported: each
casenames one declared struct,
lowered to the tagged tuple{tag, Field…}via the same pattern builder the
0.3.1 single-clause receive uses (tag = lowercased type name, each field bound
to its capitalized Erlang variable, in declaration order).case *Pingequals
case Ping— Erlang has no pointers — but two cases that would share a tag
(both forms of one type, or names differing only in case) are rejected, since
the second clause would be unreachable. Field accessv.Fieldlowers to the
bound variableField; a bare use of the aliasvis rejected. Each clause
runs in its own binding scope (boundsnapshotted and restored), so a field
collision with an outer binding is still rejected — the fourth
bound-set-integrationcontext. -
default:is optional, and the choice is semantic: with adefault:the
receive gets a trailing catch-all_ ->(matches any other message
immediately); without one it is a selective receive — non-matching messages
stay in the mailbox and the process blocks until a listed type arrives. Both
are idiomatic Erlang. Accordingly,terminates()counts a type-switch receive
as terminating once every clause terminates, with NO default required (a
receivecannot fall through), so it may be the then-branch of a bareif.
Correctness is proven end-to-end by a real-toolchain rung: a dispatcher fixture
transpiles, compiles with erlc, and runs — a {ping, …} message hits the
ping clause and a {pong, …} the pong clause, each returning its checked
payload.
Deliberately deferred to 0.3.5+ (all still error): a type switch over a plain
value (→ a case), non-struct cases (→ Erlang type guards), multi-type cases
(case Ping, Pong:), passing the whole aliased value, and after-timeout /
fallthrough / init / tagless forms.
Built subagent-driven and TDD: six tasks (fresh implementer + spec-and-quality
review each), a whole-branch opus review returning "ready to merge" after an
adversarial silent-mis-transpile hunt found none, and two post-review cleanups
(unified struct-type check, removed a dead statement case) folded before release.
Wintermute 0.3.3 — switch
Third step of the 0.3.x transpiler-language line: the tagged expression
switch. Alongside if, Go's other value-branching form now maps to an
Erlang case, and a pre-existing integer-literal mis-transpile is closed.
-
Tagged
switch→ Erlangcase-on-value — the expression-switch form,
mirroringemitIf:func Name(N int) string { switch N { case 1: return "one" case 2: return "two" default: return "many" } }
→
name(N) -> case N of 1 -> "one"; 2 -> "two"; _ -> "many" end.
Single literal case values; a
defaultis required and always emitted as the
catch-all_, sorted LAST regardless of its position in the Go source. Each
clause body runs in its own binding scope (boundsnapshotted and restored),
so the switch tag and sibling clauses obey the same outer-collision rejection
asifbranches and receive-pattern fields. Empty clauses are rejected; the
switch must be terminal and in tail position. An exhaustive switch (a
default whose every clause returns) also counts as terminating, so it may
serve as the then-branch of a bareif, exactly like an if/else. A type
switch (switch v := x.(type)) is rejected outright. -
Integer-literal normalization — every Go integer literal is now
normalized to decimal Erlang (strconv.ParseInt(v, 0, 64)+ base-10 format).
Previously the literal was emitted verbatim:0777produced Erlang0777
(read as 777 — a clean compile with the WRONG value) and0x1Fproduced
invalid Erlang. This was a pre-existing root cause affecting any octal/hex/
binary literal,return 0x1Fincluded. Overflow is a positioned error, never
a silent wrap.
Correctness is proven end-to-end by a real-toolchain rung: a switch
classifier fixture transpiles, compiles with erlc, and runs to a checked
result (classify:name(2) = "two").
Deliberately deferred to 0.3.4+ (all still error): switch without default,
multi-value cases (case 1, 2:), tagless switch, type switch, fallthrough,
and switch with an init statement.
Built subagent-driven and TDD: three tasks (fresh implementer + two-stage
review each), with the integer-literal fix promoted from a Task-1 review
Critical and a whole-branch opus review returning "ready to merge" after
folding two coverage tests (string case value, receive-field switch tag).
Wintermute 0.3.2 — control flow
Second step of the 0.3.x transpiler-language line: control flow. The flat
function body becomes a value-yielding tree, and recursion becomes useful.
- Operators — the full set beyond
+: arithmetic-*,/→div,
%→rem; comparison==→=:=,!=→=/=,<>>=,<=→=<
(exact term equality, no coercion); boolean&&→andalso,||→orelse,
unary!→not. A binary operand that is itself a binary expression is
parenthesized, so Go's grouping survives regardless of Erlang precedence. if→ Erlangcase— both the if/else form and the bare-if-plus-
continuation base case:→func Fact(N int) int { if N == 0 { return 1 } return N * Fact(N-1) }
Eachfact(N) -> case N =:= 0 of true -> 1; false -> N * fact(N - 1) end.
casebranch is emitted in its own binding scope (boundis
snapshotted and restored), so sibling clauses may reuse a name freshly while
a collision with an outer binding — a parameter, a:=, or a receive-pattern
field — is still rejected. A bare-ifwhose then-branch would fall through to
the continuation is rejected rather than silently mis-transpiled.
Correctness is proven end-to-end by a real-toolchain rung: a recursive
factorial fixture transpiles, compiles with erlc, and runs to a checked
result (fact(5) = 120) — the base-case loop actually closes.
Deliberately deferred to 0.3.3+ (all still error): switch, else if chains,
side-effect-only if, guards, multi-value return, cross-module plain calls.
Built subagent-driven and TDD: four tasks (fresh implementer + two-stage review
each), a whole-branch opus review returning "ready to merge" after independently
tracing the receive-field × branch-binding intersection — the exact class the
0.3.1 Copilot gate caught — and confirming it is rejected, not silently emitted.
Wintermute 0.3.1 — value model
Opens the 0.3.x transpiler-language line: the transpiler gains a value model,
without introducing control flow yet.
- Function parameters —
func Add(X, Y int) inttranspiles toadd(X, Y) ->.
Parameter names must be uppercase-leading (Erlang variables); lowercase is
rejected, never auto-capitalized. Exported functions now export with the
correct arityf/N(previously hardcodedf/0). - Trailing return —
return expras the last statement of a function body
emitsexpras the function's value. Early/multi-value returns are rejected,
pointing at 0.3.2 (they needcase/if). A return before a receive is
correctly rejected as a non-tail early return. - Local bindings —
Z := exprbecomes the Erlang matchZ = expr.
Re-assignment (=) and rebinding an already-bound name are rejected — Erlang
variables are immutable. - Calls with arguments —
f(A, B)transpiles tof(A, B), enabling
self-recursion emission (Erlang's last-call optimization is free). Non-trivial
recursion still awaits a base-case branch (case/if, 0.3.2).
Correctness is proven end-to-end by a new real-toolchain rung: a value-model
fixture transpiles and compiles with erlc to a .beam. Green unit tests alone
are not enough.
This is a review-clean, TDD-built branch: 5 subagent-driven tasks (fresh
implementer + two-stage review each), one Critical caught and fixed mid-branch
(return-before-receive), and a whole-branch opus review returning "ready to
merge". The transpiler is unchanged in scope beyond these four features; all
other constructs (operators beyond +, if/case/switch, guards, multi-value
return, cross-module calls) still error by design and are deferred to 0.3.2+.
Wintermute 0.3.0 — promotion of the 0.2.x line (review + fix sweep)
Promote the feature-complete 0.2.x line (single-node → distributed → gen_server
→ application → persistent node → release → self-contained target system →
native interop) to 0.3.0. This is a hardening/consolidation release: no new
capability, no transpiler or pkg/otp change.
Control-node hardening + DRY:
- The short-lived control nodes for
stop/status/call/attachnow load the
RCE-grade Erlang distribution cookie from a 0o600erl -args_file
(cookieArgsFile) instead of-setcookieon argv (previously visible via
/proc/ps for the sub-second lifetime) — closing the last cookie-on-argv
residual from 0.2.5. A shared controlTarget preamble replaces the duplicated
resolveApp→parseVersionFlag→readState→NewLayout in the three resolveApp-based
commands, and folds in erlang.ValidateVersion (previously unenforced for
stop/status/call/attach).
Security-review sweep (whole 0.2.x surface — no Critical/Important found; four
Minor hardenings folded):
- validVsn rejects "..".
- Untar rejects absolute/traversing symlink targets and surfaces os.Symlink errors.
- The generated bin/stop halts non-zero when the target node is unreachable
({badrpc,_}), instead of always exiting 0. - validAppName rejects shell/Erlang-dangerous characters (still accepts dotted
versions and lowercase app/module names).
Test hygiene:
- The integration tests no longer leak detached beam.smp nodes: bin/stop's exit
status cannot be trusted (async init:stop/0 + a stale baked-in rpc target once
the test rewrites vm.args), so cleanup now SIGKILL-sweeps any node rooted at
the test's unique temp dir. - CutSuffix nit in resolveApp.
All green on real OTP 29.0.3: unit suite; ladder (24 rungs) and CLI integration
(0.2.5 e2e + rung VII + native e2e), no leaked node; govulncheck/gitleaks clean;
gosec 5 HIGH all accepted G703 (path-traversal-taint on wm's own artifacts).
Signed-off-by: Boris Mühmer 328183+bsmr@users.noreply.github.com
Wintermute 0.2.7 — native Erlang interop (whole-module escape hatch)
Make Wintermute Go-first but Erlang-capable: wm build/wm release now accept
hand-written .erl modules alongside Go sources, for constructs the transpiler
subset cannot express but OTP needs (records, macros, complex guards, binary
pattern matching, list comprehensions).
Mechanism (single integration point — buildApp, shared by build and release):
a .erl input bypasses the transpiler, is validated (basename = module name,
guarded by validAppName; -module/filename mismatch rejected via erlModuleName),
copied through, and listed in the generated .app; downstream erlc + .app/
.rel generation is unchanged. Native modules are non-application by scope —
the application and supervisor modules stay Go.
Interop uses existing OTP mechanisms: a native gen_server registered
{global, Name} is reached from transpiled Go via otp.CallGlobal("Name", ...).
No new calling marker (deferred), no transpiler change, no pkg/otp change.
Testing (all green on real OTP 29.0.3): unit tests for buildApp .erl routing
and the -module/filename cross-check; a CLI integration e2e driving real
wm release --self-contained with a mixed Go+native input set, booted under a
scrubbed environment; and ladder rung VIII running a native gen_server (record +
guard) inside a supervised release with a transpiled-Go client. gosec unchanged
in class (5 HIGH, all accepted G703 path-traversal-taint; the one new finding is
the validAppName-guarded .erl copy).
Third-opinion review gate (GitHub Copilot) surfaced three items, all folded in:
a -module/filename cross-check (fail fast rather than defer to erlc), a shared
usage-hint constant (DRY), and a writeModule helper (KISS).
Signed-off-by: Boris Mühmer 328183+bsmr@users.noreply.github.com
Wintermute 0.2.6 — self-contained OTP target system
The seventh step of the 0.2.x line and the second with no transpiler change:
pure CLI/release tooling extending the 0.2.5 release builder.
wm release --self-containedproduces a standalone OTP target system tarball
(<app>-<vsn>.tar.gz, unpacking into a single<app>-<vsn>/dir) that Ops runs
on a host with NO Erlang installed via./bin/start. No wm, no system Erlang,
no secret in the artifact.- Bundles ERTS (systools:make_tar {erts}); non-local boot (paths resolve from the
bundled $ROOTDIR/lib); sasl in the .rel; assembled in Go: unpack -> start_clean.boot- RELEASES via erl -> top-level bin/ (erts launchers + generated self-locating
bin/start/bin/stop) + start_erl.data -> repack. Relocatability is free (modern erts
self-locates). Cookie via OTP-default ~/.erlang.cookie (no -setcookie, no secret).
- RELEASES via erl -> top-level bin/ (erts launchers + generated self-locating
- New: internal/pkg/release/{archive.go (mode-preserving, traversal-guarded tar
extract/repack), target.go (WriteLauncherLayout)} + launcher text helpers. No
transpiler change; wm start unchanged. - Ladder rung VII: the real artifact unpacked and booted via bin/start under a fully
scrubbed environment (no system Erlang) -> {global,echo} -> hello.
Reviews folded in: real-OTP integration corrected two assembly assumptions
(make_tar names the boot start.boot; make_tar omits vm.args); the final whole-branch
review fixed a tar-bomb (artifact now unpacks into one dir); the Copilot gate caught
a shell-injection via --vsn into the generated launchers (new validVsn) and a
swallowed TarGz close error. All fixed with regression tests.
Verification: build clean; go test ./... green; 23 integration ladder rungs + the
0.2.5 e2e + rung VII on real OTP 29.0.3; govulncheck/gitleaks clean; gosec only-HIGH
is the accepted G703 taint class (G115 fixed, G110/G122 annotated).
Signed-off-by: Boris Mühmer 328183+bsmr@users.noreply.github.com