diff --git a/.github/workflows/lgb-diff.yml b/.github/workflows/lgb-diff.yml
new file mode 100644
index 000000000..f126cf3e7
--- /dev/null
+++ b/.github/workflows/lgb-diff.yml
@@ -0,0 +1,95 @@
+name: lgb-diff
+
+# Opt-in bundle diff: label a PR `lgb-diff` and this posts (and keeps updated)
+# a sticky comment with the semantic bytecode diff of pkg/rt/core_compiled.lgb
+# against the merge-base, rendered by scripts/lgbdump.lg. Same-repo PRs only:
+# fork PRs get a read-only token, so the comment step would fail there.
+
+on:
+ pull_request:
+ types: [labeled, synchronize]
+
+permissions:
+ contents: read
+ pull-requests: write
+
+concurrency:
+ group: lgb-diff-${{ github.event.pull_request.number }}
+ cancel-in-progress: true
+
+jobs:
+ diff:
+ if: contains(github.event.pull_request.labels.*.name, 'lgb-diff')
+ runs-on: ubuntu-latest
+ steps:
+ # Head SHA, not the merge ref: the lg built here must share an opcode
+ # enum with the bundle it dumps, and the head tree is that producer.
+ - uses: actions/checkout@v4
+ with:
+ ref: ${{ github.event.pull_request.head.sha }}
+ fetch-depth: 0
+
+ - name: Locate bundle change
+ id: bundle
+ run: |
+ MB=$(git merge-base HEAD "origin/${{ github.event.pull_request.base.ref }}")
+ echo "mb=$MB" >> "$GITHUB_OUTPUT"
+ if git diff --quiet "$MB" HEAD -- pkg/rt/core_compiled.lgb; then
+ echo "changed=false" >> "$GITHUB_OUTPUT"
+ else
+ echo "changed=true" >> "$GITHUB_OUTPUT"
+ fi
+
+ - uses: actions/setup-go@v5
+ if: steps.bundle.outputs.changed == 'true'
+ with:
+ go-version-file: go.mod
+ cache: true
+
+ - name: Build lg, dump both bundles, diff
+ if: steps.bundle.outputs.changed == 'true'
+ run: |
+ make lg
+ MB=${{ steps.bundle.outputs.mb }}
+ git show "$MB:pkg/rt/core_compiled.lgb" > /tmp/base.lgb
+ ./lg scripts/lgbdump.lg /tmp/base.lgb > /tmp/base.txt
+ ./lg scripts/lgbdump.lg pkg/rt/core_compiled.lgb > /tmp/head.txt
+ # diff(1) semantics: exit 1 just means the bundles differ.
+ diff -u --label "base ($(git rev-parse --short "$MB"))" --label head \
+ /tmp/base.txt /tmp/head.txt > /tmp/lgb.diff || true
+
+ - name: Post sticky comment
+ env:
+ GH_TOKEN: ${{ github.token }}
+ REPO: ${{ github.repository }}
+ PR: ${{ github.event.pull_request.number }}
+ run: |
+ MARKER=''
+ MB_SHORT=$(git rev-parse --short "${{ steps.bundle.outputs.mb }}")
+ {
+ echo "$MARKER"
+ echo "### lgb-diff: \`pkg/rt/core_compiled.lgb\` vs merge-base \`$MB_SHORT\`"
+ echo ""
+ if [ "${{ steps.bundle.outputs.changed }}" = "false" ]; then
+ echo "Bundle unchanged."
+ else
+ LINES=$(wc -l < /tmp/lgb.diff)
+ echo "unified diff ($LINES lines)
"
+ echo ""
+ echo '```diff'
+ head -400 /tmp/lgb.diff
+ echo '```'
+ echo " "
+ if [ "$LINES" -gt 400 ]; then
+ echo ""
+ echo "_Truncated at 400 lines; dump both bundles with \`scripts/lgbdump.lg\` locally for the rest._"
+ fi
+ fi
+ } > /tmp/comment.md
+ CID=$(gh api "repos/$REPO/issues/$PR/comments" --paginate \
+ --jq ".[] | select(.body | startswith(\"$MARKER\")) | .id" | head -1)
+ if [ -n "$CID" ]; then
+ gh api -X PATCH "repos/$REPO/issues/comments/$CID" -F body=@/tmp/comment.md
+ else
+ gh pr comment "$PR" --repo "$REPO" --body-file /tmp/comment.md
+ fi
diff --git a/pkg/compiler/compiler.go b/pkg/compiler/compiler.go
index d692cfd1f..e806b463f 100644
--- a/pkg/compiler/compiler.go
+++ b/pkg/compiler/compiler.go
@@ -195,6 +195,7 @@ func (c *Context) CompileMultiple(reader io.Reader) (compiled *vm.CodeChunk, res
compiledForms++
return nil
}
+ sawVoid := false
for {
o, err := r.Read()
if err != nil {
@@ -203,10 +204,23 @@ func (c *Context) CompileMultiple(reader io.Reader) (compiled *vm.CodeChunk, res
}
return nil, result, err
}
+ // Comments, #_ discards, and empty reader conditionals read as the
+ // VOID sentinel. Compiling them would emit a dead LOAD_CONST/POP pair
+ // per occurrence (and let a trailing comment clobber the last value),
+ // so skip them here like every other read loop does.
+ if o.Type() == vm.VoidType {
+ sawVoid = true
+ continue
+ }
if err := evalTopForm(o); err != nil {
return nil, result, err
}
}
+ // Input that held only no-value forms still evaluates to VOID (not nil),
+ // so the REPL keeps echoing nothing for a comment-only line.
+ if compiledForms == 0 && sawVoid {
+ result = vm.VOID
+ }
c.chunk = chunk
diff --git a/pkg/compiler/void_toplevel_test.go b/pkg/compiler/void_toplevel_test.go
new file mode 100644
index 000000000..7899b713f
--- /dev/null
+++ b/pkg/compiler/void_toplevel_test.go
@@ -0,0 +1,69 @@
+/*
+ * Copyright (c) 2026 let-go contributors
+ * SPDX-License-Identifier: MIT
+ */
+
+package compiler
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/nooga/let-go/pkg/rt"
+ "github.com/nooga/let-go/pkg/vm"
+)
+
+// A trailing comment (or #_ discard) must not clobber the last value: the
+// reader surfaces those as the VOID sentinel and CompileMultiple used to
+// compile-and-run it like a real form, so `42 ;; done` evaluated to VOID.
+func TestTrailingCommentKeepsLastValue(t *testing.T) {
+ for _, src := range []string{
+ "42 ;; done",
+ "42\n;; done\n",
+ "42 #_(discarded)",
+ "42 #?(:cljr 1)",
+ } {
+ v, err := Eval(src)
+ if err != nil {
+ t.Fatalf("Eval(%q): %v", src, err)
+ }
+ if n, ok := v.(vm.Int); !ok || int(n) != 42 {
+ t.Fatalf("Eval(%q) = %v (%s), want 42", src, v, v.Type().Name())
+ }
+ }
+}
+
+// Input holding only no-value forms still evaluates to VOID, not nil — the
+// REPL relies on this to echo nothing for a comment-only line.
+func TestCommentOnlyInputEvaluatesToVoid(t *testing.T) {
+ for _, src := range []string{
+ ";; just a comment",
+ "#_(all discarded)",
+ } {
+ v, err := Eval(src)
+ if err != nil {
+ t.Fatalf("Eval(%q): %v", src, err)
+ }
+ if v != vm.VOID {
+ t.Fatalf("Eval(%q) = %v (%s), want VOID", src, v, v.Type().Name())
+ }
+ }
+}
+
+// Skipped forms must not leave dead LOAD_CONST/POP pairs in the chunk: a
+// comment between two forms compiles identically to no comment at all.
+func TestVoidFormsEmitNoCode(t *testing.T) {
+ compile := func(src string) []int32 {
+ c := NewTransientCompiler(consts, rt.NS(rt.NameCoreNS))
+ chunk, _, err := c.CompileMultiple(strings.NewReader(src))
+ if err != nil {
+ t.Fatalf("CompileMultiple(%q): %v", src, err)
+ }
+ return chunk.Code()
+ }
+ plain := compile("1 2")
+ commented := compile("1 ;; between\n#_(dead) 2")
+ if len(plain) != len(commented) {
+ t.Fatalf("comments changed emitted code size: %d words vs %d", len(plain), len(commented))
+ }
+}
diff --git a/pkg/rt/core_compiled.lgb b/pkg/rt/core_compiled.lgb
index 22e4f1525..8a893fb48 100644
Binary files a/pkg/rt/core_compiled.lgb and b/pkg/rt/core_compiled.lgb differ
diff --git a/scripts/lgbdump.lg b/scripts/lgbdump.lg
new file mode 100644
index 000000000..959c1086a
--- /dev/null
+++ b/scripts/lgbdump.lg
@@ -0,0 +1,75 @@
+;; scripts/lgbdump.lg — canonical text dump of a .lgb bundle, for diffing.
+;;
+;; ./lg scripts/lgbdump.lg
+;;
+;; Diff two bundles by diffing two dumps:
+;;
+;; diff -u <(./lg scripts/lgbdump.lg old.lgb) <(./lg scripts/lgbdump.lg new.lgb)
+;;
+;; Builds on disasm/decode-bundle (decodes without executing) and
+;; disassemble-resolved (LOAD_CONST/LOAD_VAR rows carry the referenced
+;; identifier, so const-pool index shifts don't drown a diff). Output is
+;; deterministic: namespaces sorted by name, no addresses or pool order.
+;;
+;; Named functions from the shared const pool are dumped under their name;
+;; anonymous ones under a bare "fn anon" label (a pool index would shift on
+;; any insertion, making identical fns diff as different).
+
+(ns lgbdump
+ (:require [disasm]))
+
+;; A resolved LOAD_CONST/LOAD_VAR row is [op pool-idx identifier]. The pool
+;; index shifts whenever any earlier const is added or removed, which turns a
+;; one-fn change into a whole-bundle diff — so for diffing, keep only the
+;; identifier. Unresolved rows keep their index (it's all we have).
+(defn scrub-row [row]
+ (if (and (>= (count row) 3)
+ (or (= :LOAD_CONST (first row)) (= :LOAD_VAR (first row))))
+ (vector (first row) (nth row 2))
+ row))
+
+;; Top-level comments and #_ discards compile to a LOAD_CONST-VOID/POP pair
+;; (dead code; being removed by #600). Rendering each pair would make "added
+;; a comment" look like bytecode churn, so suppress them and print one count
+;; line per chunk instead — the count still diffs when it changes.
+(defn void-load? [row]
+ (and (= :LOAD_CONST (first row))
+ (let [x (nth row (dec (count row)))]
+ (and (vector? x) (= :opaque (first x)) (= "VOID" (second x))))))
+
+(defn dump-chunk [label chunk]
+ (println (str "== " label " =="))
+ (loop [rows (seq (disasm/disassemble-resolved chunk)) skipped 0]
+ (if (nil? rows)
+ (when (> skipped 0)
+ (println (str ";; " skipped " no-value pairs suppressed")))
+ (let [row (first rows)
+ nxt (next rows)]
+ (if (and (void-load? row) nxt (= :POP (first (first nxt))))
+ (recur (next nxt) (inc skipped))
+ (do (println (pr-str (scrub-row row)))
+ (recur nxt skipped)))))))
+
+(defn dump-bundle [path]
+ (let [b (disasm/decode-bundle path)
+ nss (sort-by (fn [m] (get m :ns)) (get b :namespaces))]
+ (println (str ";; lgbdump: " (count nss) " namespaces, "
+ (count (get b :consts)) " consts"))
+ (dump-chunk "main" (get b :main))
+ (doseq [m nss]
+ (dump-chunk (str "ns " (get m :ns)) (get m :chunk)))
+ ;; Nested fns live in the bundle-wide shared const pool: raw values give
+ ;; the chunks, the projected view gives stable [:fn name] labels.
+ (let [raw (disasm/constants (get b :main))
+ proj (get b :consts)]
+ (doseq [i (range (count raw))]
+ (let [p (nth proj i)]
+ (when (and (vector? p) (= :fn (first p)))
+ (dump-chunk (str "fn " (if (> (count p) 1) (second p) "anon"))
+ (nth raw i))))))))
+
+(let [path (first *command-line-args*)]
+ (if (nil? path)
+ (do (println "usage: lg scripts/lgbdump.lg ")
+ (os/exit 2))
+ (dump-bundle path)))