Skip to content

Releases: bsmr/wintermute

Wintermute 0.3.7 — tagless + multi-type type switch

Choose a tag to compare

@bsmr bsmr released this 17 Jul 15:48

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 keeps v at the interface type in a multi-type case), so
    v.Field there is rejected loudly rather than emitted as an unbound or
    silently-outer-bound variable. Bare v (the whole value) is bound via the
    0.3.6 whole-alias machinery (V = {ping, _} / V when is_integer(V)).
  • Nested-alias foldbodyUsesBareAlias now 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

Choose a tag to compare

@bsmr bsmr released this 17 Jul 13:47

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 to V 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 bare V (Erlang alias pattern V = {tag, Fields}), and for the
    default: when its body uses bare V (catch-all binding V -> 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 Erlang case cannot
    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

Choose a tag to compare

@bsmr bsmr released this 17 Jul 10:33

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 new emitTypeSwitch entry
    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 Erlang case cannot 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.Field access resolves to the bound field variable; a bare alias V is
    rejected (whole-value passing deferred).
  • terminates() distinguishes the two forms: a value type switch terminates only
    with a default (a value case falls through in Go without one), so a
    default-carrying one may be a bare-if then-branch; a receive terminates without
    a default (it blocks, never falling through).
  • Runnable rung: testdata/typeswitch/classify.go transpiles, 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

Choose a tag to compare

@bsmr bsmr released this 13 Jul 14:17

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-clause receive — 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 case names 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 *Ping equals
    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 access v.Field lowers to the
    bound variable Field; a bare use of the alias v is rejected. Each clause
    runs in its own binding scope (bound snapshotted and restored), so a field
    collision with an outer binding is still rejected — the fourth
    bound-set-integration context.

  • default: is optional, and the choice is semantic: with a default: 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
    receive cannot fall through), so it may be the then-branch of a bare if.

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

Choose a tag to compare

@bsmr bsmr released this 13 Jul 08:02

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 → Erlang case-on-value — the expression-switch form,
    mirroring emitIf:

    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 default is 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 (bound snapshotted and restored),
    so the switch tag and sibling clauses obey the same outer-collision rejection
    as if branches 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 bare if, 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: 0777 produced Erlang 0777
    (read as 777 — a clean compile with the WRONG value) and 0x1F produced
    invalid Erlang. This was a pre-existing root cause affecting any octal/hex/
    binary literal, return 0x1F included. 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

Choose a tag to compare

@bsmr bsmr released this 13 Jul 04:57

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 → Erlang case — 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)
    }
    fact(N) -> case N =:= 0 of true -> 1; false -> N * fact(N - 1) end.
    Each case branch is emitted in its own binding scope (bound is
    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-if whose 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

Choose a tag to compare

@bsmr bsmr released this 12 Jul 21:59

Opens the 0.3.x transpiler-language line: the transpiler gains a value model,
without introducing control flow yet.

  • Function parametersfunc Add(X, Y int) int transpiles to add(X, Y) ->.
    Parameter names must be uppercase-leading (Erlang variables); lowercase is
    rejected, never auto-capitalized. Exported functions now export with the
    correct arity f/N (previously hardcoded f/0).
  • Trailing returnreturn expr as the last statement of a function body
    emits expr as the function's value. Early/multi-value returns are rejected,
    pointing at 0.3.2 (they need case/if). A return before a receive is
    correctly rejected as a non-tail early return.
  • Local bindingsZ := expr becomes the Erlang match Z = expr.
    Re-assignment (=) and rebinding an already-bound name are rejected — Erlang
    variables are immutable.
  • Calls with argumentsf(A, B) transpiles to f(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)

Choose a tag to compare

@bsmr bsmr released this 12 Jul 19:39

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/attach now load the
    RCE-grade Erlang distribution cookie from a 0o600 erl -args_file
    (cookieArgsFile) instead of -setcookie on 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)

Choose a tag to compare

@bsmr bsmr released this 12 Jul 19:39

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

Choose a tag to compare

@bsmr bsmr released this 12 Jul 19:39

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-contained produces 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).
  • 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