From 4297b0839820a570ad02375a79e54d3e148abafe Mon Sep 17 00:00:00 2001 From: Kevin Tang <73975146+vt128@users.noreply.github.com> Date: Sun, 14 Jun 2026 03:28:49 +0800 Subject: [PATCH 1/3] [fix] isolate REPL behind a build tag so the core compiles for js/wasm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The interactive Machine.REPL() imported go.starlark.net/repl, which pulls in github.com/chzyer/readline — a terminal library that does not compile for GOOS=js. Because run.go is always compiled, importing it there forced chzyer/readline into every consumer's build and made the whole starlet package fail to cross-compile for js/wasm (e.g. a browser WASM playground), even though no js code uses the REPL. Move REPL() out of run.go into build-tagged files: - run_repl.go (//go:build !js): the real REPL + the go.starlark.net/repl import — unchanged behavior on linux/macos/windows. - run_repl_js.go (//go:build js): a no-op stub keeping the API present so consumer code referencing REPL still compiles for wasm. readline stays in go.mod (the non-js REPL still needs it) but is no longer in the js import graph: `GOOS=js GOARCH=wasm go list -deps .` no longer shows readline/repl, and the package now cross-compiles for js/wasm. Test-first: TestBuildsForJSWASM cross-compiles the package for js/wasm and fails on the old code (chzyer/readline undefined symbols). Requirement: LET-30. Co-authored-by: Claude Opus 4.8 --- run.go | 15 +++++---------- run_repl.go | 19 +++++++++++++++++++ run_repl_js.go | 12 ++++++++++++ run_test.go | 22 ++++++++++++++++++++++ 4 files changed, 58 insertions(+), 10 deletions(-) create mode 100644 run_repl.go create mode 100644 run_repl_js.go diff --git a/run.go b/run.go index 55f6bfd..4dad580 100644 --- a/run.go +++ b/run.go @@ -11,20 +11,15 @@ import ( "github.com/1set/starlet/lib/goidiomatic" "github.com/1set/starlight/convert" - "go.starlark.net/repl" "go.starlark.net/starlark" "go.starlark.net/syntax" ) -// REPL is a Read-Eval-Print-Loop for Starlark. -// It loads the predeclared symbols and modules into the global environment, -func (m *Machine) REPL() { - if err := m.prepareThread(nil); err != nil { - repl.PrintError(err) - return - } - repl.REPLOptions(m.getFileOptions(), m.thread, m.predeclared) -} +// REPL is defined in run_repl.go (non-js) / run_repl_js.go (js): the +// interactive REPL pulls go.starlark.net/repl -> chzyer/readline, a terminal +// library that does not compile for GOOS=js. Isolating it behind a build tag +// keeps the library core (and every consumer, e.g. a WASM playground) +// free of that terminal dependency. // RunScript initiates a Machine, executes a script with extra variables, and returns the Machine and the execution result. func RunScript(content []byte, extras StringAnyMap) (*Machine, StringAnyMap, error) { diff --git a/run_repl.go b/run_repl.go new file mode 100644 index 0000000..5a4e9cb --- /dev/null +++ b/run_repl.go @@ -0,0 +1,19 @@ +//go:build !js + +package starlet + +import "go.starlark.net/repl" + +// REPL is a Read-Eval-Print-Loop for Starlark. It loads the predeclared +// symbols and modules into the global environment and reads from the terminal. +// +// This lives in a non-js file because go.starlark.net/repl pulls in +// chzyer/readline (a terminal library) which does not compile for GOOS=js; +// see run_repl_js.go for the wasm stub. +func (m *Machine) REPL() { + if err := m.prepareThread(nil); err != nil { + repl.PrintError(err) + return + } + repl.REPLOptions(m.getFileOptions(), m.thread, m.predeclared) +} diff --git a/run_repl_js.go b/run_repl_js.go new file mode 100644 index 0000000..614e9e5 --- /dev/null +++ b/run_repl_js.go @@ -0,0 +1,12 @@ +//go:build js + +package starlet + +// REPL is unavailable on js/wasm builds: the upstream go.starlark.net/repl +// pulls in a terminal library (chzyer/readline) that does not compile for +// GOOS=js, and an interactive terminal REPL is meaningless in a browser. +// Drive the Machine with Run/RunScript instead. This stub keeps the method +// present so consumer code referencing REPL still compiles for wasm. +func (m *Machine) REPL() { + // no-op in js/wasm; use Run/RunScript +} diff --git a/run_test.go b/run_test.go index 8ead922..32a6d13 100644 --- a/run_test.go +++ b/run_test.go @@ -6,6 +6,7 @@ import ( "fmt" "io/fs" "os" + "os/exec" "reflect" "testing" "time" @@ -2140,6 +2141,27 @@ func TestMachine_REPL_Error(t *testing.T) { m.REPL() } +// TestBuildsForJSWASM guards the GOOS=js/wasm portability of the library core. +// The interactive REPL pulls go.starlark.net/repl -> github.com/chzyer/readline, +// a terminal library that does not compile for GOOS=js; it is therefore +// isolated behind a build tag (run_repl.go //go:build !js, run_repl_js.go +// //go:build js). Re-importing a terminal dependency into an always-compiled +// file would silently break any js/wasm consumer (e.g. a browser playground). +// This cross-compiles the package for js/wasm to catch that regression early. +func TestBuildsForJSWASM(t *testing.T) { + goBin, err := exec.LookPath("go") + if err != nil { + t.Skip("go toolchain not found; skipping js/wasm build check") + } + cmd := exec.Command(goBin, "build", ".") + cmd.Env = append(os.Environ(), "GOOS=js", "GOARCH=wasm") + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("starlet must compile for GOOS=js GOARCH=wasm — did a terminal "+ + "dependency (e.g. chzyer/readline via go.starlark.net/repl) leak into "+ + "an always-compiled file?\n%s\n%v", out, err) + } +} + func Test_Machine_Run_LoadTypedErrors(t *testing.T) { // an unknown name surfaces as ModuleNotFoundError through the chain m := starlet.NewDefault() From c1d219f5d7c15fcbee939a48156f5542ee726876 Mon Sep 17 00:00:00 2001 From: Kevin Tang <73975146+vt128@users.noreply.github.com> Date: Mon, 15 Jun 2026 14:45:56 +0800 Subject: [PATCH 2/3] fix: stub REPL on wasm targets --- run.go | 9 ++++---- run_repl.go | 8 +++---- run_repl_js.go | 12 ----------- run_repl_stub.go | 12 +++++++++++ run_test.go | 55 ++++++++++++++++++++++++++++++++++++------------ 5 files changed, 62 insertions(+), 34 deletions(-) delete mode 100644 run_repl_js.go create mode 100644 run_repl_stub.go diff --git a/run.go b/run.go index 4dad580..202b069 100644 --- a/run.go +++ b/run.go @@ -15,11 +15,12 @@ import ( "go.starlark.net/syntax" ) -// REPL is defined in run_repl.go (non-js) / run_repl_js.go (js): the +// REPL is defined in run_repl.go (terminal targets) / run_repl_stub.go +// (non-terminal targets): the // interactive REPL pulls go.starlark.net/repl -> chzyer/readline, a terminal -// library that does not compile for GOOS=js. Isolating it behind a build tag -// keeps the library core (and every consumer, e.g. a WASM playground) -// free of that terminal dependency. +// library that does not compile for browser js/wasm or WASI. Isolating it +// behind a build tag keeps the library core (and every consumer, e.g. a WASM +// playground) free of that terminal dependency. // RunScript initiates a Machine, executes a script with extra variables, and returns the Machine and the execution result. func RunScript(content []byte, extras StringAnyMap) (*Machine, StringAnyMap, error) { diff --git a/run_repl.go b/run_repl.go index 5a4e9cb..787fc48 100644 --- a/run_repl.go +++ b/run_repl.go @@ -1,4 +1,4 @@ -//go:build !js +//go:build !js && !wasip1 package starlet @@ -7,9 +7,9 @@ import "go.starlark.net/repl" // REPL is a Read-Eval-Print-Loop for Starlark. It loads the predeclared // symbols and modules into the global environment and reads from the terminal. // -// This lives in a non-js file because go.starlark.net/repl pulls in -// chzyer/readline (a terminal library) which does not compile for GOOS=js; -// see run_repl_js.go for the wasm stub. +// This lives in a terminal-target file because go.starlark.net/repl pulls in +// chzyer/readline, a terminal library that does not compile for browser +// js/wasm or WASI; see run_repl_stub.go for the no-terminal stub. func (m *Machine) REPL() { if err := m.prepareThread(nil); err != nil { repl.PrintError(err) diff --git a/run_repl_js.go b/run_repl_js.go deleted file mode 100644 index 614e9e5..0000000 --- a/run_repl_js.go +++ /dev/null @@ -1,12 +0,0 @@ -//go:build js - -package starlet - -// REPL is unavailable on js/wasm builds: the upstream go.starlark.net/repl -// pulls in a terminal library (chzyer/readline) that does not compile for -// GOOS=js, and an interactive terminal REPL is meaningless in a browser. -// Drive the Machine with Run/RunScript instead. This stub keeps the method -// present so consumer code referencing REPL still compiles for wasm. -func (m *Machine) REPL() { - // no-op in js/wasm; use Run/RunScript -} diff --git a/run_repl_stub.go b/run_repl_stub.go new file mode 100644 index 0000000..c9488a7 --- /dev/null +++ b/run_repl_stub.go @@ -0,0 +1,12 @@ +//go:build js || wasip1 + +package starlet + +// REPL is unavailable on no-terminal wasm targets: the upstream +// go.starlark.net/repl pulls in chzyer/readline, which depends on terminal +// primitives that are not available in browser js/wasm or WASI. Drive the +// Machine with Run/RunScript instead. This stub keeps the method present so +// consumer code referencing REPL still compiles for wasm. +func (m *Machine) REPL() { + // no-op on no-terminal wasm targets; use Run/RunScript +} diff --git a/run_test.go b/run_test.go index 32a6d13..04a1125 100644 --- a/run_test.go +++ b/run_test.go @@ -8,6 +8,7 @@ import ( "os" "os/exec" "reflect" + "strings" "testing" "time" @@ -2141,25 +2142,51 @@ func TestMachine_REPL_Error(t *testing.T) { m.REPL() } -// TestBuildsForJSWASM guards the GOOS=js/wasm portability of the library core. +// TestBuildsForWASM guards the wasm portability of the library core. // The interactive REPL pulls go.starlark.net/repl -> github.com/chzyer/readline, -// a terminal library that does not compile for GOOS=js; it is therefore -// isolated behind a build tag (run_repl.go //go:build !js, run_repl_js.go -// //go:build js). Re-importing a terminal dependency into an always-compiled -// file would silently break any js/wasm consumer (e.g. a browser playground). -// This cross-compiles the package for js/wasm to catch that regression early. -func TestBuildsForJSWASM(t *testing.T) { +// a terminal library that does not compile for no-terminal wasm targets; it is +// therefore isolated behind build tags (run_repl.go for terminal targets, +// run_repl_stub.go for js/wasm and WASI). Re-importing a terminal dependency +// into an always-compiled file would silently break wasm consumers (e.g. a +// browser playground). +func TestBuildsForWASM(t *testing.T) { goBin, err := exec.LookPath("go") if err != nil { - t.Skip("go toolchain not found; skipping js/wasm build check") + t.Skip("go toolchain not found; skipping wasm build check") } - cmd := exec.Command(goBin, "build", ".") - cmd.Env = append(os.Environ(), "GOOS=js", "GOARCH=wasm") - if out, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("starlet must compile for GOOS=js GOARCH=wasm — did a terminal "+ - "dependency (e.g. chzyer/readline via go.starlark.net/repl) leak into "+ - "an always-compiled file?\n%s\n%v", out, err) + + targets := []struct { + goos string + goarch string + }{ + {goos: "js", goarch: "wasm"}, + {goos: "wasip1", goarch: "wasm"}, + } + for _, target := range targets { + target := target + t.Run(target.goos+"/"+target.goarch, func(t *testing.T) { + if !goTargetSupported(t, goBin, target.goos, target.goarch) { + t.Skipf("go toolchain does not support %s/%s", target.goos, target.goarch) + } + cmd := exec.Command(goBin, "build", ".") + cmd.Env = append(os.Environ(), "GOOS="+target.goos, "GOARCH="+target.goarch) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("starlet must compile for GOOS=%s GOARCH=%s — did a terminal "+ + "dependency (e.g. chzyer/readline via go.starlark.net/repl) leak into "+ + "an always-compiled file?\n%s\n%v", target.goos, target.goarch, out, err) + } + }) + } +} + +func goTargetSupported(t *testing.T, goBin, goos, goarch string) bool { + t.Helper() + out, err := exec.Command(goBin, "tool", "dist", "list").Output() + if err != nil { + t.Logf("warning: failed to list go targets: %v", err) + return false } + return strings.Contains(string(out), goos+"/"+goarch+"\n") } func Test_Machine_Run_LoadTypedErrors(t *testing.T) { From d91a3cd67743ffa0387cf0e0a2b88d6bfdd821cb Mon Sep 17 00:00:00 2001 From: Kevin Tang <73975146+vt128@users.noreply.github.com> Date: Mon, 15 Jun 2026 15:01:33 +0800 Subject: [PATCH 3/3] ci: add wasm cross-build checks --- .github/workflows/build.yml | 56 +++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 66201ae..87db682 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -89,6 +89,62 @@ jobs: revive -config revive.toml -formatter friendly ./... >> $GITHUB_STEP_SUMMARY printf '```\n\n' >> $GITHUB_STEP_SUMMARY + wasm: + name: Cross-build ${{ matrix.goos }}/${{ matrix.goarch }} with ${{ matrix.go-version }} on ${{ matrix.vm-os }} + runs-on: ${{ matrix.vm-os }} + strategy: + max-parallel: 10 + fail-fast: false + matrix: + vm-os: [ + ubuntu-22.04, + macos-14, + windows-2022 + ] + go-version: [ + 1.19.x, + 1.25.x, + ] + goos: [ + js, + wasip1 + ] + goarch: [ + wasm + ] + exclude: + # WASI support starts after Go 1.19; keep the compatibility floor + # on browser js/wasm and verify WASI on the current toolchain. + - go-version: 1.19.x + goos: wasip1 + permissions: + contents: read + steps: + - uses: actions/checkout@v5 + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: ${{ matrix.go-version }} + cache: true + - name: Cross-build package + env: + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + run: | + go version + go tool dist list | grep -Fx "${GOOS}/${GOARCH}" + go mod download + go build . + - name: Guard REPL terminal dependencies + env: + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + run: | + if go list -deps . | grep -E '^(go\.starlark\.net/repl|github\.com/chzyer/readline)$'; then + echo "terminal-only REPL dependency leaked into ${GOOS}/${GOARCH}" + exit 1 + fi + # cmd/starlet is a separate Go module with its own (older) pins and extra # third-party dependencies; build it as a non-gating signal so a CLI-side # breakage is visible without blocking library PRs.