Skip to content

Claude/js build api design 7luaw8 - #249

Merged
mizchi merged 2 commits into
mainfrom
claude/js-build-api-design-7luaw8
Sep 16, 2026
Merged

mizchi merged 2 commits into
mainfrom
claude/js-build-api-design-7luaw8

Conversation

@mizchi

@mizchi mizchi commented Sep 16, 2026

Copy link
Copy Markdown
Owner

No description provided.

`moon build --target js` failed in `src/cmd/mtsc/watch.mbt`:
`@async_fs.mtime` does not exist on the JS backend, because
`moonbitlang/async/fs` states in its own `unimplemented.mbt` that the
package "currently does not support JavaScript backend" and restricts
every other source file to `native` / `wasm`.

The CLI is a native executable, so it now declares
`supported_targets = "all-js"` and that build skips it instead of
failing inside it. A per-target `#cfg` arm for the one call is not
enough: the native arm is `async` only by virtue of that call, so a
sync JS arm makes the `async` on `mtsc_watch_stamp` AND
`mtsc_watch_stamps` useless and the workaround spreads a function at a
time up the call chain. Nothing was lost that anything used — in this
repo `--target js` only ever built generated example packages, and no
harness built the CLI for JS, which is why a whole-module JS build was
broken unnoticed. `moon check --target js` still covers all 42
packages, so nothing stopped being checked.

The type checker reaches JavaScript through `src/mtsc` instead, which
is now a library rather than a string ABI. `checkModuleGraph` made the
caller pre-resolve the whole program before it could type-check one
file; resolution moves in and the IO moves out, behind an `MtscHost`
trait. That is TypeScript's split, where `LanguageServiceHost` supplies
file access and the service resolves, and the API is named after
`ts.LanguageService` so an existing integration reads the same:
getProgramDiagnostics / getSemanticDiagnostics /
getSyntacticDiagnostics / getProgramFileNames / resolveModuleName,
returning structured diagnostics rather than a joined string.

A trait rather than a struct of closures because MoonBit requires an
`impl` to supply every method, so a new host cannot inherit a default
that fails open. Synchronous, and therefore NOT shared with
`src/parser`'s resolver, which is `async` throughout — a shared
interface today would fit neither consumer.

`collect_parsed_graph_issues` is extracted verbatim from
`collect_module_graph_issues`, so a host-loaded program is checked by
the same code as a pre-resolved one and cannot drift from it.
`checkModuleGraph` is unchanged and still has its consumers.

`start` / `length` are absent from a diagnostic: `ExprIssue` and
`ParseError` carry no offsets, and `ts.Diagnostic` declares both
optional for exactly this case. The checker's breadcrumb ships as
`context` instead.

Scope kept deliberate: relative specifiers only (bare ones report
`isExternal` rather than a failure), and no completions / quick info /
definitions / rename, all of which need a position.

Verified: `moon build --target js` clean from scratch, `moon fmt
--check`, `moon check --deny-warn` (native gate), `moon check
--deny-warn --target js src/mtsc` (the native check cannot see the
js-only FFI files at all, so this is a new gate), `moon test --target
native` 3066/0, 35 new native cases, 22 Node cases, and the shipped
`.d.ts` under `tsc --strict`.

The Node harness was mutation-proven rather than trusted for passing
first time: dropping the `.js` -> `.ts` remap, swallowing parse errors,
or removing the `getScriptSnapshot` fallback each fail it. One of those
was a real bug in the first draft — the loader looked for parse
failures among the files that had parsed, so no syntax error could ever
be reported. The no-span assertion needed the same treatment: spelled
`@ts-expect-error` over `d.start` it passed whether or not the field
existed, so it is a `keyof` assertion now, which fails when the field
is added.

Two pre-existing `unused_package` warnings remain on `moon check
--target js` for `src/`, whose `moonbitlang/async/fs` import is
native-only. moon has no per-target import syntax, and the alternatives
are suppressing the warning or declaring the root package native-only,
which would be untrue.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xb1GadNQusxErePTkw1zGn
The previous commit fixed `moon build --target js` by declaring
`supported_targets = "all-js"` on the CLI, so the build skipped it. That
fixed the build by trading the capability away, and the trade was
unnecessary: only ONE symbol was missing (`@async_fs.mtime`) and the
rest of the CLI compiled for `js` untouched.

`moonbitlang/async/fs` says in its own `unimplemented.mbt` that it "does
not support JavaScript backend" and restricts every source file but that
one to `native` / `wasm`. The per-target arm is cheap because the native
arm is `async` only by virtue of that one call, so a sync JS arm makes
the `async` on `mtsc_watch_stamps` useless — fixed with one
`#warnings("-unused_async")` at that declaration rather than a
per-target duplicate of a six-line loop. `node:fs/promises` would keep
it async and match `mizchi/x/fs`'s own JS backend, but awaiting a JS
promise needs `moonbitlang/async/js_async`, unused on native, and native
`--deny-warn` is the gate.

The unused-import problem those dependencies create needed no
suppression: `@async_fs.unimplemented` is the one symbol the package
exposes on `js`, declared for exactly this, so naming it from the live
`js` arm in each of the two packages makes the import genuinely
referenced everywhere. `moon check --deny-warn --target js` is now clean
for the WHOLE module and `just check-js` widened from `src/mtsc` to all
of it. No `warnings = "-0029"` anywhere — a package-wide suppression
also hides a dead import, on every target.

Two bugs were found by RUNNING it, neither reachable by building.

`@env.args()` does not have the same shape on every backend:
`moonbitlang/core/env` returns `process.argv` verbatim on `js`, which
has two leading entries where the native runtime's argv has one.
`driver.mbt` reads that shape at six places, so under Node the CLI took
its own 19 MB bundle as an input file and reported
`ParseError("Unexpected token: Gt")` against itself — `mtsc ok.ts
--noEmit` on a clean file exited 1. Normalized once in `mtsc_argv`
rather than patched at six sites.

The second was silent. The mtime probe first read `require("node:fs")`,
and the bundle is a `.js` IIFE under a `package.json` saying `"type":
"module"`, so Node loads it as ESM where `require` is undefined — which
the body's own `catch` turned into "no stamp" for every file. "No stamp"
compares equal to the previous "no stamp", so `--watch` would have
polled forever and never rebuilt, looking exactly like a watcher with
nothing to do. `process.getBuiltinModule` is the sync builtin accessor
that works from either module system, with `require` kept as the
CommonJS fallback.

`just verify-cli-node` is a differential against the native binary —
same source, a different backend and a different runtime for every
syscall — over 19 cases plus a `--watch` round trip on both backends,
comparing stdout, the exit code, and for the emit cases the file
written. Three cases cover the `bridge` / `pkg` verbs, which route
through `mizchi/ts` and its own per-target split
(`publish_staged_bridge_file`), so that arm does not ship unexercised.

Mutation-proven rather than trusted for passing first time: reverting
the argv normalization fails eight cases, reverting the mtime probe
fails the watch round trip with the diagnostic written for it, and
dropping the JS publish write fails the bridge-package case on all four
emitted files. Exactly one case is allowed to diverge and says why — a
missing entry file, where `mizchi/x/fs` relays the OS error text and the
two backends word it differently; it asserts what must hold for both,
still requires the exit codes to match, and FAILS if the outputs ever
become identical so the allowance cannot outlive its reason.

Verified: `moon fmt --check`, `moon check --deny-warn` (native gate),
`moon check --deny-warn --target js` (whole module, clean), `moon test
--target native` 3066/0, 21/21 in the new harness, 22/22 in the
language-service harness, and the native CLI unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xb1GadNQusxErePTkw1zGn
@mizchi
mizchi merged commit a604a30 into main Sep 16, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants