feat(interop): self-contained interop packages and an importable pkg/cli - #773
Conversation
…tree use lginterop could only run inside a let-go checkout, and could only emit files that compile as part of pkg/rt. A third-party module could not use it to generate bindings for an arbitrary Go package. The tool walked up to go.mod to find a checkout, built a fresh lg there, and shelled out to it with -source-paths pointing at scripts/. Move the codegen script into cmd/lginterop (go:embed cannot reach outside the package directory), embed it, and evaluate it through the runtime this binary already links: gogen registers via pkg/rt's installer queue, so any binary importing rt has the emitter — nothing to find, nothing to build. Output is byte-identical and the round-trip golden drops from ~5.2s to ~0.9s. CompileMultiple rather than pkg/api's Run: the script is a sequence of top-level forms and Run compiles only the first, silently dropping the rest. -out-pkg <name> emits the self-contained form. Three things change: the package clause, a pkg/rt import with qualified rt.RegisterNS, and — the load-bearing one — a DIRECT install call from init() in place of RegisterInstaller. rt drains its installer queue during its own package init, and Go runs an imported package's init before the importer's, so an out-of-tree RegisterInstaller enqueues after the drain and silently never runs. A direct call is safe: rt is fully initialized by then, RegisterNS is mutex-guarded, and ordering relative to LoadCore matches an in-tree installer. Omitting the flag stays byte-identical, so the golden and the in-tree files are untouched. Rejected inputs rather than silent breakage: -out-pkg rt is ambiguous with the in-tree output; Go keywords, the blank identifier and non-identifier characters would emit unparseable Go. A package whose own alias is rt would collide with the runtime import, so the runtime is aliased to letgort in exactly that case. The equivalent collision for vm (always imported) and fmt (smart mode) predates this and affects the in-tree path too; deliberately left alone to keep this change scoped. lginterop also exited 0 after generating nothing, because per-package failures log and continue — a build pipeline would have seen success and a missing file. Exits non-zero now. scripts/ir-stress-corpus.edn follows the moved script, so the coverage ratchet does not bucket it as a read-error. Tests: the existing round-trip golden unchanged; a new e2e that compiles out-of-tree output in a scratch module behind a replace directive rather than only grepping it; the rt-alias collision forced through deps.edn's alias form and compiled; rejected -out-pkg names; and the non-zero exit. Docs cover the flag, the init-timing rationale and the module-context caveat. Baseline: main a665761.
The whole command line lived in package main at the module root, so a third-party module could only build an lg-runtime-style bytecode host — no repl, no resolver, no -b. That is not enough for a binary built around generated interop, which has to BE lg: it runs scripts against the native namespace, and it is the compiling side of lg -b, where top-level forms execute at AOT time and a veneer's (:require) must resolve that namespace at build time too. Move lg.go and its build-tagged siblings into pkg/cli behind Main(version, commit string) int, which returns an exit code instead of calling os.Exit. The move is mechanical: the only edit to the tagged files is the package clause. Two things deliberately stay at the root. version/commit, because goreleaser and the Makefile both set -X main.version / -X main.commit and ldflags can only reach the declaring package — Main takes them as arguments. And lg_gogen_ir.go, a gitignored build artifact of blank imports whose path, package and tag are hardcoded in cmd/lgbgen/main.go, the Makefile, scripts/fanout-ratchet.lg and a determinism test; blank imports behave identically from package main, so moving it costs four call sites and buys nothing. Flag registration moves out of init() into a sync.Once from Main, so importing pkg/cli no longer mutates the global flag set before a custom main can register its own. Flags stay on flag.CommandLine rather than a private FlagSet precisely so a custom main's own flags still get parsed; the consequences — -h exits from inside Main, and Main is call-once-per-process because option state is package-level — are stated in its doc comment rather than implied away. One bug this creates and fixes in the same breath: -w passed the ldflags-stamped version to gomod.Generate, which turns it into github.com/nooga/let-go@v<version>. Correct while let-go was always the main module, wrong once pkg/cli is importable, since a custom binary's stamp describes its OWN module — a custom lg at v2.0.0 would pin the generated WASM module to a let-go release that does not exist. Read let-go's entry from BuildInfo.Deps instead. Also fixed while here: profile flags still registered from init() under lg_profile, the exact import side effect the other flags were moved out of init() to avoid. The e2e builds the real thing — generate interop for a stdlib package, blank-import it beside pkg/cli, run a script through the resulting binary, then bundle with that binary as its own bundle base and run the result. Hermetic: a replace directive and a stdlib target, so nothing is fetched. Writing it surfaced an emitter bug. ex-struct? tested the export vector's LENGTH, but all three :type serialization branches produce six elements — the non-struct branch writes `nil []` where the struct branch writes `:struct [fields]` — so it answered true for every named type and emitted vm.RegisterStruct for named arrays. hash/crc32's Table is `type Table [256]uint32`, so the generated package panicked at init with "expected struct, got array" before main ever ran. The in-tree xxh3 golden never caught it because xxh3 uses -opaque-structs, the one flag that skips RegisterStruct. Test the marker instead; both directions pinned; golden byte-identical. docs/guide/custom-lg.md documents the module layout, the blank-import pattern, the init-timing note and the bundle-base story, including that -w cannot carry custom namespaces: the WASM build scaffolds a fresh module from a fixed template that imports runtime packages only. Verified as a pure refactor: make test green; default, lg_profile, gogen_ir, plan9 and js/wasm all build; and bin/lg -v, -e and the full -h flag set are byte-identical to a binary built from main, bar the usage line's program path. GOOS=windows fails in pkg/rt/term.go before and after — pre-existing. Baseline: main a665761. Depends on the lginterop out-of-tree change.
|
The idea behind this PR is what |
nnunley
left a comment
There was a problem hiding this comment.
Reviewed at ba4f3be64a694b3d4eec8be96e8e3e68419755f6. Requesting changes for public API and generator contract failures that the current tests do not cover.
Must fix
-
Custom-host metadata overwrites let-go runtime identity.
pkg/cli/cli.go:407-409assigns the host strings passed tocli.Maintort.Version/rt.Commit, whilepkg/rt/system.goexposes those values aslet-go.versionandlet-go.commit. The new guide explicitly says these strings describe the host. A custom binary callingcli.Main("host-v9", ...)therefore returns"host-v9"for(System/getProperty "let-go.version"), which can break runtime-version feature checks. Keep host CLI metadata distinct from the let-go dependency/runtime metadata and add a test where they differ. -
The documented local-replace custom-host
-wpath selects nonexistentv0.0.0.pkg/cli/wasm.go:54-62readsdep.Versionbut ignoresdep.Replace. The guide and e2e build custom hosts withrequire github.com/nooga/let-go v0.0.0plus a local replace, so real BuildInfo returnsv0.0.0with a non-nil replacement.gomod.Generatethen treats that as a release before consideringLETGO_SRC. Reproduced withGOPROXY=off: stock-namespace-wfails ongithub.com/nooga/let-go@v0.0.0even withLETGO_SRCset. Model the realModule{Version:"v0.0.0", Replace:...}shape in tests and honor the replacement. -
Accepted self-contained generator inputs can produce invalid or overwritten output. The new
-out-pkgcontract says the result is usable as an imported package, but:- aliases
vm, orfmtwith-smart, collide with generator-owned imports incmd/lginterop/lginterop.lg:341-348; an out-of-treevmalias reproducedvm redeclaredand undefined target symbols; -out-pkg mainpassesvalidateOutPkg, generates successfully, then cannot be blank-imported (is a program, not an importable package);- aliases
foo-barandfoo_barpass the rawseenAliascheck (cmd/lginterop/main.go:110-123) but both normalize tointerop_foo_bar.go; the second silently overwrites the first and the tool reports2/2.
Some collision mechanics predate this PR, but the new self-contained-package contract knowingly exposes them as accepted external inputs. Either reject these names before writing or choose collision-free generated aliases/paths. Add compile/output-count tests for each case.
- aliases
-
Generation success and reproducibility claims are false for valid workflows.
generatePackagereturns nil when a package has zero eligible exports (cmd/lginterop/main.go:343-345), so the new failure accounting reportsgenerated 1/1, exits 0, and writes no file. I reproduced this with a local package containing only unexported declarations. Also, the new docs promise that the generated header round-trips, butcmd/lginterop/lginterop.lg:367-383omits a deps.edn alias, output path, and generator version. A custom alias therefore regenerates under the default namespace/filename; an unversionedlginteropcommand cannot pin the documentedgo run ...@<version>workflow. Define skip accounting explicitly, and either make the header reproducible or narrow the documentation claim. Add byte-for-byte regeneration coverage for a non-default alias.
Should fix
docs/guide/custom-lg.md:43-45 says the host controls shutdown because Main returns an exit code, but flag.CommandLine uses ExitOnError; help and malformed flags still call os.Exit inside Main, as pkg/cli/cli.go:598-602 admits. Narrow the guide claim or give callers ownership of the FlagSet/args.
Validation
The existing focused suites pass:
go test ./cmd/lginterop ./pkg/cligo test ./test/e2e -run 'TestCustomMain|TestLginterop' -short=false -count=1
All GitHub checks are green on the reviewed head. These findings are uncovered contract cases, not existing-test failures.
Review fixes for nooga#773 (all four must-fix items plus the should-fix): - Host CLI metadata no longer overwrites the runtime identity: rt.Version/ rt.Commit resolve from let-go's own dep entry in build info for a custom host, so cli.Main("host-v9", ...) cannot leak into let-go.version feature checks. The host stamp still drives -v. - letgoVersionFrom honors replace directives: the documented require-v0.0.0 + directory-replace setup resolves to the local-source path instead of selecting a nonexistent v0.0.0 release; a versioned replace pins to the replacement's version. - lginterop rejects inputs that produce invalid or clobbered output before writing: aliases colliding with the emitted file's own imports (vm, and fmt in smart mode), -out-pkg main (not importable), and distinct aliases that normalize to one interop_<alias>.go filename. - Generation accounting is explicit: a package with no eligible exports is reported as skipped in the summary, not counted as generated output that was never written. - The generated-by header round-trips for non-default aliases: -packages gains a path=alias form (mirroring deps.edn) and the header records it, plus a version line when the generator ran via go run ...@<version>. -out stays deliberately omitted (the file's location supplies it) and the docs now say so. - docs/guide/custom-lg.md narrows the shutdown claim: flag.CommandLine is ExitOnError, so -h and malformed flags exit inside Main.
|
@nnunley Thanks for the review. All five items are addressed, and the branch is updated from main.
|
mparrett
left a comment
There was a problem hiding this comment.
Reviewed the updated head. The focused CLI/lginterop suites and custom-main e2e tests pass, and GitHub checks are green. I found two remaining code-contract issues plus one documentation inconsistency; details are inline.
| } | ||
| v := strings.TrimPrefix(dep.Version, "v") | ||
| if v == "" || v == "(devel)" { | ||
| return "dev", "none" |
There was a problem hiding this comment.
[P1] Preserve the replacement source for custom -w builds. Following dep.Replace but returning only its version discards the replacement path. With the documented require v0.0.0 plus replace => /local/let-go, this returns dev; gomod.Generate cannot discover that path from the custom module or executable and falls back to @latest. I reproduced customlg -w failing with GOPROXY=off; online it can silently build against a different release. Please carry the replacement path/mapping into the generated module.
There was a problem hiding this comment.
You were right that carrying
only the version discards the path. pkg/gomod gained GenerateFrom, which
takes an explicit let-go source directory and emits replace <let-go> => <dir>
through the existing localFiles path, validating the directory first and
falling back to ordinary resolution when it is not let-go's module. pkg/cli
extracts the path from dep.Replace in build info and passes it through.
Worth recording: my first cut still failed your repro. Go writes
Replace.Version as "(devel)" for a directory replace, not "", so the
guard I wrote rejected the real shape and the build still fell through to
@latest. TestCustomMain now has a subtest that runs customlg -w with
GOPROXY=off, which is what caught it: it fails with exactly your
module lookup disabled by GOPROXY=off without the fix.
| prev.alias, prev.pkg, alias, ent.pkg, normalized) | ||
| } | ||
| okCount++ | ||
| seenFile[normalized] = owner{pkg: ent.pkg, alias: alias} |
There was a problem hiding this comment.
[P2] Reject aliases that are invalid Go identifiers. This validates import and filename collisions, but not whether normalized can be used as a Go import name. -packages hash/crc32=for exits 0 and emits for "hash/crc32" plus for.Checksum; _ similarly produces invalid selectors. Validate the normalized alias against Go keywords and the blank identifier before generation.
There was a problem hiding this comment.
validateEntries now
checks the normalized alias against the Go keyword set and the identifier
rules, sharing isGoIdent with validateOutPkg. -packages hash/crc32=for
and =_ exit non-zero before anything is written; =my-pkg still normalizes
to my_pkg and is accepted. Covered by table cases in TestValidateEntries
and an e2e rejection test alongside the existing -out-pkg one.
|
|
||
| These describe *your* module. let-go's own version is read separately from build | ||
| info where it matters (`-w` resolves it from your `go.mod`'s let-go | ||
| requirement), so passing your version here is correct and won't mispin anything. |
There was a problem hiding this comment.
[P3] Correct the runtime metadata description. This section says the host strings feed System/getProperty, contradicting both the earlier guide text and the new implementation, which deliberately exposes the linked let-go dependency as let-go.version/let-go.commit. The arguments drive host -v output, not those runtime properties.
There was a problem hiding this comment.
Corrected. That section now says the
arguments feed -v only, and states that the runtime resolves
let-go.version/let-go.commit separately from build info, honoring a local
replace. It no longer contradicts the earlier section or the implementation.
Review follow-ups on the interop-package contract. Following dep.Replace but keeping only its version discarded the replacement PATH, so the documented require-v0.0.0 plus directory-replace setup collapsed to "dev". gomod.Generate cannot recover the path from a custom host, whose own module root is not let-go's, so it fell back to @latest: broken under GOPROXY=off and silently a different let-go online. pkg/gomod gains GenerateFrom, which takes an explicit source directory and emits the replace through the existing localFiles path, validating the directory and falling back to ordinary resolution when it is not let-go's module. pkg/cli extracts the path from build info and passes it through. Note Go records Replace.Version as "(devel)" for a directory replace, not "", which the first cut of this got wrong; TestCustomMain now builds -w with GOPROXY=off, which fails without the fix. validateEntries also checks that the normalized alias is spellable as a Go identifier, sharing isGoIdent with validateOutPkg. Before this, -packages hash/crc32=for exited 0 and emitted `for "hash/crc32"` plus `for.Checksum`, which does not parse. Finally, custom-lg.md's build-metadata section still claimed the host strings feed System/getProperty, contradicting both the earlier section and the implementation. It now says they drive -v, and that the runtime resolves let-go.version/commit separately from build info.
mparrett
left a comment
There was a problem hiding this comment.
Re-reviewed the updated head at ef0e155. The prior three findings are addressed and the focused suites pass. Two replacement-semantics gaps remain; both are based on the current head, not workspace drift. Details are inline.
| return "" | ||
| } | ||
| if v := dep.Replace.Version; v != "" && v != "(devel)" { | ||
| return "" |
There was a problem hiding this comment.
[P1] Preserve the module path for versioned replacements. A versioned replacement is reduced to dep.Replace.Version, while this branch discards dep.Replace.Path. GenerateFrom then receives no source directory and Generate constructs github.com/nooga/let-go@v<replacement-version>. For a normal fork replacement such as replace github.com/nooga/let-go => example.com/fork v1.2.3, that fetches the wrong upstream module/version (or fails) rather than reproducing the linked dependency. Please carry the full replacement directive into the generated module.
There was a problem hiding this comment.
You were right, and the fix is broader than the finding: the directive is now carried as a (path, version) pair and reproduced verbatim in the generated module, whether it points at a directory, a fork, or a pin of the stock module at another version. gomod.Replacement and gomod.GenerateWithReplace replace round two's GenerateFrom. For a module replacement the generated go.mod carries the replace line as-is and go get github.com/nooga/let-go on the placeholder require resolves the replacement and writes go.sum for it — no go mod tidy, same read-back path as the pinned and @latest cases.
Covered end to end: TestCustomMain now builds a host against replace github.com/nooga/let-go => example.com/fork v1.2.3, served from a file:// module proxy that holds only the fork (this worktree, zipped), with GOSUMDB=off. -w can only succeed against that proxy by requiring the fork, and let-go.version through that host reports 1.2.3. letgoDepMeta keeps reporting the replacement's version for runtime identity, which for a fork is the right answer.
| p = abs | ||
| } | ||
| } | ||
| return p |
There was a problem hiding this comment.
[P2] Do not resolve relative replacements against runtime cwd. Go build info preserves a replacement such as ../let-go verbatim; filepath.Abs therefore anchors it to wherever the custom binary happens to be run, not the main module directory against which the replace was defined. Against this exact head I built a custom host with replace => ../let-go-pr773-review, ran it from another directory with GOPROXY=off, and -w fell through to @latest and failed. Either establish a recoverable module-root contract for relative paths or reject/document this unsupported case rather than claiming the local replace is honored.
There was a problem hiding this comment.
Agreed. Build info records ../let-go verbatim and carries no module root to anchor it, so nothing a built binary can do recovers that path honestly. I took the "reject and document" branch rather than a heuristic: a relative directory replacement now fails -w with
let-go replace path "../let-go" is relative and cannot be resolved from a built binary; set LETGO_SRC to the checkout or build the host with an absolute replace
and never reaches the proxy. LETGO_SRC is checked before build info is consulted (wasmLetgoSource), so it can rescue exactly that case; round two had the precedence backwards, which is why setting it did not help against your repro. An absolute directory that is not a let-go checkout is an error too, for the same reason — silently building against @latest was the real bug in both shapes.
E2e: a host built with a ../..-style replace, run from an unrelated cwd with GOPROXY=off, fails with that message (and the test asserts the module lookup disabled text is absent, so it did not fall through), then passes with LETGO_SRC pointing at the checkout. Unit tables pin the precedence and each build-info shape. The guide's -w section now states the contract.
A custom host's build info records its replace for let-go as a (path, version) pair. Round two reduced that to one value each way: a fork replace lost its module path and pinned github.com/nooga/let-go at the fork's version, and a relative directory replace was resolved against the runtime working directory, then silently fell through to @latest. gomod gains Replacement and GenerateWithReplace, which reproduce the directive verbatim in the generated module: a directory replace goes through the existing local-source path, and a module replace is written as-is and resolved with `go get` on the require, which follows the replacement and writes go.sum for it. GenerateFrom is gone. pkg/cli reads the pair from build info. A relative directory path is an error naming LETGO_SRC rather than a guess: build info carries no module root to anchor it. LETGO_SRC is checked before build info so the override can rescue exactly that case.
…host's -w Two more custom hosts join TestCustomMain, both run against a proxy that cannot serve github.com/nooga/let-go so any fall-through to @latest fails loudly. One is built with a relative directory replace and run from a foreign directory: -w must reject it, naming LETGO_SRC, and then succeed once LETGO_SRC names the checkout. The other replaces let-go with example.com/fork v1.2.3, served from a file:// module proxy built by zipping this worktree; -w must reproduce that directive to build at all, and let-go.version must report the fork's 1.2.3. The host-building code is shared through buildCustomHost. The proxy uses a comma-free temp dir on purpose: t.TempDir embeds the subtest name, and GOPROXY reads a comma as a list separator.
TestCustomMain self-skips under -short, and the expensive-e2e lane runs a named list that did not include it, so the PR's headline e2e never ran in CI. It joins that lane. The custom-lg guide now states how -w finds let-go: the host's replace directive is reproduced verbatim, a relative directory replace is refused with LETGO_SRC as the way out, and LETGO_SRC wins whenever it is set.
|
@mparrett thanks — both addressed at
|
|
@nnunley appreciate a final review, if you have bandwidth |
nooga
left a comment
There was a problem hiding this comment.
Approving. Every item from nnunley's and mparrett's rounds is in the diff (version/commit split in pkg/cli/cli.go, replace-path carrying and LETGO_SRC precedence in pkg/cli/wasm.go, alias/collision validation in cmd/lginterop). Build/vet/tests/check-generated pass locally.
One nit, non-blocking: the new relative replace is rejected subtest in test/e2e/custom_main_test.go fails on stock macOS because filepath.Rel at ~L132 doesn't see through the /var → /private/var symlink that Go's build tooling resolves — CI is green only because it's ubuntu. An EvalSymlinks on both sides before computing rel fixes it. Happy to merge with or without that.
On macOS t.TempDir() sits under /var/folders, a symlink to /private/var that the go tool resolves, so a lexical filepath.Rel across the boundary produced a replace path that missed the checkout. EvalSymlinks on both sides first. CI never saw it because the lane is ubuntu-only.
|
@nooga Good catch — fixed in |
nnunley
left a comment
There was a problem hiding this comment.
Re-reviewed current head b689c1d. The previous runtime-identity, replacement, generator-validation, accounting/reproducibility, and documentation findings are resolved in the current diff. The later full-replacement and relative-replacement findings are also covered, and the final macOS symlink-path test nit is fixed. Focused unit suites pass independently and all GitHub checks are green. Approving.
Note: the e2e test independently reached TestCustomMain; its fork-replace case could not complete only because the read-only source tarball lacked .git for git ls-files. The same e2e path is covered by green repository CI.
|
@mparrett I re-reviewed current head |
mparrett
left a comment
There was a problem hiding this comment.
Re-reviewed at 2aa872ed. Both replacement-semantics findings are fixed, and fixed at the level they were about rather than at the call site. Clearing the requested-changes state.
P1, the fork replacement. letgoReplacementFrom now carries both halves of the directive, and GenerateWithReplace emits them: replace github.com/nooga/let-go => example.com/fork v1.2.3 reproduces the fork, where before the path was dropped and the version alone produced a require against the stock module at a version that is not its own. TestReplacedGoMod pins the fork case and the same-path pin, and TestLetgoReplacementFrom covers both at the build-info boundary.
P2, the relative directory replace. Taken as the second of the two options I offered: refuse it rather than resolve it. ../let-go now returns an error naming LETGO_SRC, so the case that previously fell through to @latest fails loudly instead. I checked the escape hatch works in the order the comment claims — wasmLetgoSource short-circuits on a non-empty LETGO_SRC before build info is consulted, so a host whose replace is unrecoverable still has a way out. docs/guide/custom-lg.md documents the limitation and the two workarounds.
The docs state the failure mode more usefully than my finding did: "so a fork is built against the fork, never against github.com/nooga/let-go at the fork's version number."
go test ./pkg/gomod/ ./pkg/cli/ passes. The diff since the head I last reviewed is confined to these two fixes plus their tests and docs; the rest is main merging in.
Still needs updating onto current main before it can go in, as nnunley noted.
|
Heads-up on generated-artifact churn, since it affects your open PRs more than this one. I merged a four-PR bytecode stack today — #501, #745, #502, and #624 — and all four touch The source changes are disjoint from yours; the overlap is the generated pair and nothing else. That makes it a regeneration rather than a conflict to work through, including on the branches where GitHub currently reports a conflict on those files. Nothing else of ours is queued against them, so this should be the last of it. |
…ree-lginterop # Conflicts: # lg.go
…ript (#14) ## Why `concepts/lginterop.md` and `concepts/go-structs.md` both cite `scripts/lginterop.lg`, which nooga/let-go#773 (merged 2026-09-06) deleted. The generator is now `cmd/lginterop`, a Go binary that runs the embedded emitter in-process, and the usage line on the page (`./lg scripts/lginterop ...`) no longer works. ## What changed - `concepts/lginterop.md`: usage shows the in-tree invocation that produces `pkg/rt/interop_xxh3.go` and the `-out-pkg` form for other modules; the "How it works" section describes the two stages (`main.go` scans with `go/types`, `lginterop.lg` renders with `gogen`) and why in-tree output registers through `RegisterInstaller` while out-of-tree output installs directly from `init()`; smart wrappers are documented as the `-smart` opt-in they are now. Resource, sources, citations, and `updated` follow. - `concepts/go-structs.md`: resource and citation re-pointed at `cmd/lginterop/lginterop.lg`. No content change; the page stays speculative. - `log.md` entry. ## Verification Claims checked against nooga/let-go `638b4a6a`: the flag table in `cmd/lginterop/main.go`, `smartable?` and the installer emission in `cmd/lginterop/lginterop.lg`, the header line of `pkg/rt/interop_xxh3.go`, and `docs/guide/go-interop.md`. `tools/check_wiki.py` and `build_site.py` pass.
Second batch off [#28](#28). Every number was re-counted against `origin/main` at `36b13f79`, which changed two of them relative to the sweep that scheduled the work. ## `concepts/stack-vm.md` "The ~37 opcodes" is **47**, and not the 48 an enum count suggests: `OP_COUNT` is a sentinel (`// keep last; must equal len(opcodeNames)`), `len(opcodeNames)` is 47, `init()` panics if they disagree, and `lg -v` on 1.13.0 prints `opcodes: 47 (signature 7cf8f862cf5ba1e5)`. Three sources agreeing seemed worth the trouble, since this is the number the `.lgb` capability check compares. The roles list gained the unchecked trio ([#811](nooga/let-go#811)) and a bitwise bullet it had always omitted, which is eight opcodes the page never grouped. The `Frame` listing was seven fields stale, missing `argbuf`, `prepArgs`, `argc`, `constsc`, `debug`, `prevOp`, `profileOn` and, load-bearingly, `parent`. That last one is what [#645](nooga/let-go#645) hangs on, so the page's flat claim that "cross-frame propagation *does* unwind Go frames" is now qualified: a contiguous span of direct bytecode calls runs as child frames inside one dispatch loop, and `releaseFailedFrames` walks the `parent` chain offering the error to each suspended handler. It is still true once the call leaves bytecode through `ec.Invoke`, so the caveat is narrowed rather than dropped. The overflow paragraph gained the `*unchecked-math*` caveat ([#839](nooga/let-go#839)). ## `concepts/op-catalog.md` — the page was right "39 at `0911118`" was **correct**, and is kept as the prior value. Flagging it because the miscount is easy to repeat: a grep for op rows in `pkg/ir/ir_ops.lg` misses the `Invalid` row, which is written on a `'[[` line rather than a `["` line. Counting it gives 39 then and **42** now, the three new ones being #811's. ## `concepts/debug-info.md` and `concepts/lgb-bytecode-format.md` Both said "the `-w`/WASI paths are not stripped". [#800](nooga/let-go#800) made `-strip` compose with `-w`; `cli.go` now only refuses `-strip` when none of `-c`, `-b` or `-w` is given. `lgb-bytecode-format` also carried an illustrative "runtime has 44" pinned to `0911118`, now the real 47 and signature. Its capability section names #811 as the second deliberate opcode-set break after the one that added `OP_DIV`, which is the thing most likely to reach someone as an unexplained rejection after upgrading. ## `sources/let-go-source-code.md` "`/pkg` holds thirteen main packages" is **18**. The five newer ones are named rather than described, with [#773](nooga/let-go#773) called out: moving the CLI into `pkg/cli` is the one that changes an import for anyone building their own `lg`. `check_wiki.py` passes.
Resolves #772
Resolves #774
Two commits, reviewable in order.
1.
feat(lginterop)--out-pkg <name>emits a self-contained interop package that importspkg/rtand installs frominit(), so generated bindings work outside the let-go tree. Ambiguous or invalid names(
-out-pkg rt, Go keywords, non-identifiers) are rejected up front rather than failing later.2.
feat(cli)- moves the CLI out ofpackage mainintopkg/clibehindMain(version, commit) int. A third-party module can now build a binary that is lg - repl, resolver and-bincluded - not just a bytecode host. Flag registration moves frominit()to async.Once, so importing the package no longer mutates the global flag set.version/commitstay at the root because ldflags only reach the declaring package.Together they're what it takes to build a custom
lgaround generated interop: the first produces the package, the second produces something that can host it. The e2e exercises exactly that - generate for a stdlib package, blank-import it besidepkg/cli, run a script through the resulting binary, then bundle with that binary as its own bundle base.Also fixes an emitter bug the e2e surfaced:
ex-struct?tested the export vector's length, but all three:typebranches serialize to 6 elements, so every named type registered as a struct.crc32.Table(type Table [256]uint32) panicked at init with "expected struct, got array". This is latent onmaintoday - the in-tree xxh3 golden misses it because xxh3 uses-opaque-structs. Golden output is byte-identical.New:
docs/guide/custom-lg.md.