Skip to content

feat(interop): self-contained interop packages and an importable pkg/cli - #773

Merged
nooga merged 19 commits into
nooga:mainfrom
abogoyavlensky:interop/p1-out-of-tree-lginterop
Sep 6, 2026
Merged

nooga merged 19 commits into
nooga:mainfrom
abogoyavlensky:interop/p1-out-of-tree-lginterop

Conversation

@abogoyavlensky

@abogoyavlensky abogoyavlensky commented Aug 23, 2026 •

Copy link
Copy Markdown
Contributor

Resolves #772
Resolves #774

Two commits, reviewable in order.

1. feat(lginterop) - -out-pkg <name> emits a self-contained interop package that imports pkg/rt and installs from init(), 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 of package main into pkg/cli behind Main(version, commit) int. A third-party module can now build a binary that is lg - repl, resolver and -b included - not just a bytecode host. Flag registration moves from init() to a sync.Once, so importing the package no longer mutates the global flag set. version/commit stay at the root because ldflags only reach the declaring package.

Together they're what it takes to build a custom lg around 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 beside pkg/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 :type branches 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 on main today - the in-tree xxh3 golden misses it because xxh3 uses -opaque-structs. Golden output is byte-identical.

New: docs/guide/custom-lg.md.

…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.
@abogoyavlensky abogoyavlensky changed the title feat(lginterop): generate self-contained interop packages for out-of-tree use feat(interop): self-contained interop packages and an importable pkg/cli Aug 23, 2026
@abogoyavlensky

Copy link
Copy Markdown
Contributor Author

The idea behind this PR is what lgx needs to support Go dependencies. For lgx.edn to name a Go library, lgx needs both halves of this PR: bindings that work outside the let-go tree, and a way to build a binary that is a real lg and can host them. Then lgx run and lgx build work on projects backed by Go libraries.

@nnunley nnunley left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at ba4f3be64a694b3d4eec8be96e8e3e68419755f6. Requesting changes for public API and generator contract failures that the current tests do not cover.

Must fix

  1. Custom-host metadata overwrites let-go runtime identity. pkg/cli/cli.go:407-409 assigns the host strings passed to cli.Main to rt.Version/rt.Commit, while pkg/rt/system.go exposes those values as let-go.version and let-go.commit. The new guide explicitly says these strings describe the host. A custom binary calling cli.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.

  2. The documented local-replace custom-host -w path selects nonexistent v0.0.0. pkg/cli/wasm.go:54-62 reads dep.Version but ignores dep.Replace. The guide and e2e build custom hosts with require github.com/nooga/let-go v0.0.0 plus a local replace, so real BuildInfo returns v0.0.0 with a non-nil replacement. gomod.Generate then treats that as a release before considering LETGO_SRC. Reproduced with GOPROXY=off: stock-namespace -w fails on github.com/nooga/let-go@v0.0.0 even with LETGO_SRC set. Model the real Module{Version:"v0.0.0", Replace:...} shape in tests and honor the replacement.

  3. Accepted self-contained generator inputs can produce invalid or overwritten output. The new -out-pkg contract says the result is usable as an imported package, but:

    • aliases vm, or fmt with -smart, collide with generator-owned imports in cmd/lginterop/lginterop.lg:341-348; an out-of-tree vm alias reproduced vm redeclared and undefined target symbols;
    • -out-pkg main passes validateOutPkg, generates successfully, then cannot be blank-imported (is a program, not an importable package);
    • aliases foo-bar and foo_bar pass the raw seenAlias check (cmd/lginterop/main.go:110-123) but both normalize to interop_foo_bar.go; the second silently overwrites the first and the tool reports 2/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.

  4. Generation success and reproducibility claims are false for valid workflows. generatePackage returns nil when a package has zero eligible exports (cmd/lginterop/main.go:343-345), so the new failure accounting reports generated 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, but cmd/lginterop/lginterop.lg:367-383 omits a deps.edn alias, output path, and generator version. A custom alias therefore regenerates under the default namespace/filename; an unversioned lginterop command cannot pin the documented go 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/cli
  • go 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.
@abogoyavlensky

Copy link
Copy Markdown
Contributor Author

@nnunley Thanks for the review. All five items are addressed, and the branch is updated from main.

  1. Runtime identity. cli.Main's ver/com now describe the host binary only and drive -v. rt.Version/rt.Commit resolve independently from let-go's own BuildInfo entry, so a custom host cannot leak into let-go.version. Covered by TestRuntimeMetadataFrom plus a TestCustomMain subtest where the two differ.
  2. Local-replace -w. letgoVersionFrom follows dep.Replace: a directory replace resolves to dev (local-source path), a versioned replace pins to the replacement. Tests use the real Module{Version: "v0.0.0", Replace: ...} shape.
  3. Generator inputs. New validateEntries runs before any write: it rejects aliases colliding with the emitted file's own imports (vm, and fmt under -smart), rejects -out-pkg main, and detects collisions on the normalized filename so foo-bar and foo_bar cannot overwrite each other.
  4. Accounting and header. Zero-export packages are now reported as skipped rather than generated. -packages gained a path=alias form that the header records, so aliased files reproduce from their own command, plus a version line under go run ...@<version>. -out is deliberately omitted, since the file's location supplies it and an absolute path would differ per checkout. The docs state this.
  5. Docs. The shutdown claim now notes that flag.CommandLine is ExitOnError, so -h and malformed flags exit inside Main.

make test and make check-generated pass locally.

@mparrett mparrett left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread pkg/cli/wasm.go
}
v := strings.TrimPrefix(dep.Version, "v")
if v == "" || v == "(devel)" {
return "dev", "none"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread cmd/lginterop/main.go
prev.alias, prev.pkg, alias, ent.pkg, normalized)
}
okCount++
seenFile[normalized] = owner{pkg: ent.pkg, alias: alias}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread docs/guide/custom-lg.md Outdated

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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 mparrett left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread pkg/cli/wasm.go Outdated
return ""
}
if v := dep.Replace.Version; v != "" && v != "(devel)" {
return ""

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread pkg/cli/wasm.go Outdated
p = abs
}
}
return p

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
@abogoyavlensky

abogoyavlensky commented Aug 30, 2026 •

Copy link
Copy Markdown
Contributor Author

@mparrett thanks — both addressed at ed81d2b, branch merged with main.

  • P1: the whole replace directive (path and version) is reproduced in the generated module; a fork replacement builds against the fork. E2e uses an offline file:// proxy that serves only example.com/fork v1.2.3.
  • P2: relative directory replacements are rejected with an error naming LETGO_SRC, and LETGO_SRC now wins before build info is consulted. E2e runs the host from a foreign cwd with GOPROXY=off.
  • While adding those I noticed TestCustomMain was never in the CI expensive-e2e lane (it is -short-gated and the lane runs a named list), so "checks green" did not cover this PR's headline e2e. It is in the lane now.

make generate, make test, check-generated, make lint, and the full TestCustomMain pass locally.

@mparrett

Copy link
Copy Markdown
Collaborator

@nnunley appreciate a final review, if you have bandwidth

@nooga nooga left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
@abogoyavlensky

Copy link
Copy Markdown
Contributor Author

@nooga Good catch — fixed in b689c1d: EvalSymlinks on both sides before the Rel, with a comment naming the /var → /private/var boundary. Verified the subtest still passes on linux; test-only change. Branch is already up to date with main.

@nnunley nnunley left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@nnunley

nnunley commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

@mparrett I re-reviewed current head b689c1d and confirmed the replacement-semantics findings from your two requested-change rounds are addressed; focused unit suites and repository CI are green. I approved above. Could you re-review/dismiss the stale requested-changes state so this can merge after updating onto current main?

@nnunley nnunley added the review-priority/high Review/merge first: ready or unblocks the stack label Sep 2, 2026

@mparrett mparrett left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@mparrett

mparrett commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

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 pkg/rt/generated.sums and pkg/rt/generated.manifest. You rebased your open PRs a couple of hours before the first of those went in, so they are now behind main and will each want one more make generate.

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.

@nooga
nooga merged commit f19e9f8 into nooga:main Sep 6, 2026
21 checks passed
mparrett added a commit to nnunley/let-go-wiki that referenced this pull request Sep 8, 2026
…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.
mparrett added a commit to nnunley/let-go-wiki that referenced this pull request Sep 20, 2026
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

review-priority/high Review/merge first: ready or unblocks the stack

Projects

None yet

Development

Successfully merging this pull request may close these issues.

The lg CLI is not importable, so custom binaries can't be full-featured lg lginterop cannot generate bindings for use outside the let-go tree

4 participants