diff --git a/.github/workflows/msvc-verify.yml b/.github/workflows/msvc-verify.yml new file mode 100644 index 000000000..151f620a6 --- /dev/null +++ b/.github/workflows/msvc-verify.yml @@ -0,0 +1,65 @@ +name: MSVC POSIX Shims + +on: + push: + branches: [main] + pull_request: + +jobs: + msvc_compile: + name: verify MSVC compilation + runs-on: windows-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 11 + - uses: actions/setup-node@v4 + with: + node-version-file: .node-version + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm build + - name: Compile runtime with MSVC clang + shell: pwsh + run: | + # Verify clang is MSVC-targeted (not mingw) + clang --version + # Compile a simple program — this is the exact scenario from #25 + node packages/cli/dist/main.js run tests/corpus/001-hello.ts --backend c + - name: Compile Map + sort program (the bare.ts pattern) + shell: pwsh + run: | + $ts = @' + const m = new Map(); + m.set(1, 10); + m.set(2, 5); + m.set(3, 20); + const sorted = [...m.entries()].sort((a, b) => a[1] - b[1]); + console.log(sorted[0][0], sorted[0][1]); + '@ + $ts | Out-File -Encoding utf8 test-msvc.ts + node packages/cli/dist/main.js run test-msvc.ts --backend c + + linux_regression: + name: verify no Linux regressions + runs-on: ubuntu-24.04 + timeout-minutes: 10 + env: + SCRIPTC_NO_CACHE: "1" + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 11 + - uses: actions/setup-node@v4 + with: + node-version-file: .node-version + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm build + - name: Corpus smoke tests + run: >- + SCRIPTC_TEST_WORKERS=4 pnpm exec vitest run tests/harness/differential.test.ts + -t "001-hello|518-array-sort|520-map-basics|path" diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e1430f97..9de5fa88b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to scriptc will be documented in this file. ## Unreleased +### Features + +- **Native MIDI messaging.** `node:midi` (API-compatible with node-midi/@julusian/midi) enumerates ports, opens inputs and outputs including virtual ports, sends raw messages, and receives time-stamped messages through the `"message"` event on the dependency-free event loop. Backends bind each platform's MIDI stack — ALSA on Linux, CoreMIDI on macOS, WinMM on Windows — and are linked only into binaries that use the surface. An open input holds the loop alive like a bound `dgram` socket; the runtime is byte-transparent and does not parse MIDI semantics. `openVirtualPort` is POSIX-only (WinMM has no user-space virtual ports), and any MIDI surface on `wasm32-wasi` refuses before linking with `SC3002`. + ## 0.0.35 diff --git a/docs/src/app/how-it-works/page.mdx b/docs/src/app/how-it-works/page.mdx index 0ded6b60c..deea595c9 100644 --- a/docs/src/app/how-it-works/page.mdx +++ b/docs/src/app/how-it-works/page.mdx @@ -31,7 +31,7 @@ fib.ir.json - **Memory** — values are reference-counted; an acyclic value is freed the moment its last reference drops. Reference cycles are collected at deterministic points by a cycle collector, not a concurrent GC. There are no GC pauses and no tracing heap. - **Concurrency** — `async`/`await` runs on stackful fibers with JS-exact scheduling: microtasks drain in the same order Node's do, timers fire in the same order, and the event loop (kqueue on macOS, epoll on Linux) has no external dependencies. -- **The server stack** — `net`, `http`, `https`, `tls` (vendored mbedTLS), `dgram`, `dns` are native implementations on that same loop. +- **The server stack** — `net`, `http`, `https`, `tls` (vendored mbedTLS), `dgram`, `dns` are native implementations on that same loop, as is `midi` (ALSA/CoreMIDI/WinMM, linked only into binaries that use it). - **Numbers** — JS-exact f64 semantics, including shortest-roundtrip number-to-string formatting fuzz-verified against Node's output. - **Regular expressions** — the same ECMAScript-exact bytecode interpreter QuickJS uses, linked only into regex-using binaries. diff --git a/docs/src/app/introduction/page.mdx b/docs/src/app/introduction/page.mdx index d473423d2..4e32553a9 100644 --- a/docs/src/app/introduction/page.mdx +++ b/docs/src/app/introduction/page.mdx @@ -51,7 +51,7 @@ The static surface covers the language and the standard library real programs us - **The language** — classes with single inheritance and dynamic dispatch, closures with JS capture semantics, generic function declarations (monomorphized), discriminated unions driven by TypeScript's own narrowing, `async`/`await` with JS-exact scheduling, exceptions with `finally`, destructuring, spread, optional/default/rest parameters, getters and setters, iterators, template literals, bitwise operators with JS-exact ToInt32 semantics, and the static slice of regular expressions. - **The standard library** — strings with UTF-16-exact surface semantics, arrays, `Map` and `Set` with JS-exact ordering, read-only `Date` values and calendar getters, `JSON` with runtime-validated casts, `Math`, typed arrays and `Buffer`, `Error` hierarchies with typed `catch`. -- **Node's API surface** — `fs` (sync and promises), `path`, `process`, `child_process`, `os`, `crypto`, `url`/`URL`, `zlib`, timers and signal handlers on a dependency-free event loop, and the server stack: `net`, `http`, `https`, `tls`, `dgram`, `dns`, `readline`. Real servers compile: +- **Node's API surface** — `fs` (sync and promises), `path`, `process`, `child_process`, `os`, `crypto`, `url`/`URL`, `zlib`, timers and signal handlers on a dependency-free event loop, the server stack: `net`, `http`, `https`, `tls`, `dgram`, `dns`, `readline`, and native `midi` (raw MIDI messaging over the same loop). Real servers compile: ```ts:server.ts import { createServer } from "node:http"; diff --git a/docs/src/app/limitations/page.mdx b/docs/src/app/limitations/page.mdx index 8307f8b3d..61000b047 100644 --- a/docs/src/app/limitations/page.mdx +++ b/docs/src/app/limitations/page.mdx @@ -89,6 +89,16 @@ const who = process.argv.length > 2 ? process.argv[2] : "world"; The production wasm32-wasi target supports the complete executable language tier through LLVM: async/await, promises, generators, timers and other portable event-loop work, stdin/readline, filesystem callbacks and promises, and the --dynamic island. Portable WASI Preview 1 has no socket, process-spawn, OS-signal, network-interface, or filesystem-notification capabilities, so networking/fetch, child processes, signal APIs, os.networkInterfaces(), and fs.watch are rejected before linking with SC3002. --sanitize, native FFI, and library-mode archive builds are also unavailable. Filesystem access is bounded by the host's preopens; scriptc run exposes the current working directory and /tmp. See [Platform Support](/platforms) for build and run details. +## MIDI limits + +`node:midi` is raw MIDI messaging, modeled on node-midi/@julusian/midi — enumerate ports, open input/output (including virtual ports), send raw messages, and receive time-stamped messages via the `"message"` event. + +- **The runtime is byte-transparent.** It carries raw message bytes (Note On/Off, CC, Program Change, Pitch Bend, SysEx as a byte run) and neither parses nor validates MIDI semantics. Higher-level semantic events (`noteon`, `cc`, …), MIDI file parsing, sequencing/clock scheduling, and MIDI 2.0 / UMP are out of scope. +- **Virtual ports are POSIX-only.** `openVirtualPort` works on Linux (ALSA) and macOS (CoreMIDI); on Windows WinMM it fails at runtime with a clear error, because WinMM has no user-space virtual ports. See [Platform Support](/platforms). +- **No MIDI on WASI.** WASI Preview 1 has no MIDI capability, so any `node:midi` surface is rejected before linking with `SC3002`. Browser Web MIDI is a separate runtime the WASI target does not cover. +- **`on`/`once` accept only the `"message"` event**, with a `(deltaTime, message)` listener. `deltaTime` is seconds since the previous message on that input (`0` for the first) and is inherently nondeterministic — a differential test must never print it. +- **A Linux host without ALSA** (many CI containers) has no MIDI backend: the runtime enumerates zero ports and throws on open. The hardware-free loopback tests use a virtual-port pair on a capable host. + ## Tooling gaps - `scriptc run` does not forward extra CLI arguments to the program — `build` and invoke the binary directly. diff --git a/docs/src/app/platforms/page.mdx b/docs/src/app/platforms/page.mdx index 6ad379af7..98fb028d6 100644 --- a/docs/src/app/platforms/page.mdx +++ b/docs/src/app/platforms/page.mdx @@ -63,6 +63,44 @@ WASI is a production LLVM target with the same language tiers as the native targ The remaining executable boundary is host capability, not language coverage. WASI Preview 1 has no portable socket, process-spawn, OS-signal, network-interface, or filesystem-notification APIs. Networking/fetch, child processes, signal APIs, os.networkInterfaces(), and fs.watch therefore fail before linking with diagnostic SC3002. --sanitize, native FFI, and library-mode archive builds are unavailable too. Filesystem behavior is bounded by the host's preopens, and process/OS introspection follows WASI's reduced model. +## MIDI (`node:midi`) + +Raw MIDI messaging (`node:midi`, API-compatible with node-midi/@julusian/midi) is a native runtime unit linked only into binaries that use it. Each platform binds its own MIDI stack, so the availability is per target: + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PlatformBackendVirtual ports
LinuxALSA sequencer (libasound, linked as -lasound)Yes — a native ALSA port other clients connect to
macOSCoreMIDI (-framework CoreMIDI)Yes — MIDISourceCreate/MIDIDestinationCreate
WindowsWinMM (winmm.lib)No — WinMM has no user-space virtual ports; openVirtualPort fails at runtime with a clear error
WASINoneNo — any midi surface fences before linking with SC3002
+ +An open Input is a live pollable source that holds the event loop alive (like a bound `dgram` socket); an Output is fire-and-forget. Port enumeration (`getPortCount`/`getPortName`) works on a fresh handle before `openPort`. The runtime is byte-transparent — it neither parses nor validates MIDI message semantics. Note that a Linux host without an ALSA sound stack (many CI containers) enumerates zero ports and throws on open. + ## Cross-target limits - `--sanitize` is a host-build lane. diff --git a/package.json b/package.json index fdf35d415..9e773cd0e 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "devDependencies": { "@types/node": "^24.0.0", "eslint": "^9.20.0", + "midi": "npm:@julusian/midi@^3.8.1", "tsx": "^4.19.0", "typescript": "5.9.3", "typescript-eslint": "^8.24.0", diff --git a/packages/compiler/ambient/scriptc-node-fallback.d.ts b/packages/compiler/ambient/scriptc-node-fallback.d.ts index 4ef5a210f..f0ac799ca 100644 --- a/packages/compiler/ambient/scriptc-node-fallback.d.ts +++ b/packages/compiler/ambient/scriptc-node-fallback.d.ts @@ -3032,6 +3032,47 @@ declare module "node:dns" { export * from "dns"; } +/* node:midi — raw MIDI messaging over the event loop (scr_midi.c, linked + * only into using binaries — the moduleUsesMidi switch). API-compatible + * with node-midi/@julusian/midi so the Node differential baseline is a + * real, installable package. Input is a live pollable source (an open + * port holds the loop alive, like a bound dgram socket); Output is + * fire-and-forget (send never holds the loop). Port enumeration + * (getPortCount/getPortName) works on a fresh handle before openPort — + * enumerate then open, like node-midi. openVirtualPort is POSIX-only and + * fences at runtime on Windows (WinMM has no user-space virtual ports). + * on/once accept ONLY the "message" event with a (deltaTime, message) + * handler; message bytes arrive as a number[] with deltaTime (seconds + * since the previous message, 0 for the first) as the leading argument — + * the node-midi callback shape. sendMessage takes an array literal or a + * Uint8Array; the runtime is byte-transparent (it neither parses nor + * validates MIDI semantics). */ +declare module "midi" { + export class Input { + getPortCount(): number; + getPortName(port: number): string; + openPort(port: number): void; + openVirtualPort(name: string): void; + closePort(): void; + isPortOpen(): boolean; + ignoreTypes(sysex: boolean, timing: boolean, activeSensing: boolean): void; + on(event: "message", listener: (deltaTime: number, message: number[]) => void): void; + once(event: "message", listener: (deltaTime: number, message: number[]) => void): void; + } + export class Output { + getPortCount(): number; + getPortName(port: number): string; + openPort(port: number): void; + openVirtualPort(name: string): void; + closePort(): void; + isPortOpen(): boolean; + sendMessage(message: number[] | Uint8Array): void; + } +} +declare module "node:midi" { + export * from "midi"; +} + /* node:worker_threads — the MAIN-THREAD slice only. A compiled binary is * always the main thread (no JS-engine thread machinery exists), so * isMainThread lowers to `true` and threadId to 0 — Node's main-thread @@ -3133,7 +3174,9 @@ declare module "node:async_hooks" { * checks annotated listener parameters against it (unannotated non-empty * parameter lists have no static types and fence at the registration). */ declare module "events" { - class EventEmitter { + // Node 24 makes EventEmitter generic; the native surface remains + // intentionally event-name agnostic, but accepts that type argument. + class EventEmitter { constructor(); static defaultMaxListeners: number; on(eventName: string, listener: (...args: any[]) => void): this; diff --git a/packages/compiler/src/backend/cc.ts b/packages/compiler/src/backend/cc.ts index cc317dec1..bd75853b2 100644 --- a/packages/compiler/src/backend/cc.ts +++ b/packages/compiler/src/backend/cc.ts @@ -380,6 +380,12 @@ export interface CcOptions { * on the IR): compiles scr_dgram.c into the binary — the net gating * precedent, so dgram-free binaries keep their exact link line. */ dgram?: boolean; + /** The program uses the node:midi surface (moduleUsesMidi on the IR): + * compiles scr_midi.c into the binary and links the platform MIDI stack + * (ALSA seq on Linux where libasound is present, CoreMIDI on macOS, WinMM + * on Windows) — the dgram gating precedent, so midi-free binaries keep + * their exact link line. */ + midi?: boolean; /** The program uses fs.watch (moduleUsesFsWatch on the IR): compiles * scr_watch.c into the binary — the net gating precedent, so watch-free * binaries keep their exact link line. */ @@ -716,12 +722,108 @@ function androidNdkSysroot(env: NodeJS.ProcessEnv): string { ); } +/** MinGW-w64 install roots to probe, in order, when SCRIPTC_MINGW_ROOT is + * unset — mirrors the Android NDK auto-discovery above (explicit env var + * first, then the common install locations for the platform). MSYS2's own + * default (C:\msys64\mingw64) covers both its official installer and every + * package manager that wraps it (winget, choco, scoop); a bare mingw-w64 + * standalone install commonly lands at C:\mingw64. */ +function mingwRootCandidates(env: NodeJS.ProcessEnv): string[] { + const explicit = env["SCRIPTC_MINGW_ROOT"]; + if (explicit !== undefined && explicit !== "") return [explicit]; + return ["C:\\msys64\\mingw64", "C:\\mingw64", "C:\\msys2\\mingw64"]; +} + +function findMingwRoot(env: NodeJS.ProcessEnv): string { + for (const root of mingwRootCandidates(env)) { + if (existsSync(join(root, "include", "dirent.h"))) return root; + } + throw new Error( + "no MinGW-w64 install was found — clang's own default target on Windows is the MSVC ABI, " + + "whose C runtime has none of the POSIX headers (dirent.h, unistd.h) or types (ssize_t) " + + "this project's runtime C sources need. Install MSYS2 (winget install MSYS2.MSYS2) and its " + + "mingw-w64-x86_64-gcc package (pacman -S mingw-w64-x86_64-gcc), and/or set " + + "SCRIPTC_MINGW_ROOT to the mingw64 directory (its default install path is C:\\msys64\\mingw64).", + ); +} + +/** clang targeting MinGW still links against a few of GCC's OWN runtime + * support libraries (libgcc.a, libgcc_eh.a — software float/int helpers and + * the DWARF-2 unwinder MinGW's exception model uses), which live under a + * GCC-VERSION-specific subdirectory clang has no reason to already know + * (it is not the compiler that put them there) — glob for it rather than + * pinning a version this project doesn't control the upgrade schedule of. */ +function findMingwGccLibDir(mingwRoot: string): string | null { + const base = join(mingwRoot, "lib", "gcc", "x86_64-w64-mingw32"); + let versions: string[]; + try { + versions = readdirSync(base); + } catch { + return null; + } + versions.sort().reverse(); + for (const version of versions) { + const dir = join(base, version); + if (existsSync(join(dir, "libgcc.a"))) return dir; + } + return null; +} + /** Resolve native platform flags independently of the machine running tests, - * so the host-Linux contract remains pinned on every development host. */ -function nativePlatformArgs(platform: NodeJS.Platform): Pick { - return platform === "linux" - ? { targetArgs: ["-D_GNU_SOURCE"], linkArgs: ["-lm"] } - : { targetArgs: [], linkArgs: [] }; + * so the host-Linux contract remains pinned on every development host. + * `viaZig` distinguishes the two host-native drivers `resolveCc` can select + * (bare clang vs `zig cc`): on win32 they need OPPOSITE handling, unlike + * every other platform/driver combination here. Bare clang's own default + * target on Windows is the MSVC ABI, with none of the POSIX surface + * (dirent.h, unistd.h, ssize_t) this project's C sources need — it must be + * pointed at an external MinGW-w64 install via --target=x86_64-w64-mingw32. + * `zig cc`, by contrast, ships its OWN bundled mingw-w64 sysroot for its + * `x86_64-windows-gnu` target (see the module doc comment) and needs no + * external MinGW at all — worse, `--target=x86_64-w64-mingw32` is clang's + * LLVM triple spelling, not one of zig's own target-query spellings, and + * zig's `-target` parser hard-errors on it ("unable to parse target query + * 'x86_64-w64-mingw32': UnknownOperatingSystem"), confirmed against a real + * zig 0.16 install. Feeding it to zig doesn't just make findMingwRoot's + * external-install requirement pointless for zig users — it breaks the + * build outright. */ +function nativePlatformArgs( + platform: NodeJS.Platform, + env: NodeJS.ProcessEnv = process.env, + viaZig = false, +): Pick { + if (platform === "linux") return { targetArgs: ["-D_GNU_SOURCE"], linkArgs: ["-lm"] }; + if (platform === "win32" && !viaZig) { + // clang's own default target here is the MSVC ABI (this project never + // targeted Windows until now — its docs only ever named macOS/Linux + // toolchains). MinGW-w64's headers/libs are the POSIX-compatible + // surface clang already knows how to target via --target=x86_64-w64- + // mingw32 — the exact same shape as Linux's -D_GNU_SOURCE branch above: + // a target-specific flag set on the SAME compiler, not a different one. + const mingwRoot = findMingwRoot(env); + const gccLibDir = findMingwGccLibDir(mingwRoot); + return { + targetArgs: [ + "--target=x86_64-w64-mingw32", + `-isystem${mingwRoot}\\include`, + ], + linkArgs: [ + `-L${mingwRoot}\\lib`, + `-B${mingwRoot}\\bin`, + // libgcc.a/libgcc_eh.a (software helpers + the unwinder MinGW's + // exception model uses) — see findMingwGccLibDir. Absent only for a + // MinGW install with no GCC at all (clang-only toolchains exist), + // which nothing in this codebase's runtime C currently needs. + ...(gccLibDir !== null ? [`-L${gccLibDir}`] : []), + // winpthreads: MinGW's (pulled in by ) + // aliases clock_gettime/nanosleep to ITS OWN 64-bit-safe + // clock_gettime64/nanosleep64 (MinGW's own convention — unrelated + // to glibc's _TIME_BITS=64 Y2038 story, but the same idea); those + // symbols' actual definitions live in winpthreads, not the CRT. + "-lwinpthread", + ], + }; + } + return { targetArgs: [], linkArgs: [] }; } export function resolveCc( @@ -730,19 +832,32 @@ export function resolveCc( ): CcDriver { const cc = env["SCRIPTC_CC"] ?? ""; const target = env["SCRIPTC_TARGET"] ?? ""; - const hostArgs = nativePlatformArgs(hostPlatform); + // Computed lazily, only at the two return sites that actually use it + // (below): every cross-compile target (iOS, Android, wasm, ...) builds + // its own explicit targetArgs/linkArgs and never touches this. On + // win32, nativePlatformArgs THROWS when no MinGW-w64 install is found — + // eagerly computing this here would make every zig-cc cross-compile + // target refuse to resolve on a Windows host with no local MinGW, + // even though none of them need it. if (cc === "" || cc === "clang") { if (target !== "") { throw new Error( `SCRIPTC_TARGET=${target} requires SCRIPTC_CC=zigcc — the default clang path has no cross-target sysroots.`, ); } - return { argv: ["clang"], target: null, zigTarget: null, ...hostArgs }; + return { argv: ["clang"], target: null, zigTarget: null, ...nativePlatformArgs(hostPlatform, env) }; } if (cc !== "zigcc") { throw new Error(`unknown SCRIPTC_CC '${cc}' (supported: clang, zigcc)`); } - if (target === "") return { argv: ["zig", "cc"], target: null, zigTarget: null, ...hostArgs }; + if (target === "") { + return { + argv: ["zig", "cc"], + target: null, + zigTarget: null, + ...nativePlatformArgs(hostPlatform, env, /* viaZig */ true), + }; + } if (target.includes("wasi") && target !== "wasm32-wasi") { throw new Error(`unsupported WASI target '${target}' (supported: wasm32-wasi)`); } @@ -5102,7 +5217,7 @@ async function compileCInternal( // platform, so all three link whenever a poller-using unit does and // the others cost nothing (ws2_32 rides the unconditional win32 libs // above). - ...(net || opts.dgram + ...(net || opts.dgram || opts.midi ? [ rt(join(rtDir, "scr_loop_kqueue.c")), rt(join(rtDir, "scr_loop_epoll.c")), @@ -5113,6 +5228,26 @@ async function compileCInternal( ...(http ? [rt(join(rtDir, "scr_http.c"))] : []), ...(opts.http2 ?? false ? [rt(join(rtDir, "scr_http2.c"))] : []), ...(opts.dgram ? [rt(join(rtDir, "scr_dgram.c"))] : []), + // node:midi (scr_midi.c) + the platform MIDI stack. The runtime's ALSA + // backend is guarded by __has_include(): on a Linux + // host with libasound-dev it compiles the ALSA seq path and needs + // -lasound; without the header it compiles a stub that references no + // snd_* symbols, so -lasound must be withheld or the link fails. The + // host header probe below matches that compile-time guard (the default + // host-target path; a cross-compile to Linux keys off the target sysroot + // header at compile time and may need the flag threaded explicitly). + ...(opts.midi + ? [ + rt(join(rtDir, "scr_midi.c")), + ...(targetPlatform(driver) === "darwin" + ? ["-framework", "CoreMIDI", "-framework", "CoreFoundation"] + : targetPlatform(driver) === "win32" + ? ["-lwinmm"] + : targetPlatform(driver) === "linux" && existsSync("/usr/include/alsa/asoundlib.h") + ? ["-lasound"] + : []), + ] + : []), ...(opts.watch ? [rt(join(rtDir, "scr_watch.c"))] : []), ...(opts.foreignFfi ? [rt(join(rtDir, "scr_ffi_queue.c"))] : []), ...(opts.nodeTest ? [rt(join(rtDir, "scr_test.c"))] : []), diff --git a/packages/compiler/src/backend/emission/emit-exprs.ts b/packages/compiler/src/backend/emission/emit-exprs.ts index 7b54da6ff..bde543fbd 100644 --- a/packages/compiler/src/backend/emission/emit-exprs.ts +++ b/packages/compiler/src/backend/emission/emit-exprs.ts @@ -3522,6 +3522,30 @@ export function emitExpr(E: CEmitter, e: IrExpr): Temp { // halves and naive floor(x+0.5) drifts at the epsilon boundary). case "math.abs": return finish(`fabs(${arg(0)})`); + // Math.sqrt is IEEE-754 correctly-rounded in both libm and the JS + // spec, so it is bit-exact. Math.sin/cos/exp/log/pow are NOT + // required to be correctly rounded by either spec — libm and + // V8's fdlibm-derived Math agree to double precision but may + // differ by a ULP or two on transcendental inputs. Domain + // errors (sqrt of a negative, log of zero/negative, 0**negative) + // fall out of IEEE-754 the same way in C and JS: NaN or ±Infinity, + // never a throw. + case "math.sin": + return finish(`sin(${arg(0)})`); + case "math.cos": + return finish(`cos(${arg(0)})`); + case "math.sqrt": + return finish(`sqrt(${arg(0)})`); + case "math.exp": + return finish(`exp(${arg(0)})`); + case "math.log": + return finish(`log(${arg(0)})`); + case "math.pow": + return finish(`pow(${arg(0)}, ${arg(1)})`); + // Math.fround — narrow to float32 and widen back to double, + // matching the JS single-precision rounding. No throw. + case "math.fround": + return finish(`(double)(float)(${arg(0)})`); case "math.round": return finish(`scr_math_round(${arg(0)})`); // The scalar Math.min/max (scr_lib.c — fmin/fmax drop NaN, so @@ -4370,6 +4394,54 @@ export function emitExpr(E: CEmitter, e: IrExpr): Temp { E.line(`scr_dns_lookup(${arg(0)}, ${arg(1)}, ${cb.name}, &${adapter});${E.srcComment(e.loc)}`); return { name: "", type: e.type }; } + // node:midi (scr_midi.c + the loop's midi hook — linked only when + // these appear on the IR; moduleUsesMidi is the switch). Handles + // and byte payloads are BORROWED; the onMessage CALLBACK MOVES into + // the input's registry. An open input port holds the loop live + // (usesTimers) — a source of pending messages, like a bound socket. + case "midi.newInput": + return finish(`scr_midi_input_new()`); + case "midi.newOutput": + return finish(`scr_midi_output_new()`); + case "midi.portCount": + return finish(`scr_midi_port_count(${arg(0)}, ${arg(1)})`); + case "midi.portName": + return finish(`scr_midi_port_name(${arg(0)}, ${arg(1)})`); + case "midi.openPort": + // Opening an INPUT makes the loop live; an OUTPUT does not. + if (e.args[0]!.type.kind === "midiInput") E.usesTimers = true; + return finish(`scr_midi_open_port(${arg(0)}, ${arg(1)})`); + case "midi.openVirtual": + if (e.args[0]!.type.kind === "midiInput") E.usesTimers = true; + return finish(`scr_midi_open_virtual(${arg(0)}, ${arg(1)})`); + case "midi.closePort": + E.line(`scr_midi_close_port(${arg(0)});${E.srcComment(e.loc)}`); + return { name: "", type: e.type }; + case "midi.isOpen": + return finish(`scr_midi_is_open(${arg(0)})`); + case "midi.ignoreTypes": + E.line(`scr_midi_ignore_types(${arg(0)}, ${arg(1)}, ${arg(2)}, ${arg(3)});${E.srcComment(e.loc)}`); + return { name: "", type: e.type }; + case "midi.sendArray": + return finish(`scr_midi_send_array(${arg(0)}, ${arg(1)})`); + case "midi.sendBytes": + return finish(`scr_midi_send_bytes(${arg(0)}, ${arg(1)})`); + case "midi.onMessage": { + // The message listener receives (deltaTime: f64, message: + // number[]); the runtime invokes the moved-in closure through + // the per-arity adapter picked by the declared param count. + E.usesTimers = true; // a listening input holds the loop open + const cbT = e.args[1]!.type; + if (cbT.kind !== "func") throw new Error("emitter bug: midi.onMessage callback not a func"); + const cb = args[1]!; + E.moveTemp(cb); + const adapter = + cbT.params.length === 0 ? "scr_midi_msg_thunk0" + : cbT.params.length === 1 ? "scr_midi_msg_thunk1" + : "scr_midi_msg_thunk2"; + E.line(`scr_midi_on_message(${arg(0)}, ${cb.name}, &${adapter}, ${arg(2)});${E.srcComment(e.loc)}`); + return { name: "", type: e.type }; + } // node:test (scr_test.c — linked only when these appear on the // IR; moduleUsesNodeTest is the switch). Strings borrowed, // callbacks MOVE. Registrations keep the loop-run emitted diff --git a/packages/compiler/src/backend/emission/emit-types.ts b/packages/compiler/src/backend/emission/emit-types.ts index 59eba2b91..04b3a90e3 100644 --- a/packages/compiler/src/backend/emission/emit-types.ts +++ b/packages/compiler/src/backend/emission/emit-types.ts @@ -60,6 +60,10 @@ export function cType(t: IrType): string { return "ScrH2Stream *"; case "dgramSocket": return "ScrDgramSocket *"; + case "midiInput": + return "ScrMidiInput *"; + case "midiOutput": + return "ScrMidiOutput *"; case "testCtx": return "ScrTestCtx *"; case "httpReq": @@ -164,6 +168,10 @@ export function retainCallC(type: IrType, expr: string): string { return `scr_http2_stream_retain(${expr})`; case "dgramSocket": return `scr_dgram_retain(${expr})`; + case "midiInput": + return `scr_midi_input_retain(${expr})`; + case "midiOutput": + return `scr_midi_output_retain(${expr})`; case "testCtx": return `scr_testctx_retain(${expr})`; case "httpReq": @@ -245,6 +253,10 @@ export function releaseCallC(type: IrType, expr: string): string { return `scr_http2_stream_release(${expr})`; case "dgramSocket": return `scr_dgram_release(${expr})`; + case "midiInput": + return `scr_midi_input_release(${expr})`; + case "midiOutput": + return `scr_midi_output_release(${expr})`; case "testCtx": return `scr_testctx_release(${expr})`; case "httpReq": @@ -320,6 +332,8 @@ export function boxKindC(t: IrType): string { case "http2Session": case "http2Stream": case "dgramSocket": + case "midiInput": + case "midiOutput": case "testCtx": case "httpReq": case "httpRes": @@ -403,6 +417,10 @@ export function vAdapters(t: IrType): { retain: string; release: string } { return { retain: "scr_http2_stream_retain_v", release: "scr_http2_stream_release_v" }; case "dgramSocket": return { retain: "scr_dgram_retain_v", release: "scr_dgram_release_v" }; + case "midiInput": + return { retain: "scr_midi_input_retain_v", release: "scr_midi_input_release_v" }; + case "midiOutput": + return { retain: "scr_midi_output_retain_v", release: "scr_midi_output_release_v" }; case "testCtx": return { retain: "scr_testctx_retain_v", release: "scr_testctx_release_v" }; case "httpReq": @@ -526,6 +544,8 @@ export function elemKindC(elem: IrType): string { case "http2Session": case "http2Stream": case "dgramSocket": + case "midiInput": + case "midiOutput": case "testCtx": case "httpReq": case "httpRes": diff --git a/packages/compiler/src/backend/emission/emitter.ts b/packages/compiler/src/backend/emission/emitter.ts index 9cd87659b..e8eb3cc46 100644 --- a/packages/compiler/src/backend/emission/emitter.ts +++ b/packages/compiler/src/backend/emission/emitter.ts @@ -39,7 +39,7 @@ import type { IrUnionDef, SrcLoc, } from "../../ir/nodes.js"; -import { ffiCallbackType, funcOf, isFfiCallbackParam, isFfiContextParam, isFfiReleaseParam, isRefCounted, isUnitType, mapOf, moduleEmbedsCompressedNpm, moduleUsesDgram, moduleUsesDynInvoke, moduleEmbedsBuiltin, moduleUsesFetch, moduleUsesFsWatch, moduleUsesHttp2, moduleUsesHttpServer, moduleUsesNet, moduleUsesNodeTest, moduleUsesProcessEvents, moduleUsesStream, moduleUsesTls, moduleUsesTlsCa, RUNTIME_EMITTER_CLASS, STRING, VOID } from "../../ir/nodes.js"; +import { ffiCallbackType, funcOf, isFfiCallbackParam, isFfiContextParam, isFfiReleaseParam, isRefCounted, isUnitType, mapOf, moduleEmbedsCompressedNpm, moduleUsesDgram, moduleUsesDynInvoke, moduleEmbedsBuiltin, moduleUsesFetch, moduleUsesFsWatch, moduleUsesHttp2, moduleUsesHttpServer, moduleUsesMidi, moduleUsesNet, moduleUsesNodeTest, moduleUsesProcessEvents, moduleUsesStream, moduleUsesTls, moduleUsesTlsCa, RUNTIME_EMITTER_CLASS, STRING, VOID } from "../../ir/nodes.js"; import { allocateFfiCallbackAdapters, hasForeignFfiCallback, hasRetainedFfiCallback, type FfiCallbackAdapter } from "../ffi-callbacks.js"; import { mangleAsyncSpawn, @@ -971,6 +971,9 @@ export class CEmitter { // Dgram/dns-surface programs fill the loop's dgram hooks the same // way — scr_dgram.c links only when this line is emitted. ...(moduleUsesDgram(this.mod) ? [` scr_dgram_install();`] : []), + // node:midi programs fill the loop's midi hook the same way — + // scr_midi.c links only when this line is emitted. + ...(moduleUsesMidi(this.mod) ? [` scr_midi_install();`] : []), // fs.watch programs fill the loop's watch hooks the same way — // scr_watch.c links only when this line is emitted. ...(moduleUsesFsWatch(this.mod) ? [` scr_watch_install();`] : []), @@ -1749,16 +1752,22 @@ export class CEmitter { * assigns later, a constructor branch skips it, a base constructor's * virtual call reads a derived field before super() returns. Node reads * `undefined` there; a NULL payload pointer would be a segfault (union - * fields) or a silent nothing (jsval fields). Undefined-armed unions get - * the interned immortal unit instance (free; releases skip it); jsval - * (`any`) fields get an engine undefined cell — such classes exist only - * in --dynamic builds, and the field's release balances it. Empty for - * every type that cannot hold undefined (tsc's SPI guards those) and for - * record shapes' construction paths, which write every field. */ + * fields) or a silent nothing (jsval/dyn fields). Undefined-armed unions + * get the interned immortal unit instance (free; releases skip it); jsval + * (`any`) fields get an engine undefined cell, while dyn (`unknown`) + * fields get the checked-dynamic immortal undefined singleton — both are + * retained here, and the field's ordinary release (releaseExprC's "dyn"/ + * "jsval" cases, run wherever the instance's fields are released) balances + * it. Empty for every type that cannot hold undefined (tsc's SPI guards + * those) and for record shapes' construction paths, which write every + * field. */ undefFieldInitLineC(name: string, t: IrType): string[] { if (t.kind === "jsval") { return [` o->${mangleField(name)} = scr_jsval_undefined(); /* ${name} starts undefined */`]; } + if (t.kind === "dyn") { + return [` o->${mangleField(name)} = scr_dyn_retain(scr_dyn_undefined()); /* ${name} starts undefined */`]; + } const tag = this.undefinedArmTag(t); if (tag < 0 || t.kind !== "union") return []; return [` o->${mangleField(name)} = ${this.unitInstanceRef(t.unionId, tag)}; /* ${name} starts undefined */`]; @@ -2066,6 +2075,8 @@ export class CEmitter { case "http2Session": case "http2Stream": case "dgramSocket": + case "midiInput": + case "midiOutput": case "testCtx": case "httpReq": case "httpRes": diff --git a/packages/compiler/src/backend/llvm/classes.ts b/packages/compiler/src/backend/llvm/classes.ts index b208411fe..9a52c7bf9 100644 --- a/packages/compiler/src/backend/llvm/classes.ts +++ b/packages/compiler/src/backend/llvm/classes.ts @@ -213,16 +213,18 @@ export interface ClassHost extends ShapeHost { /** The newFn initialization stores for fields whose type ADMITS undefined * (undefFieldInitLineC's LLVM twin): undefined-armed union fields start - * at the interned unit instance; jsval fields (an `any` class field under - * --dynamic) start at the engine's undefined cell. */ + * at the interned unit instance; jsval (`any`) fields start at the engine's + * undefined cell, while dyn (`unknown`) fields start at the checked-dynamic + * immortal undefined singleton. */ function undefFieldInits(host: ClassHost, meta: LlClassMeta): string[] { const out: string[] = []; meta.def.fields.forEach((f, i) => { const { index } = classFieldIndex(meta, f.name); - if (f.type.kind === "jsval") { - host.declare(`declare ptr @scr_jsval_undefined()`); + if (f.type.kind === "jsval" || f.type.kind === "dyn") { + const fn = f.type.kind === "jsval" ? "scr_jsval_undefined" : "scr_dyn_undefined"; + host.declare(`declare ptr @${fn}()`); out.push( - ` %ufv${i} = call ptr @scr_jsval_undefined()`, + ` %ufv${i} = call ptr @${fn}()`, ` %uf${i} = getelementptr inbounds %${mangleClassStruct(meta.def.name)}, ptr %o, i64 0, i32 ${index}`, ` store ptr %ufv${i}, ptr %uf${i} ; ${f.name} starts undefined`, ); diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 6f685bc2f..2e2dadca2 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -14173,6 +14173,35 @@ class LlEmitter { B.line(`${t} = call double @llvm.fabs.f64(double ${v.name})`); return { name: t, type: e.type }; } + if ( + e.fn === "math.sin" || + e.fn === "math.cos" || + e.fn === "math.sqrt" || + e.fn === "math.exp" || + e.fn === "math.log" + ) { + const v = this.emitExpr(e.args[0]!); + this.declare(`declare double @llvm.${e.fn.slice(5)}.f64(double)`); + const t = B.tmp(); + B.line(`${t} = call double @llvm.${e.fn.slice(5)}.f64(double ${v.name})`); + return { name: t, type: e.type }; + } + if (e.fn === "math.pow") { + const left = this.emitExpr(e.args[0]!); + const right = this.emitExpr(e.args[1]!); + this.declare(`declare double @llvm.pow.f64(double, double)`); + const t = B.tmp(); + B.line(`${t} = call double @llvm.pow.f64(double ${left.name}, double ${right.name})`); + return { name: t, type: e.type }; + } + if (e.fn === "math.fround") { + const v = this.emitExpr(e.args[0]!); + const narrowed = B.tmp(); + const widened = B.tmp(); + B.line(`${narrowed} = fptrunc double ${v.name} to float`); + B.line(`${widened} = fpext float ${narrowed} to double`); + return { name: widened, type: e.type }; + } if (e.fn === "num.isNaN") { const v = this.emitExpr(e.args[0]!); const t = B.tmp(); diff --git a/packages/compiler/src/coverage/report.ts b/packages/compiler/src/coverage/report.ts index 99b484017..e0024a139 100644 --- a/packages/compiler/src/coverage/report.ts +++ b/packages/compiler/src/coverage/report.ts @@ -207,7 +207,9 @@ export function renderCoverage(input: CoverageInput, opts: { color?: boolean; so ); for (const b of builtins) { const status = b.shimmed - ? c(GREEN, "shimmed".padEnd(widestS)) + ? b.partial + ? c(YELLOW, "partial".padEnd(widestS)) + : c(GREEN, "shimmed".padEnd(widestS)) : b.lazy ? c(YELLOW, "not shimmed — lazy trap".padEnd(widestS)) : c(RED, "not shimmed".padEnd(widestS)); @@ -226,6 +228,11 @@ export function renderCoverage(input: CoverageInput, opts: { color?: boolean; so c(DIM, `(${t.via.join("/")} in ${t.packages.join(", ")})`), ); } + if (builtins.some((b) => b.partial)) { + out.push( + ` ${c(DIM, "(partial: the shim exists but covers only part of Node's surface; unsupported members throw at the call)")}`, + ); + } if (builtins.some((b) => b.lazy) || traps.length > 0) { out.push( ` ${c(DIM, "(lazy trap: only reachable through require()/import() boundaries — the build embeds Node's call-time error; the call throws at runtime)")}`, diff --git a/packages/compiler/src/coverage/surface-manifest.ts b/packages/compiler/src/coverage/surface-manifest.ts index 0291c060d..e6fdb41d5 100644 --- a/packages/compiler/src/coverage/surface-manifest.ts +++ b/packages/compiler/src/coverage/surface-manifest.ts @@ -52,6 +52,7 @@ import { SET_COMBINE_METHODS, SET_METHODS, STATIC_MATH_FNS, + STATIC_MATH_PROPS, STATIC_NUMBER_METHODS, STR_METHODS, UNSUPPORTED_EXPR, @@ -221,8 +222,12 @@ export function generateSurfaceManifest(compilerVersion: string): SurfaceManifes add({ id: `stdlib.math.${name}`, kind: "stdlib", name: `Math.${name}`, status: "dynamic-only", code: "SC2012" }); } } - for (const name of Object.keys(ISLAND_SURFACE.math.props)) { - add({ id: `stdlib.math.${name}`, kind: "stdlib", name: `Math.${name}`, status: "dynamic-only", code: "SC2012" }); + for (const name of new Set([...Object.keys(STATIC_MATH_PROPS), ...Object.keys(ISLAND_SURFACE.math.props)])) { + if (STATIC_MATH_PROPS[name] !== undefined) { + add({ id: `stdlib.math.${name}`, kind: "stdlib", name: `Math.${name}`, status: "static" }); + } else { + add({ id: `stdlib.math.${name}`, kind: "stdlib", name: `Math.${name}`, status: "dynamic-only", code: "SC2012" }); + } } const numberNames = new Set([ ...Object.keys(STATIC_NUMBER_METHODS), diff --git a/packages/compiler/src/diagnostics/render.ts b/packages/compiler/src/diagnostics/render.ts index 772618049..b79fb881f 100644 --- a/packages/compiler/src/diagnostics/render.ts +++ b/packages/compiler/src/diagnostics/render.ts @@ -82,6 +82,12 @@ export function renderDiagnostic( return out.join("\n"); } +// Above this many diagnostics, rendering every one (each carrying its own +// source-line context) can push the joined report past V8's max string +// length (RangeError: Invalid string length) — cap the render and say so, +// rather than crashing the whole report. +const MAX_RENDERED_DIAGNOSTICS = 1000; + export function renderAll( diags: ScrDiagnostic[], sourceTextByFile: Map, @@ -90,10 +96,15 @@ export function renderAll( const sorted = [...diags].sort( (a, b) => a.loc.file.localeCompare(b.loc.file) || a.loc.start - b.loc.start, ); - return sorted + const shown = sorted.slice(0, MAX_RENDERED_DIAGNOSTICS); + const rendered = shown .map((d) => { const text = sourceTextByFile.get(d.loc.file); return renderDiagnostic(d, text === undefined ? undefined : { text }, opts); }) .join("\n\n"); + const omitted = sorted.length - shown.length; + return omitted > 0 + ? `${rendered}\n\n... ${omitted} more diagnostic${omitted === 1 ? "" : "s"} not shown (${sorted.length} total)` + : rendered; } diff --git a/packages/compiler/src/executable/early-cache.test.ts b/packages/compiler/src/executable/early-cache.test.ts index 463a410cb..c14fd426f 100644 --- a/packages/compiler/src/executable/early-cache.test.ts +++ b/packages/compiler/src/executable/early-cache.test.ts @@ -44,6 +44,7 @@ const native: EarlyExecutableNativeFeatures = { http: false, http2: false, dgram: false, + midi: false, watch: false, foreignFfi: false, nodeTest: false, diff --git a/packages/compiler/src/executable/early-cache.ts b/packages/compiler/src/executable/early-cache.ts index 09e22acfc..6360438ba 100644 --- a/packages/compiler/src/executable/early-cache.ts +++ b/packages/compiler/src/executable/early-cache.ts @@ -42,6 +42,7 @@ export interface EarlyExecutableNativeFeatures { http: boolean; http2: boolean; dgram: boolean; + midi: boolean; watch: boolean; foreignFfi: boolean; nodeTest: boolean; @@ -143,6 +144,7 @@ const BOOLEAN_NATIVE_KEYS = [ "http", "http2", "dgram", + "midi", "watch", "foreignFfi", "nodeTest", diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index 63427842f..f9aadcc20 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -2864,7 +2864,7 @@ export function lowerFfiCall(L: Lowerer, expr: ts.CallExpression): IrExpr | null // No entry means the program-level pass already diagnosed this // binding. Poison the statement without duplicating that diagnostic. if (validSymbols === undefined) throw new PoisonError(); - if (!validSymbols.has(symbol)) { + if (!L.ownsValidatedFfiSymbol(binding.name, symbol)) { if (L.libraryCallbacks) { const declarations = L.checker.declarationsOf(symbol); const programDeclarations = declarations.filter((decl) => @@ -3632,6 +3632,11 @@ export function lowerCall(L: Lowerer, expr: ts.CallExpression): IrExpr { // The dgram spoke (lower-dgram.ts) owns dgram and dns the same way. const dgramServed = L.lowerDgramDnsModuleCall(expr, bi, loc); if (dgramServed) return dgramServed; + // The midi spoke (lower-midi.ts) owns node:midi — fence-only here + // (the ports are `new`-constructed, so a call on a midi binding has + // no lowering); construction rides the lowerNew chain. + const midiServed = L.lowerMidiModuleCall(expr, bi, loc); + if (midiServed) return midiServed; // The assert spoke (lower-assert.ts) owns node:assert the same way // (`import { strictEqual } from "node:assert"` and the destructured // require twin land here). @@ -4227,6 +4232,10 @@ export function lowerCall(L: Lowerer, expr: ts.CallExpression): IrExpr { L.lowerDcTracingChannelMethodCall(expr, expr.expression) ?? L.lowerServerMethodCall(expr, expr.expression) ?? L.lowerDgramMethodCall(expr, expr.expression) ?? + // midi.Input / midi.Output receivers — the port method surface + // (getPortCount/getPortName/openPort/openVirtualPort/closePort/ + // isPortOpen, ignoreTypes, sendMessage) and the "message" listener. + L.lowerMidiMethodCall(expr, expr.expression) ?? // node:test — skip/todo/only twins on named import bindings, the // TestContext surface (t.test/t.skip/t.diagnostic), t.assert.*. L.lowerTestMethodCall(expr, expr.expression) ?? @@ -5809,14 +5818,8 @@ const inliningPredicates = new Set(); // A `void e` body rides the statement lowering (the value is // discarded here, so the operand evaluates for effect alone — // `(name) => void doThing(name)`, the fire-and-forget arrow). - let stripped: ts.Expression = bodyExpr; - while (ts.isParenthesizedExpression(stripped)) stripped = stripped.expression; - if (ts.isVoidExpression(stripped)) { - body = [L.lowerExprStatement(stripped)]; - } else { - const value = L.lowerExpr(bodyExpr); - body = value.kind === "unitLit" ? [] : [{ kind: "exprStmt", expr: value, loc: locOf(node.body!) }]; - } + const stmt = L.lowerExprStatement(bodyExpr); + body = stmt.kind === "block" && stmt.body.length === 0 ? [] : [stmt]; } else { let value = L.lowerExpr(bodyExpr); // An async concise body whose value is itself a promise diff --git a/packages/compiler/src/frontend/lowering/lower-classes.ts b/packages/compiler/src/frontend/lowering/lower-classes.ts index cad7f1437..e1d34f085 100644 --- a/packages/compiler/src/frontend/lowering/lower-classes.ts +++ b/packages/compiler/src/frontend/lowering/lower-classes.ts @@ -1232,9 +1232,6 @@ export function collectClassShapeInner(L: Lowerer, decl: ts.ClassLikeDeclaration ) { const type = L.irTypeOf(member.name); if (type.kind === "void") L.badType(member.name, L.typeOf(member.name)); - if (type.kind === "dyn") { - L.unsupported("SC1090", member.name, "'unknown'-typed static fields"); - } staticFields.push({ name: member.name.text, type, @@ -1476,11 +1473,9 @@ export function collectClassShapeInner(L: Lowerer, decl: ts.ClassLikeDeclaration // the ordinary undefined-armed union machinery. const type = L.irTypeOf(member.name); if (type.kind === "void") L.badType(member.name, L.typeOf(member.name)); - // dyn stays out of class fields (KEEP NARROW; record - // fields and array elements are unmappable via mapType already). - if (type.kind === "dyn") { - L.unsupported("SC1090", member.name, "'unknown'-typed class fields"); - } + // `unknown` fields use the same checked-dynamic dyn kind as + // unknown locals/params. Allocation initializes them to the dyn + // undefined singleton before field initializers run. if (fields.has(member.name.text)) { // REDECLARING an inherited field: Node [[Define]]s the OWN // property again when THIS class's field initializers run @@ -1591,10 +1586,8 @@ export function collectClassShapeInner(L: Lowerer, decl: ts.ClassLikeDeclaration const shape = L.paramShape(p); const type = shape.bodyType ?? shape.type; if (type.kind === "void") L.badType(p.name, L.typeOf(p.name)); - // The class-field dyn rule verbatim (KEEP NARROW). - if (type.kind === "dyn") { - L.unsupported("SC1090", p.name, "'unknown'-typed class fields"); - } + // Unknown parameter properties use the ordinary dyn parameter + // ABI and assign into the dyn class slot after super(). // `override x` (and any same-named inherited member) would // redeclare a base slot — the declared-field rule verbatim. if (fields.has(name)) { @@ -4980,8 +4973,11 @@ export function lowerNew(L: Lowerer, expr: ts.NewExpression): IrExpr { // `new URL(input)`: the WHATWG URL class (stdlib/@types provenance — // a user's own `class URL` resolves through classBySymbol below). // One string argument; invalid input throws a catchable TypeError - // ("Invalid URL"), like Node. The lib's base-argument form - // typechecks and is fenced here. + // ("Invalid URL"), like Node. The two-argument `new URL(url, base)` + // form is resolved at COMPILE TIME when both arguments are string + // literals (Node's own URL class does the resolving); any other + // shape — a non-literal url or base, or a base that fails to + // resolve — is fenced. // `new RegExp(pattern, flags?)`: runtime construction over the same // libregexp engine the literals ride. The pattern compiles EAGERLY, // so bad input throws Node's catchable SyntaxError at construction. @@ -5010,11 +5006,28 @@ export function lowerNew(L: Lowerer, expr: ts.NewExpression): IrExpr { } if (symbol && symbol.name === "URL" && L.isStdlibSymbol(symbol)) { const args = expr.arguments ?? []; + if (args.length === 2) { + const urlExpr = L.lowerExpr(args[0]!); + const baseExpr = L.lowerExpr(args[1]!); + if (urlExpr.kind === "strLit" && baseExpr.kind === "strLit") { + try { + const resolved = new URL(urlExpr.value, baseExpr.value).href; + return { kind: "libCall", fn: "url.new", args: [{ kind: "strLit", value: resolved, type: STRING, loc }], type: URL_T, loc }; + } catch { + L.noLowering("new URL with an unresolvable base URL", expr, "the base argument must be a valid absolute URL"); + } + } + L.noLowering( + "new URL with a non-literal argument", + expr, + "compile-time string literals for both url and base are required; resolve relative inputs against a base yourself, or use --dynamic for runtime URL resolution", + ); + } if (args.length !== 1) { L.noLowering( `new URL with ${args.length} argument${args.length === 1 ? "" : "s"}`, expr, - "one absolute-URL string is the supported form (resolve relative inputs against a base yourself)", + "one absolute-URL string or two string literals (url + base) are the supported forms", symbol, ); } diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts index 470b9ffe8..1cde7db2a 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -410,6 +410,21 @@ function lowerExprInner(L: Lowerer, expr: ts.Expression): IrExpr { } return L.jsvalIn(fence, valueNode); }; + // A property key's runtime text, for every key shape this literal + // can carry: `name`/string-literal keys spell themselves; a + // COMPUTED key (`[Methods.POST]: {...}`, `[E.member]: v` — an + // enum-member or other compile-time-constant string reference in + // brackets, extremely common for method/route tables) folds + // through the same literalComputedKey/foldedStringKeyOf machinery + // every other computed-key call site in this file already uses. + // null for a key with no compile-time-constant spelling (a runtime- + // computed key — keeps the fence). + const foldedKeyTextOf = (n: ts.PropertyName): string | null => + ts.isIdentifier(n) || ts.isStringLiteral(n) + ? n.text + : ts.isComputedPropertyName(n) + ? foldedStringKeyOf(L, n.expression) + : null; // The member's SHAPE decides the fence's granularity: syntactic // functions and checker-callable values keep the call-time // closure; everything else (call results, awaits, data reads — @@ -427,8 +442,15 @@ function lowerExprInner(L: Lowerer, expr: ts.Expression): IrExpr { if (ts.isArrowFunction(inner) || ts.isFunctionExpression(inner)) return true; return L.checker.getCallSignatures(L.typeOf(src)).length > 0; }; + // `name` carries both the property's key text and a node to blame + // in diagnostics/source-locations. A plain `name: value`/shorthand + // key IS that node; a computed key that folds to a compile-time + // string constant (`[Methods.POST]: {...}`, `[E.member]: v` — see + // literalComputedKey/foldedStringKeyOf) has no single text-bearing + // node of its own, so its ComputedPropertyName stands in for loc + // purposes while the folded string supplies the text. const pushProp = ( - name: ts.Identifier | ts.StringLiteral, + name: { text: string; node: ts.Node }, value: IrExpr, valueNode: ts.Node, into: IrExpr[][], @@ -450,8 +472,8 @@ function lowerExprInner(L: Lowerer, expr: ts.Expression): IrExpr { for (const args of into) { args.push({ kind: "jsMarshal", - value: { kind: "strLit", value: name.text, type: STRING, loc: locOf(name) }, - type: JSVAL, loc: locOf(name), + value: { kind: "strLit", value: name.text, type: STRING, loc: locOf(name.node) }, + type: JSVAL, loc: locOf(name.node), }); args.push(marshaled); } @@ -480,7 +502,7 @@ function lowerExprInner(L: Lowerer, expr: ts.Expression): IrExpr { spread = { cond, whenTrue: cs.whenTrue }; for (const p of cs.props) { const v = ts.isPropertyAssignment(p) ? L.lowerExpr(p.initializer) : L.lowerShorthandValue(p); - pushProp(p.name, v, p, [argsWith]); + pushProp({ text: p.name.text, node: p.name }, v, p, [argsWith]); } continue; } @@ -544,14 +566,14 @@ function lowerExprInner(L: Lowerer, expr: ts.Expression): IrExpr { ? (L.rejectThisInObjectMethod(prop.body), L.lowerLambda(prop)) : null; } catch (err) { - const nameText = - name && (ts.isIdentifier(name) || ts.isStringLiteral(name)) ? name.text : null; + const nameText = name ? foldedKeyTextOf(name) : null; const asGetter = nameText !== null && spread === null && !funcShapedMember(prop); value = islandMemberFence(valueDiagsBefore, err, prop, asGetter ? nameText : null); if (value === null) continue; // registered as a fence getter — no data property } - if (value && name && (ts.isIdentifier(name) || ts.isStringLiteral(name))) { - pushProp(name, value, prop, [argsWithout, argsWith]); + const nameText = name ? foldedKeyTextOf(name) : null; + if (value && name && nameText !== null) { + pushProp({ text: nameText, node: name }, value, prop, [argsWithout, argsWith]); } else { L.unsupported( "SC1090", @@ -998,6 +1020,7 @@ function lowerExprInner(L: Lowerer, expr: ts.Expression): IrExpr { WeakRef: "deref()-after-collect exposes GC timing — genuinely dynamic; hold a strong reference instead", FinalizationRegistry: "finalization callbacks expose GC timing — genuinely dynamic; release resources explicitly instead", eval: "runtime code evaluation cannot be compiled ahead of time", + crypto: "the Web Crypto API (globalThis.crypto) has no static lowering; import named exports from 'node:crypto' instead — e.g. import { randomUUID } from \"node:crypto\"", }; L.noLowering(expr.text, expr, globalHints[expr.text], sym ?? undefined); } @@ -3219,6 +3242,13 @@ export function lowerOptionalChain(L: Lowerer, expr: ts.CallExpression | ts.Prop export function lowerCondition(L: Lowerer, expr: ts.Expression): IrExpr { let e: ts.Expression = expr; while (ts.isParenthesizedExpression(e)) e = e.expression; + // Node always installs the supported global Buffer constructor. A + // captured capability probe (`const b = globalThis.Buffer; if (b)`) is + // compile-time true; receiver-position calls through the alias still + // resolve via stdlibGlobalNameOf and keep Buffer's per-member fences. + if (stdlibGlobalNameOf(L, e) === "Buffer") { + return { kind: "boolLit", value: true, type: BOOL, loc: locOf(expr) }; + } if (ts.isBinaryExpression(e)) { const op = e.operatorToken.kind; if (op === ts.SyntaxKind.AmpersandAmpersandToken || op === ts.SyntaxKind.BarBarToken) { @@ -7237,6 +7267,16 @@ export function lowerTemplate(L: Lowerer, expr: ts.TemplateExpression): IrExpr { if (targetTs.flags & ts.TypeFlags.Any) return inner; const target = L.mapTypeOf(targetTs); if (!target) L.badType(expr.type, targetTs); + // A target type that ITSELF maps to the jsval representation (an + // npm/ambient-declared type with no static shape of its own — same + // island-handle kind the receiver already is) is the same erasure as + // `targetTs.flags & Any` above, spelled through a named type alias + // instead of the literal keyword. `boundarySafe` answers for the + // JSON-representable VALIDATION targets below; a jsval target has no + // validation story because it has no static shape to validate + // against — it stays a checked cast of 'any' to 'any' (formatIrType + // prints jsval as "any"), which is not a real narrowing at all. + if (target.kind === "jsval") return inner; if (!L.boundarySafe(target)) { L.unsupported( "SC1090", @@ -10130,7 +10170,7 @@ export function lowerBinary(L: Lowerer, expr: ts.BinaryExpression): IrExpr { * and `switch (r.kind)` work without dedicated test nodes. Anything else * on a union receiver is rejected specifically (narrow first). */ export function lowerUnionProperty(L: Lowerer, expr: ts.PropertyAccessExpression): IrExpr | null { - if (expr.questionDotToken) return null; + if (L.chainBlocked(expr)) return null; const receiverIr = L.mapTypeOf(L.typeOf(expr.expression)); if (receiverIr?.kind !== "union") return null; // Lower the receiver FIRST and read its actual IR union: a partially @@ -10141,8 +10181,10 @@ export function lowerBinary(L: Lowerer, expr: ts.BinaryExpression): IrExpr { // A checker-union receiver whose VALUE lowered to a plain RECORD (the // merged-signature fiction — `runner(cmd, args)` where runner joined // a structural runner type with spawnSync's, and the local adopted - // the record arm): read the record field directly, the dyn-receiver - // fallback's discipline. + // the record arm), or to a concrete CLASS object behind an erasing + // widening assertion (`concrete as A | B`): read the actual value's + // field directly. Assertions change the checker type, not the runtime + // representation; manufacturing a tagged union here would be wrong. // A checker-union receiver whose VALUE lowered checked-dynamic (a // never-tainted JS chain — `cmd[1].length` on `const cmd = ['pwd', // []]`, where the element read stayed a dyn node): read through the @@ -10151,6 +10193,17 @@ export function lowerBinary(L: Lowerer, expr: ts.BinaryExpression): IrExpr { const key: IrExpr = { kind: "strLit", value: expr.name.text, type: STRING, loc: locOf(expr.name) }; return { kind: "dynKeyGet", key, value, type: DYN, loc: locOf(expr) }; } + // A checker-union receiver whose VALUE lowered to a checked-dynamic + // island handle (`jsval` — an npm/chain-derived value the checker + // widened into a union, e.g. an optional-chained receiver whose + // narrowed arms all trace back to one dynamic value): the generic + // island property read, same shape isIslandExpr's own jsval branch + // above uses. Not a dynKeyGet — the two dynamic worlds (dyn's checked- + // dynamic tree and jsval's island handles) never share a value + // representation. + if (value.type.kind === "jsval") { + return { kind: "jsOp", op: "getProp", name: expr.name.text, args: [value], type: JSVAL, loc: locOf(expr) }; + } if (value.type.kind === "record") { const shape = L.shapes.get(value.type.shapeId); const f = shape?.fields.find((x) => x.name === expr.name.text); @@ -10166,8 +10219,26 @@ export function lowerBinary(L: Lowerer, expr: ts.BinaryExpression): IrExpr { } return null; } + if (value.type.kind === "object") { + const fieldType = L.classes.get(value.type.className)?.fields.get(expr.name.text); + if (fieldType) { + return { + kind: "fieldGet", + obj: value, + className: value.type.className, + field: expr.name.text, + type: fieldType, + loc: locOf(expr), + }; + } + return null; + } if (value.type.kind !== "union") { - throw new Error("lowerer bug: union-typed receiver lowered to a non-union"); + throw new Error( + `lowerer bug: union-typed receiver lowered to a non-union (kind=${value.type.kind}) ` + + `at ${expr.getSourceFile().fileName}:${expr.getSourceFile().getLineAndCharacterOfPosition(expr.getStart()).line + 1} ` + + `text=${expr.getText().slice(0, 120)}`, + ); } const def = L.unions.get(value.type.unionId); if (!def) throw new Error(`lowerer bug: unknown union ${value.type.unionId}`); diff --git a/packages/compiler/src/frontend/lowering/lower-island.ts b/packages/compiler/src/frontend/lowering/lower-island.ts index f75a52cf6..5585c8ffe 100644 --- a/packages/compiler/src/frontend/lowering/lower-island.ts +++ b/packages/compiler/src/frontend/lowering/lower-island.ts @@ -5,7 +5,7 @@ import * as ts from "../ts7/adapter.js"; import type { Lowerer } from "./lowerer.js"; import { BOOL, BYTES_U8, DYN, F64, IrExpr, IrStmt, IrType, JSVAL, MAX_ISLAND_CALLBACK_ARITY, STRING, VOID, canConvertToDyn, canMarshalTypedFuncIntoIsland, islandPromisePayloadTag, isUnitType } from "../../ir/nodes.js"; -import { ISLAND_SURFACE, IslandFnEntry, STATIC_MATH_FNS, boundaryIntoIslandMsg } from "./surfaces.js"; +import { ISLAND_SURFACE, IslandFnEntry, STATIC_MATH_FNS, STATIC_MATH_PROPS, boundaryIntoIslandMsg } from "./surfaces.js"; import { requiresDynamicApiDiag, requiresDynamicPackageDiag } from "../../diagnostics/diagnostic.js"; import { isCjsJsFile, isJsSourceFile, locOf, npmPackageNameOf } from "../program.js"; import { foldedStringKeyOf, lowerDynObjectLiteral, pureReemittable } from "./lower-exprs.js"; @@ -3335,6 +3335,10 @@ export function lowerStaticReadableStreamReaderCall( const member = L.stdlibGlobalMember(expr, "Math"); if (member === null) return null; const loc = locOf(expr); + const staticProp = own(STATIC_MATH_PROPS, member); + if (staticProp !== undefined) { + return { kind: "numLit", value: staticProp, type: F64, loc }; + } const propType = own(ISLAND_SURFACE.math.props, member); if (propType !== undefined) { L.requireDynamicApi(`'Math.${member}'`, expr); diff --git a/packages/compiler/src/frontend/lowering/lower-midi.ts b/packages/compiler/src/frontend/lowering/lower-midi.ts new file mode 100644 index 000000000..fe1bf66e6 --- /dev/null +++ b/packages/compiler/src/frontend/lowering/lower-midi.ts @@ -0,0 +1,334 @@ +/* The midi-surface lowering (node:midi — a spoke module like lower-dgram.ts, + * on which it is modeled part for part): the port-handle CONSTRUCTORS + * (`new Input()` / `new Output()`, the node-midi/@julusian shape) and the + * method surface on midiInput/midiOutput receivers (getPortCount/ + * getPortName/openPort/openVirtualPort/closePort/isPortOpen, ignoreTypes on + * inputs, sendMessage on outputs, and the on/once "message" listener). + * Construction is via `new` — the classes are the module's only exports, so + * there is NO module-function surface (unlike dgram's createSocket); a CALL + * on a midi import binding fences module-qualified. Everything the lib + * declares beyond these shapes fences member-qualified — never a generic + * rejection, never silence. */ +import * as ts from "../ts7/adapter.js"; +import type { Lowerer } from "./lowerer.js"; +import { locOf } from "../program.js"; +import { BOOL, F64, funcOf, IrExpr, IrLibFn, IrType, MIDIIN_T, MIDIOUT_T, SrcLoc, STRING, VOID } from "../../ir/nodes.js"; + +const MIDI_SURFACE_HINT = + "getPortCount, getPortName, openPort, openVirtualPort, closePort, " + + "isPortOpen, ignoreTypes (Input), sendMessage (Output), and on/once of " + + '"message" are the supported midi Input/Output members'; + +/** The midi lib-fn ids the runtime implements (scr_midi.c). These are NOT + * in the frozen IrLibFn union yet — the emitter cases land with the runtime + * TU (Phase 3/4); moduleUsesMidi already detects them by the "midi." + * prefix (its `typeof node.fn === "string"` guard is written for exactly + * this). The spoke casts through this alias so the lowering emits the frozen + * §4 ABI ids without touching the shared IR/emission front-matter. */ +type MidiLibFn = + | "midi.newInput" + | "midi.newOutput" + | "midi.portCount" + | "midi.portName" + | "midi.openPort" + | "midi.openVirtual" + | "midi.closePort" + | "midi.isOpen" + | "midi.ignoreTypes" + /** sendMessage's two marshalers, picked by argument type — the dgram + * sendStr/sendBytes split retargeted: a number[] literal/array rides + * sendArray (scr_midi_send_array over ScrArr*), a Uint8Array rides + * sendBytes (scr_midi_send_bytes over ScrBytes*). */ + | "midi.sendArray" + | "midi.sendBytes" + /** on/once("message", (deltaTime, message) => …) — the trailing bool is + * once; the emitter picks the msg_thunk0/1/2 adapter by the listener's + * declared parameter count (the dgram.onMessage story exactly). */ + | "midi.onMessage"; +const midiFn = (fn: MidiLibFn): IrLibFn => fn as unknown as IrLibFn; + +/** The module's lowered value members — the surfaces.ts twin. EMPTY: the + * two exports are classes reached through `new` (lowerMidiNew), so there is + * no module-function to table. The set exists to mirror the dgram spoke and + * to name the "recognized module, unlowered member" fence. */ +export const MIDI_MODULE_FNS: ReadonlySet = new Set(); + +/** VOID-result port calls are usable as statements and as concise arrow + * bodies; anything consuming the result (Node returns void here too, but + * the fence keeps parity with the dgram stance) is fenced — the lower-dgram + * rule verbatim. */ +function requireStatementPosition(L: Lowerer, call: ts.CallExpression, what: string): void { + if (ts.isExpressionStatement(call.parent) || ts.isArrowFunction(call.parent)) return; + L.unsupported( + "SC1090", + call, + `using the result of ${what} (the result is void here — call it as its own statement)`, + ); +} + +/** Lowers a listener/callback argument, pinning the closure shape: void + * return, at most `maxParams` parameters, each parameter's IR kind + * satisfying `paramOk` (indexed). The lower-dgram helper's shape, re-stated + * here so the spoke stays self-contained. */ +function lowerCallbackArg( + L: Lowerer, + node: ts.Expression, + what: string, + maxParams: number, + paramOk: (p: IrType, i: number) => boolean, + paramHint: string, +): { cb: IrExpr; nparams: number } { + let cb = L.lowerExpr(node); + // A checked-dynamic callback (test/common's mustCall wrapper — a dyn + // value): the zero-parameter slots adapt through the dynCheck function + // boundary, the lower-dgram listen-callback precedent. + if (cb.type.kind === "dyn" && maxParams === 0) { + cb = { kind: "dynCheck", value: cb, type: funcOf([], VOID), loc: locOf(node) }; + } + if (cb.type.kind !== "func" || cb.type.params.length > maxParams) { + L.unsupported( + "SC1090", + node, + `${what} with more than ${maxParams} parameter${maxParams === 1 ? "" : "s"} (${paramHint})`, + ); + } + if (cb.type.ret.kind !== "void") { + L.unsupported( + "SC1090", + node, + "listeners returning a value (make the callback body a block, or return nothing)", + ); + } + for (let i = 0; i < cb.type.params.length; i++) { + if (!paramOk(cb.type.params[i]!, i)) { + L.unsupported("SC1090", node, `${what} whose parameter is not supported (${paramHint})`); + } + } + return { cb, nparams: cb.type.params.length }; +} + +const boolLit = (value: boolean, loc: SrcLoc): IrExpr => ({ kind: "boolLit", value, type: BOOL, loc }); + +/** `new Input()` / `new Output()` — the port-handle constructors, one entry + * in lowerer.ts's lowerNew chain (the AbortController/Response precedent). + * The mapped instance type IS the discriminator: types.ts pins Input/Output + * declared inside `declare module "midi"` to midiInput/midiOutput (a user's + * local `class Input {}` never maps there), so the type answer both selects + * the constructor AND proves stdlib provenance. Null for any other `new`. + * Both ctors take no arguments (node-midi's `new midi.Input()`); an argument + * fences. */ +export function lowerMidiNew(L: Lowerer, expr: ts.NewExpression): IrExpr | null { + const kind = L.mapTypeOf(L.typeOf(expr))?.kind; + if (kind !== "midiInput" && kind !== "midiOutput") return null; + const isInput = kind === "midiInput"; + const cls = isInput ? "Input" : "Output"; + const args = expr.arguments ?? []; + const loc = locOf(expr); + if (args.length !== 0) { + L.noLowering( + `new ${cls} with ${args.length} argument${args.length === 1 ? "" : "s"}`, + expr, + `the supported form is new ${cls}() — the port constructors take no arguments`, + ); + } + return { + kind: "libCall", + fn: midiFn(isInput ? "midi.newInput" : "midi.newOutput"), + args: [], + type: isInput ? MIDIIN_T : MIDIOUT_T, + loc, + }; +} + +/** Module-function calls on midi import bindings (named imports AND + * namespace members). node:midi has NO callable exports — Input/Output are + * classes reached through `new` — so every call fences module-qualified. + * Null for other modules (the caller falls through). */ +export function lowerMidiModuleCall(L: Lowerer, expr: ts.CallExpression, + bi: { module: string; member: string }, + loc: SrcLoc,): IrExpr | null { + void loc; + if (bi.module !== "midi") return null; + L.noLowering( + `midi.${bi.member}`, + expr, + "node:midi has no callable exports — construct ports with new Input() / new Output()", + ts.isIdentifier(expr.expression) ? L.resolveValueSymbol(expr.expression) : undefined, + ); +} + +/** Method calls on midi.Input / midi.Output receivers — one entry in + * lower-calls.ts's intrinsic chain (after lowerDgramMethodCall). Null for + * other receivers. */ +export function lowerMidiMethodCall(L: Lowerer, call: ts.CallExpression, + access: ts.PropertyAccessExpression,): IrExpr | null { + if (call.questionDotToken || access.questionDotToken) return null; + const recvKind = L.mapTypeOf(L.typeOf(access.expression))?.kind; + if (recvKind !== "midiInput" && recvKind !== "midiOutput") return null; + if (!L.isStdlibMember(access)) return null; + const isInput = recvKind === "midiInput"; + const name = access.name.text; + const loc = locOf(call); + const args = call.arguments; + // getPortCount() — enumeration works on a fresh handle before openPort + // (node-midi's enumerate-then-open, the ambient decl's promise). The + // frozen ABI passes the input/output discriminator so the shared C + // symbol reads the right stack. Value-returning: no statement fence. + if (name === "getPortCount") { + if (args.length !== 0) { + L.noLowering(`getPortCount with ${args.length} arguments`, call, "getPortCount() takes no arguments"); + } + const receiver = L.lowerExpr(access.expression); + return { kind: "libCall", fn: midiFn("midi.portCount"), args: [receiver, boolLit(isInput, loc)], type: F64, loc }; + } + if (name === "getPortName") { + if (args.length !== 1) { + L.noLowering(`getPortName with ${args.length} arguments`, call, "the supported form is getPortName(port)"); + } + const receiver = L.lowerExpr(access.expression); + const port = L.lowerExprExpecting(args[0]!, F64); + return { kind: "libCall", fn: midiFn("midi.portName"), args: [receiver, port], type: STRING, loc }; + } + if (name === "openPort") { + requireStatementPosition(L, call, "port.openPort(...)"); + if (args.length !== 1) { + L.noLowering(`openPort with ${args.length} arguments`, call, "the supported form is openPort(port)"); + } + const receiver = L.lowerExpr(access.expression); + const port = L.lowerExprExpecting(args[0]!, F64); + return { kind: "libCall", fn: midiFn("midi.openPort"), args: [receiver, port], type: VOID, loc }; + } + if (name === "openVirtualPort") { + requireStatementPosition(L, call, "port.openVirtualPort(...)"); + if (args.length !== 1) { + L.noLowering(`openVirtualPort with ${args.length} arguments`, call, "the supported form is openVirtualPort(name)"); + } + const receiver = L.lowerExpr(access.expression); + const nm = L.lowerExprExpecting(args[0]!, STRING); + return { kind: "libCall", fn: midiFn("midi.openVirtual"), args: [receiver, nm], type: VOID, loc }; + } + if (name === "closePort") { + requireStatementPosition(L, call, "port.closePort(...)"); + if (args.length !== 0) { + L.noLowering(`closePort with ${args.length} arguments`, call, "closePort() takes no arguments"); + } + const receiver = L.lowerExpr(access.expression); + return { kind: "libCall", fn: midiFn("midi.closePort"), args: [receiver], type: VOID, loc }; + } + if (name === "isPortOpen") { + if (args.length !== 0) { + L.noLowering(`isPortOpen with ${args.length} arguments`, call, "isPortOpen() takes no arguments"); + } + const receiver = L.lowerExpr(access.expression); + return { kind: "libCall", fn: midiFn("midi.isOpen"), args: [receiver], type: BOOL, loc }; + } + if (name === "ignoreTypes") { + // Input-only (the ambient decl only puts it on Input); the type guard + // would already have refused an Output receiver at the checker, but the + // fence keeps the honest hint if the fallback surface ever widens. + if (!isInput) { + L.noLowering( + "midi.Output.ignoreTypes", + call, + `ignoreTypes is an Input member (${MIDI_SURFACE_HINT})`, + L.checker.getSymbolAtLocation(access.name), + ); + } + requireStatementPosition(L, call, "input.ignoreTypes(...)"); + if (args.length !== 3) { + L.noLowering( + `ignoreTypes with ${args.length} arguments`, + call, + "the supported form is ignoreTypes(sysex, timing, activeSensing) — three booleans", + ); + } + const receiver = L.lowerExpr(access.expression); + const sysex = L.lowerExprExpecting(args[0]!, BOOL); + const timing = L.lowerExprExpecting(args[1]!, BOOL); + const sense = L.lowerExprExpecting(args[2]!, BOOL); + return { kind: "libCall", fn: midiFn("midi.ignoreTypes"), args: [receiver, sysex, timing, sense], type: VOID, loc }; + } + if (name === "sendMessage") { + // Output-only. The runtime is byte-transparent: a number[] literal/ + // array marshals through sendArray (ScrArr*), a Uint8Array through + // sendBytes (ScrBytes*) — the dgram sendStr/sendBytes split, one + // marshaler per static argument type. + if (isInput) { + L.noLowering( + "midi.Input.sendMessage", + call, + `sendMessage is an Output member (${MIDI_SURFACE_HINT})`, + L.checker.getSymbolAtLocation(access.name), + ); + } + requireStatementPosition(L, call, "output.sendMessage(...)"); + if (args.length !== 1) { + L.noLowering( + `sendMessage with ${args.length} arguments`, + call, + "the supported form is sendMessage(message) — one number[] or Uint8Array", + ); + } + if (ts.isSpreadElement(args[0]!)) { + L.noLowering( + "sendMessage with a spread argument", + args[0]!, + "pass the message as a single number[] or Uint8Array value", + ); + } + const receiver = L.lowerExpr(access.expression); + const data = L.lowerExpr(args[0]!); + const dt = data.type; + if (dt.kind === "array" && dt.elem.kind === "f64") { + return { kind: "libCall", fn: midiFn("midi.sendArray"), args: [receiver, data], type: VOID, loc }; + } + if (dt.kind === "bytes" && dt.elem === "u8") { + return { kind: "libCall", fn: midiFn("midi.sendBytes"), args: [receiver, data], type: VOID, loc }; + } + L.noLowering( + "sendMessage with a message that is not a number[] or Uint8Array", + args[0]!, + "the supported message shapes are a number[] (array literal) and a Uint8Array", + ); + } + if ((name === "on" || name === "once") && args.length === 2) { + // The "message" listener — input-only (Output declares no events). The + // (deltaTime: number, message: number[]) node-midi shape; the trailing + // bool is once, and the emitter picks msg_thunk0/1/2 by the listener's + // declared parameter count (the dgram.onMessage discipline). + if (!isInput) { + L.noLowering( + `midi.Output.${name}`, + call, + `on/once are Input members (${MIDI_SURFACE_HINT})`, + L.checker.getSymbolAtLocation(access.name), + ); + } + requireStatementPosition(L, call, `input.${name}(...)`); + const once = boolLit(name === "once", loc); + const evT = L.typeOf(args[0]!); + const event = evT.isStringLiteralType() ? evT.value : null; + const receiver = L.lowerExpr(access.expression); + if (event === "message") { + const { cb } = lowerCallbackArg( + L, args[1]!, "message listeners", 2, + (p, i) => + i === 0 ? p.kind === "f64" + : p.kind === "array" && p.elem.kind === "f64", + "use (deltaTime: number, message: number[]) or (deltaTime) or ()", + ); + return { kind: "libCall", fn: midiFn("midi.onMessage"), args: [receiver, cb, once], type: VOID, loc }; + } + L.noLowering( + `input.${name}(${event === null ? "non-literal event" : `"${event}"`}, ...)`, + args[0]!, + '"message" is the supported midi Input event (as a literal)', + ); + } + L.noLowering( + `midi.${isInput ? "Input" : "Output"}.${name}`, + call, + MIDI_SURFACE_HINT, + L.checker.getSymbolAtLocation(access.name), + ); +} diff --git a/packages/compiler/src/frontend/lowering/lower-modules.ts b/packages/compiler/src/frontend/lowering/lower-modules.ts index ced8e7a97..a0caafaa8 100644 --- a/packages/compiler/src/frontend/lowering/lower-modules.ts +++ b/packages/compiler/src/frontend/lowering/lower-modules.ts @@ -10,6 +10,7 @@ import { isNpmStaticPackage } from "../npm-static.js"; import { isJsSourceFileName, isRelativeSpecifier } from "../shared.js"; import { canonicalBuiltinModule, cjsExportAssignmentOf, cjsExportDiscardReason, isCjsJsFile, isJsSourceFile, isRequireStatement, locOf, makeCycleAdmission, orderedImportsOf, resolveImport, resolveNpmImport } from "../program.js"; import type { CycleEdge } from "../program.js"; +import { resolveProjectImport } from "../resolve.js"; import { invalidJsonModuleDiag, npmEmbedFailedDiag, requiresDynamicImportDiag } from "../../diagnostics/diagnostic.js"; import { BOOL, DYN, IrClassDef, IrExpr, IrFunction, IrGlobal, IrRecordShape, IrStmt, IrType, IrUnionDef, JSVAL, RUNTIME_ERROR_CLASSES, STRING, SrcLoc, VOID, arrayOf, canConvertToDyn, isUnitType } from "../../ir/nodes.js"; import { ENTRY_NAME, PoisonError, boundIdentifiersOf, dynFallbackType, dynUndefinedExpr, importCallHandleType, newFnCtx, uncheckedOverloadHandleCall } from "./lowerer.js"; @@ -70,14 +71,34 @@ export interface FileParts { * module namespace (lowerOwnModuleImport): a non-declaration program file * that is not JSON and not CommonJS-flavored (a CJS namespace is built * from module.exports through Node's lexer — a different surface with no - * static story here). Null for everything else. */ + * static story here). Null for everything else. + * + * Relative/absolute specifiers resolve through the checker (resolveImport, + * program.ts's own tsgo-backed answer). A BARE specifier reaching this far + * can still name a program module: a package importing its OWN name + * through its package.json self-name "exports" (or, once a project's + * `paths` are adopted, a tsconfig alias) — the checker resolves that + * specifier too, so lowering must agree or a bare dynamic import that the + * checker admitted lowers as a program-module namespace build while never + * having been added to the compiled module graph (appendDynamicImportModules + * walks resolveImport/resolveProjectImport's own answers, not this + * function's — a mismatch here strands the edge). resolve.ts's + * resolveProjectImport is the SAME resolver appendDynamicImportModules' + * static-edge walk and the npm-import chokepoint both already trust for + * bare project-internal specifiers, so reusing it here keeps every bare- + * specifier answer in the compiler on one resolver. */ export function dynamicImportProgramTargetOf( program: ts.Program, sf: ts.SourceFile, spec: string, ): ts.SourceFile | null { - if (!isRelativeSpecifier(spec) && !spec.startsWith("/")) return null; - const dep = resolveImport(program, sf, spec); + let dep: ts.SourceFile | null; + if (isRelativeSpecifier(spec) || spec.startsWith("/")) { + dep = resolveImport(program, sf, spec); + } else { + const resolved = resolveProjectImport(sf.fileName, spec); + dep = resolved !== null ? (program.getSourceFile(resolved) ?? null) : null; + } if (!dep || dep.isDeclarationFile) return null; if (dep.fileName.endsWith(".json") || dep.fileName.endsWith(".cts")) return null; if (isCjsJsFile(dep)) return null; diff --git a/packages/compiler/src/frontend/lowering/lower-namespaces.ts b/packages/compiler/src/frontend/lowering/lower-namespaces.ts index 0ac9def7f..8ad09e7dd 100644 --- a/packages/compiler/src/frontend/lowering/lower-namespaces.ts +++ b/packages/compiler/src/frontend/lowering/lower-namespaces.ts @@ -340,37 +340,6 @@ export function ambientNsRootOf(L: Lowerer, e: ts.Expression): ts.Identifier | n * the order Node dies in. Null for stdlib/@types roots (their own * chokepoints stand) and anything declared with a value. */ export function ambientUndefVarRootOf(L: Lowerer, e: ts.Expression): ts.Identifier | null { - let root: ts.Expression = e; - for (;;) { - if ( - ts.isParenthesizedExpression(root) || - ts.isNonNullExpression(root) || - ts.isAsExpression(root) || - ts.isSatisfiesExpression(root) || - ts.isTypeAssertion(root) - ) { - root = root.expression; - continue; - } - if (ts.isPropertyAccessExpression(root) || ts.isElementAccessExpression(root)) { - root = root.expression; - continue; - } - if (ts.isCallExpression(root) || ts.isNewExpression(root)) { - root = root.expression; - continue; - } - if (ts.isExpressionWithTypeArguments(root)) { - root = root.expression; - continue; - } - if (ts.isTaggedTemplateExpression(root)) { - root = root.tag; - continue; - } - break; - } - if (!ts.isIdentifier(root)) return null; // PROBE resolution: every caller asks "is this chain ambient-rooted?" // and proceeds to its ordinary lowering on a null answer — so the // question must not carry resolution's side effects. Bare @@ -378,12 +347,57 @@ export function ambientUndefVarRootOf(L: Lowerer, e: ts.Expression): ts.Identifi // diagnostics onto the build (reached-only-by-the-probe declarations // reported eagerly — collectGlobals runs this walk on every // initializer) and throws the cross-block merged-namespace fence's - // PoisonError out of collection entirely. The collect-phase guard - // suppresses both; the ordinary lowering that follows a null answer - // re-resolves with full effects at its own site. + // PoisonError out of collection entirely. The collect-phase guard also + // covers exact FFI ownership checks encountered while walking the chain; + // the ordinary lowering that follows a null answer re-resolves with full + // effects at its own site. const wasCollecting = L.collecting; L.collecting = true; try { + let root: ts.Expression = e; + for (;;) { + if ( + ts.isParenthesizedExpression(root) || + ts.isNonNullExpression(root) || + ts.isAsExpression(root) || + ts.isSatisfiesExpression(root) || + ts.isTypeAssertion(root) + ) { + root = root.expression; + continue; + } + if (ts.isPropertyAccessExpression(root) || ts.isElementAccessExpression(root)) { + root = root.expression; + continue; + } + if (ts.isCallExpression(root) || ts.isNewExpression(root)) { + if ( + ts.isCallExpression(root) && + ts.isIdentifier(root.expression) && + L.ffiImportsByName.has(root.expression.text) + ) { + const symbol = L.resolveValueSymbol(root.expression); + if (symbol && L.ownsValidatedFfiSymbol(root.expression.text, symbol)) { + // The manifest supplies this exact ambient declaration. Stop at + // the call boundary so normal lowering can emit the native call + // or its existing call-shape diagnostic. + return null; + } + } + root = root.expression; + continue; + } + if (ts.isExpressionWithTypeArguments(root)) { + root = root.expression; + continue; + } + if (ts.isTaggedTemplateExpression(root)) { + root = root.tag; + continue; + } + break; + } + if (!ts.isIdentifier(root)) return null; const sym = L.resolveValueSymbol(root); if (!sym) return null; if (L.trapBindings.has(sym)) return root; diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index 1d6202dfd..4b23619e8 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -4302,6 +4302,18 @@ function isEsModuleStamp(expr: ts.Expression): boolean { // Value-position `void` keeps the syntax fence (a standalone undefined // VALUE needs a union slot to live in). if (ts.isVoidExpression(expr)) return lowerExprStatement(L, expr.expression); + // Statement-position conditionals evaluate the condition, then exactly + // one arm for effect and drop that arm's value. Lower them as `if` + // statements so void-valued arms do not form invalid value ternaries. + if (ts.isConditionalExpression(expr)) { + return { + kind: "if", + cond: L.lowerCondition(expr.condition), + then: [lowerExprStatement(L, expr.whenTrue)], + else_: [lowerExprStatement(L, expr.whenFalse)], + loc: locOf(expr), + }; + } if (ts.isBinaryExpression(expr)) { const opKind = expr.operatorToken.kind; // Statement-position comma (`({} = a, [] = a);`, `i++, j++` in a diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 936d06f5c..c09924274 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -63,6 +63,7 @@ import { fallbackDtsPath, isCjsExportTableLiteral, isJsSourceFile, + isMidiTypesPath, isNodeEsmFile, isNodeTypesPath, locOf, @@ -101,6 +102,7 @@ import { builtinImportOf, createRequireBindingDecl, createRequireNamespaceDecl, import { fenceFetchObjectAssignment, fenceFetchObjectBinding, fenceStaticAbortControllerMemberRead, fenceStaticHeadersIteration, fenceStaticHeadersMember, fenceStaticReadableStreamMember, fenceStaticResponseMember, fenceUnsupportedFetchConstructorMember, isIslandExpr, islandFuncValueFence, islandRegexpOf, jsvalIn, requireDynamicApi, islandGlobalFnOf, lowerAbortControllerNew, lowerDynamicHeadersIteratorCall, lowerDynamicHeadersSpread, lowerDynamicImportCall, lowerFetchCall, lowerFetchElementMethodCall, lowerResponseNew, lowerStaticFetchCompanionCall, lowerStaticAbortControllerCall, lowerStaticAbortSignalListenerCall, lowerStaticReadableStreamCancelCall, lowerStaticReadableStreamControllerCall, lowerStaticReadableStreamNew, lowerStaticReadableStreamReaderCall, lowerStaticResponseCall, lowerIslandMethodCall, lowerMathProperty, npmPackageOf, npmMemberFence, npmPackageOfSymbol } from "./lower-island.js"; import { lowerHttpHeadersElement, lowerNetModuleCall, lowerServerMethodCall, lowerServerProperty, lowerTlsRootCertificates } from "./lower-server.js"; import { lowerDgramDnsModuleCall, lowerDgramMethodCall } from "./lower-dgram.js"; +import { lowerMidiModuleCall, lowerMidiMethodCall, lowerMidiNew } from "./lower-midi.js"; import { lowerNodeTestModuleCall, lowerTestDirectCall, lowerTestMethodCall, lowerTestCtxProperty } from "./lower-test.js"; import { lowerAssertModuleCall, lowerAssertDirectCall } from "./lower-assert.js"; import { lowerUtilModuleCall } from "./lower-inspect.js"; @@ -1587,6 +1589,15 @@ export class Lowerer { ? this.qualify(decl.getSourceFile(), `%cx${decl.getStart()}.${decl.name?.text ?? ""}`) : this.qualify(decl.getSourceFile(), nsPathPrefix(decl) + (decl.name ? decl.name.text : "%anon")); + /** Whether whole-program validation assigned this exact source symbol to + * the configured native binding. Name agreement alone never owns a call. */ + ownsValidatedFfiSymbol(name: string, symbol: ts.Symbol): boolean { + return ( + this.ffiImportsByName.has(name) && + this.ffiBindingSymbols?.get(name)?.has(symbol) === true + ); + } + /** Follows import aliases to the original declaration's symbol. Every * value reference resolves through here, so it doubles as the flush * point for deferred collection diagnostics: resolving a reference to a @@ -6650,6 +6661,17 @@ export class Lowerer { lowerReturnStmt(node: ts.Expression, loc: SrcLoc): IrStmt { const expected = this.ctx.returnType; if (expected.kind === "void") { + let expr = node; + while (ts.isParenthesizedExpression(expr)) expr = expr.expression; + if (ts.isConditionalExpression(expr)) { + return { + kind: "if", + cond: this.lowerCondition(expr.condition), + then: [this.lowerReturnStmt(expr.whenTrue, loc)], + else_: [this.lowerReturnStmt(expr.whenFalse, loc)], + loc, + }; + } let e = this.lowerExpr(node); if (this.ctx.isAsync && e.type.kind === "promise") { e = { kind: "awaitExpr", value: e, type: e.type.inner, loc: e.loc }; @@ -8030,7 +8052,7 @@ export class Lowerer { sf.fileName === this.overridesAmbient || sf.fileName === this.fallbackAmbient || this.program.isSourceFileDefaultLibrary(sf) || - (sf.isDeclarationFile && isNodeTypesPath(sf.fileName)); + (sf.isDeclarationFile && (isNodeTypesPath(sf.fileName) || isMidiTypesPath(sf.fileName))); nodeTypesOnlySymbol(sym: ts.Symbol | null | undefined): boolean { return nodeTypesOnlySymbol(this, sym); @@ -8384,7 +8406,7 @@ export class Lowerer { const arg = this.lowerExpr(expr.arguments[0]!); return { kind: "jsOp", op: "construct", args: [ctor, arg], type: JSVAL, loc }; } - return lowerAbortControllerNew(this, expr) ?? lowerResponseNew(this, expr) ?? lowerStaticReadableStreamNew(this, expr) ?? lowerNew(this, expr); + return lowerAbortControllerNew(this, expr) ?? lowerResponseNew(this, expr) ?? lowerStaticReadableStreamNew(this, expr) ?? lowerMidiNew(this, expr) ?? lowerNew(this, expr); } lowerFieldRead(expr: ts.PropertyAccessExpression): IrExpr | null { @@ -8603,6 +8625,11 @@ export class Lowerer { // shape is special-cased there, so it never rides the param tables. const dgramServed = this.lowerDgramDnsModuleCall(call, bi, locOf(access)); if (dgramServed) return dgramServed; + // The midi spoke owns node:midi for namespace members too — the module + // has no callable exports (ports are `new`-constructed), so this only + // ever fences a call on a midi binding module-qualified. + const midiServed = this.lowerMidiModuleCall(call, bi, locOf(access)); + if (midiServed) return midiServed; // The server-surface spoke owns net and http wholesale — the same // dispatch the named-import path takes (`net.createServer(...)` via // `import * as net` is portless's own spelling). @@ -8829,6 +8856,20 @@ export class Lowerer { return lowerDgramMethodCall(this, call, access); } + // The midi spoke (lower-midi.ts): the node:midi module call (fence-only — + // no callable exports) and the midiInput/midiOutput method surface. The + // Input/Output constructors ride the lowerNew chain (lowerMidiNew). + lowerMidiModuleCall(expr: ts.CallExpression, + bi: { module: string; member: string }, + loc: SrcLoc,): IrExpr | null { + return lowerMidiModuleCall(this, expr, bi, loc); + } + + lowerMidiMethodCall(call: ts.CallExpression, + access: ts.PropertyAccessExpression,): IrExpr | null { + return lowerMidiMethodCall(this, call, access); + } + // The node:test spoke (lower-test.ts): registrations, suites, hooks, // and the TestContext surface. lowerNodeTestModuleCall(expr: ts.CallExpression, diff --git a/packages/compiler/src/frontend/lowering/surfaces.ts b/packages/compiler/src/frontend/lowering/surfaces.ts index 39ac9fef3..e8ae42b29 100644 --- a/packages/compiler/src/frontend/lowering/surfaces.ts +++ b/packages/compiler/src/frontend/lowering/surfaces.ts @@ -523,6 +523,13 @@ export const ISLAND_SURFACE = { export const STATIC_MATH_FNS: Record = { floor: { fn: "math.floor", arity: 1 }, abs: { fn: "math.abs", arity: 1 }, + sin: { fn: "math.sin", arity: 1 }, + cos: { fn: "math.cos", arity: 1 }, + sqrt: { fn: "math.sqrt", arity: 1 }, + exp: { fn: "math.exp", arity: 1 }, + log: { fn: "math.log", arity: 1 }, + pow: { fn: "math.pow", arity: 2 }, + fround: { fn: "math.fround", arity: 1 }, round: { fn: "math.round", arity: 1 }, // trunc/ceil joined the static table with ask 4: they are the // integer-boundary inference's wholeness-discharge operators (C @@ -534,6 +541,13 @@ export const STATIC_MATH_FNS: Record> = { + PI: Math.PI, + E: Math.E, +}; + /** Number prototype methods with dedicated STATIC lowering paths. The * libCall spellings are also the compiled-graph witnesses used by library * fences, while the arity range is the surface manifest's support claim. */ @@ -805,6 +819,13 @@ export const BUILTIN_MODULE_FNS: Record (isAbsolute(p) ? p : join(base, p)); + const paths: Record = {}; + for (const [key, value] of Object.entries(rawPaths as Record)) { + if (Array.isArray(value)) { + paths[key] = value.filter((v): v is string => typeof v === "string").map(abs); + } + } + adopted["paths"] = paths; + } + const rawJsx = parsed.options["jsx"]; + if (typeof rawJsx === "number") adopted["jsx"] = rawJsx; + const rawJsxImportSource = parsed.options["jsxImportSource"]; + if (typeof rawJsxImportSource === "string") adopted["jsxImportSource"] = rawJsxImportSource; + // A project's own `lib` choice (e.g. `dom` for code that reuses browser + // component prop types via `import type`, even where the runtime path is + // dead for a given compile target) was previously unreachable: `lib` was + // FORCED to a narrow no-DOM default with no override. Adopting it here + // (BASE_OPTIONS still supplies that narrow default for projects that never + // set `lib`) lets a whole-program compile satisfy type-only-imported code + // outside its own reachable surface without every such project needing to + // avoid `import type` reuse across platform-specific implementations. + const rawLib = parsed.options["lib"]; + if (Array.isArray(rawLib)) { + const lib = rawLib.filter((v): v is string => typeof v === "string"); + if (lib.length > 0) adopted["lib"] = lib; + } const nullChecks = adopted["strictNullChecks"] ?? adopted["strict"] ?? false; if (nullChecks !== true) { diags.push(strictNullChecksFloorDiag(configFile)); @@ -321,6 +372,15 @@ function loadProgram7( externalTypes: ReadonlyMap = new Map(), ): LoadResult & { disposeAll: () => void } { const config = adoptProjectConfig7(host, entryPath); + // resolveProjectImport (resolve.ts) needs the same paths map handed to + // tsgo above — see setTsconfigPaths's doc comment. One program load, one + // registry write; a later load (a second entry point in the same + // process) overwrites it, matching how tsgo itself is reconfigured per + // program. + const configPaths = config.options["paths"]; + setTsconfigPaths( + configPaths && typeof configPaths === "object" ? (configPaths as Record) : null, + ); const nodeTypes = config.configFile ? resolveNodeTypes7(entryPath) : null; // skipLibCheck is FORCED with @types/node in the program: checking a // third-party lib's internals against OUR lib choice (es2025, no dyn) is @@ -1489,13 +1549,13 @@ function resolveImport7(program: ts.Program, from: ts.SourceFile, specifier: str /** An import that resolves into node_modules: the package's shipped .d.ts * is the type surface, and the package's shipped JS runs in the dynamic * island under --dynamic. Resolution rides the own resolver (resolve.ts). - * Null for relative and node: specifiers, and for anything that doesn't - * resolve into node_modules. */ + * Null for relative and supported builtin specifiers, and for anything + * that doesn't resolve into node_modules. */ function resolveNpmImport7( fromFileName: string, specifier: string, ): { packageName: string; version?: string; typesFile: string } | null { - if (isRelativeSpecifier(specifier) || specifier.startsWith("node:")) { + if (isRelativeSpecifier(specifier) || canonicalBuiltinModule(specifier) !== null) { return null; } // --provenance-sources: a registered specifier is NOT an npm import — @@ -2039,7 +2099,10 @@ function preflight7(load: LoadResult): { continue; } const isRelative = isRelativeSpecifier(spec); - const isBare = !isRelative && !ambientModules.has(spec); + const isBare = + !isRelative && + canonicalBuiltinModule(spec) === null && + !ambientModules.has(spec); // --npm-static: an opted-in package importing node:module admits // for PROGRAM code (per-member fences, divergence 370) but marks // the PACKAGE an offender — createRequire's static story covers @@ -2158,6 +2221,21 @@ function preflight7(load: LoadResult): { refuse(refusal.message, "%Error", ambientNote); continue; } + // A BARE side-effect import (`import "x";` — no default, named, or + // namespace binding: stmt.importClause is undefined) of a module + // that exists ONLY as an ambient 'declare module' type surface has + // no runtime module AND nothing bound from it for other code to + // reference — the two facts together make dropping the statement + // behaviorally exact, not an approximation. This is the standard + // shape of a bundler-only stylesheet import (`import + // "pkg/dist/style.css";`, ambient-declared via a `declare module + // "*.css"` surface): real CSS side effects don't exist in a + // compiled binary with no browser to apply them to, so there is + // nothing this program could have observed from the import + // succeeding that it can no longer observe. A bound import + // (`import styles from "x.css"`, `import { x } from "x"`) still + // fences below — something WOULD be missing. + if (stmt.importClause === undefined && ambientDeclared(spec)) continue; // Runtime-resolvable (or a shape the probe stays conservative // about) with no compilable types answer: scriptc's fence. diags.push( @@ -2700,6 +2778,7 @@ export { builtinDefaultImportModule, canonicalBuiltinModule, fallbackDtsPath, + isMidiTypesPath, isNodeTypesPath, npmPackageNameOf, overridesDtsPath, @@ -2804,7 +2883,7 @@ export function orderedImportsOf( * lowering paths share. */ export function npmStaticDepSf7(program: ts.Program, sf: ts.SourceFile, spec: string): ts.SourceFile | null { if (!npmStaticActive() || isRelativeSpecifier(spec)) return null; - if (spec.startsWith("node:") || spec.startsWith("#")) return null; + if (canonicalBuiltinModule(spec) !== null || spec.startsWith("#")) return null; const npm = resolveNpmImport7(sf.fileName, spec); if (npm === null || !isNpmStaticPackage(npm.packageName)) return null; if (!isJsSourceFileName(npm.typesFile)) return null; diff --git a/packages/compiler/src/frontend/resolve.ts b/packages/compiler/src/frontend/resolve.ts index 68958a953..1a5534caf 100644 --- a/packages/compiler/src/frontend/resolve.ts +++ b/packages/compiler/src/frontend/resolve.ts @@ -232,6 +232,55 @@ function loadAsDirectory(base: string): string | null { return null; } +/* tsconfig `paths` registry — populated once per program load (program.ts's + * adoptProjectConfig7, which already parses the real tsconfig for the + * checker) with the SAME absolutized map handed to tsgo. tsgo resolves + * `paths` natively; this module's own resolveProjectImport only understands + * package.json self-name "exports" and the "#alias" imports field, so an + * alias with no package.json counterpart at all (a project's `@/*` pointing + * at its own src tree, distinct from its package name) previously had no + * project-internal resolver to answer it — SC1010 "package not installed" + * even though the checker resolved the same specifier fine. Values are + * already absolute (the same targets tsgo's synthesized tsconfig uses), so + * candidates need only the ordinary bundler extension-substitution pass. */ +let tsconfigPaths: Record | null = null; + +export function setTsconfigPaths(paths: Record | null): void { + tsconfigPaths = paths; +} + +/** Longest-prefix `paths` match, mirroring package.json "exports" pattern + * precedence (resolveExportsTypes below) rather than tsconfig's declared + * first-match-wins order — the two are equivalent for well-formed configs + * (a project should never declare two `paths` keys where a shorter one is + * also a prefix of the specifier and a real ambiguity would result), and + * longest-prefix avoids depending on object key enumeration order. */ +function resolveViaTsconfigPaths(specifier: string): string | null { + if (tsconfigPaths === null) return null; + let best: { targets: string[]; prefix: string; suffix: string } | null = null; + for (const [key, targets] of Object.entries(tsconfigPaths)) { + const star = key.indexOf("*"); + const prefix = star < 0 ? key : key.slice(0, star); + const suffix = star < 0 ? "" : key.slice(star + 1); + if ( + specifier.startsWith(prefix) && + specifier.length >= prefix.length + suffix.length && + specifier.endsWith(suffix) && + (best === null || prefix.length > best.prefix.length) + ) { + best = { targets, prefix, suffix }; + } + } + if (best === null) return null; + const wildcard = specifier.slice(best.prefix.length, specifier.length - best.suffix.length); + for (const target of best.targets) { + const path = target.includes("*") ? target.split("*").join(wildcard) : target; + const answer = loadAsFile(path) ?? loadAsDirectory(path) ?? (isFile(path) ? path : null); + if (answer !== null) return answer; + } + return null; +} + /** The RUNTIME sibling of a PROJECT declaration twin — "src/index.js" for * "src/index.d.ts" when both exist OUTSIDE node_modules — or null. Node * loads the JS (declaration files do not exist in its world), and a @@ -504,6 +553,15 @@ export function nearestInvalidPackageJsonPath(fromFile: string): string | null { * Relative specifiers, real node_modules packages, and builtins are other * resolvers' business; callers try those first. */ export function resolveProjectImport(fromFile: string, specifier: string): string | null { + const answer = resolveProjectImportViaPackageJson(fromFile, specifier); + // tsconfig `paths` fallback: an alias with no package.json counterpart at + // all (see resolveViaTsconfigPaths above) — never consulted for "#alias" + // specifiers, which are exclusively package.json's own imports-field + // business and must not silently pick up an unrelated `paths` entry. + return answer ?? (specifier.startsWith("#") ? null : resolveViaTsconfigPaths(specifier)); +} + +function resolveProjectImportViaPackageJson(fromFile: string, specifier: string): string | null { // --provenance-sources (flag-gated; the registry is empty otherwise): a // registered bare specifier answers its attested SOURCE entry — the one // chokepoint that makes preflight's user-module edges, the module @@ -542,7 +600,14 @@ export function resolveProjectImport(fromFile: string, specifier: string): strin } if (target === null) return null; const path = join(pkgDir, target); - return loadAsFile(path) ?? (isFile(path) ? path : null); + // Mirrors resolveRelativeModule below: a package.json "exports"/"imports" + // target can itself be a DIRECTORY (a wildcard subpath landing on + // "./src/foo", answered by "./src/foo/index.ts") — this resolver was + // missing that fallback entirely, unlike every other resolver in this + // module, so a self-name specifier landing on a directory answered null + // (SC1010 "package not installed") even though the exact same directory + // resolves fine as a relative import one character away. + return loadAsFile(path) ?? loadAsDirectory(path) ?? (isFile(path) ? path : null); } /* 5.9.3 with allowJs resolves node_modules in TWO FULL PASSES (probed): the diff --git a/packages/compiler/src/frontend/shared.ts b/packages/compiler/src/frontend/shared.ts index 8bd5deea3..f9d287d75 100644 --- a/packages/compiler/src/frontend/shared.ts +++ b/packages/compiler/src/frontend/shared.ts @@ -51,13 +51,21 @@ export function isNodeTypesPath(file: string): boolean { return pkg === "@types/node" || pkg === "undici-types"; } +/** True for the declaration surface shipped by the Node-compatible MIDI + * package. ScriptC lowers this package's Input/Output handles natively, so + * its declarations are trusted surface types rather than dynamic-island + * package values. */ +export function isMidiTypesPath(file: string): boolean { + return npmPackageNameOf(file) === "@julusian/midi"; +} + /** The node builtin modules with scriptc lowerings, by CANONICAL (bare) * name — every module answers to both specifier forms ("fs" and "node:fs" * are the same module, like in Node). When the fallback declarations ship, * this is exactly the set of `declare module` names in that file; when * @types/node stands in (which declares ALL node builtins) the supported * surface must not widen, so preflight allowlists this same fixed set. */ -export const SUPPORTED_BUILTIN_MODULES = ["fs", "path", "path/posix", "path/win32", "os", "url", "fs/promises", "crypto", "zlib", "child_process", "net", "http", "tls", "https", "dgram", "dns", "util", "util/types", "string_decoder", "querystring", "readline", "http2", "assert", "assert/strict", "worker_threads", "buffer", "cluster", "tty", "async_hooks", "events", "stream", "stream/promises", "stream/consumers", "test", "timers", "timers/promises", "diagnostics_channel", "perf_hooks", "module"] as const; +export const SUPPORTED_BUILTIN_MODULES = ["fs", "path", "path/posix", "path/win32", "os", "url", "fs/promises", "crypto", "zlib", "child_process", "net", "http", "tls", "https", "dgram", "dns", "midi", "util", "util/types", "string_decoder", "querystring", "readline", "http2", "assert", "assert/strict", "worker_threads", "buffer", "cluster", "tty", "async_hooks", "events", "stream", "stream/promises", "stream/consumers", "test", "timers", "timers/promises", "diagnostics_channel", "perf_hooks", "module"] as const; /** Builtins Node itself serves ONLY under the node: prefix — * require("test") is MODULE_NOT_FOUND in Node, so the bare name stays a diff --git a/packages/compiler/src/frontend/ts7/enums.ts b/packages/compiler/src/frontend/ts7/enums.ts index 238a65af2..018abe8d6 100644 --- a/packages/compiler/src/frontend/ts7/enums.ts +++ b/packages/compiler/src/frontend/ts7/enums.ts @@ -87,6 +87,27 @@ export const ModuleDetectionKind: ModuleDetectionKindEnum = loadHiddenEnum("moduleDetectionKind", "ModuleDetectionKind"); export type ModuleDetectionKind = number; +/* JsxEmit isn't re-exported from unstable/ast or unstable/sync either — same + * hidden dist/enums placement as ModuleResolutionKind/ModuleDetectionKind + * above. Worth calling out by name: 7.0.2 renumbers React/ReactNative + * relative to 5.9.3 (None=0 Preserve=1 ReactNative=2 React=3 ReactJSX=4 + * ReactJSXDev=5, vs 5.9.3's React=2/ReactNative=3) — exactly the silent-lie + * risk this file's own top comment warns about, so this goes through the + * same symbolic reverse-mapping as everything else here rather than a + * hardcoded positional table. */ +interface JsxEmitEnum { + readonly None: number; + readonly Preserve: number; + readonly React: number; + readonly ReactNative: number; + readonly ReactJSX: number; + readonly ReactJSXDev: number; + readonly [key: string | number]: string | number; +} + +export const JsxEmit: JsxEmitEnum = loadHiddenEnum("jsxEmit", "JsxEmit"); +export type JsxEmit = number; + /** Reverse-maps a numeric enum value to its TS7 key name ("ESNext", * "Bundler") — the spelling tsgo's tsconfig JSON parser accepts (lowercased * by the caller where needed). Symbolic by construction: the name comes from diff --git a/packages/compiler/src/frontend/ts7/program.ts b/packages/compiler/src/frontend/ts7/program.ts index f319016f9..8238f6b50 100644 --- a/packages/compiler/src/frontend/ts7/program.ts +++ b/packages/compiler/src/frontend/ts7/program.ts @@ -26,7 +26,7 @@ import type { } from "typescript/unstable/sync"; import type { SourceFile } from "typescript/unstable/ast"; import { CheckerFacade } from "./checker.js"; -import { enumKeyOf, ModuleDetectionKind, ModuleKind, ModuleResolutionKind, ScriptTarget } from "./enums.js"; +import { enumKeyOf, JsxEmit, ModuleDetectionKind, ModuleKind, ModuleResolutionKind, ScriptTarget } from "./enums.js"; import { tsgoPath } from "../shared.js"; import { trackedAccessibleEntries, trackedDirectoryExists, trackedFileExists, trackedReadFile, trackedRealpath } from "../input-tracker.js"; @@ -71,6 +71,29 @@ function serializeOptions(options: Ts7CompilerOptions): Record lib.startsWith("lib.") && lib.endsWith(".d.ts") ? lib.slice(4, -5) : lib, ); break; + // Same reverse-mapping need as target/module/moduleResolution/ + // moduleDetection above — JsxEmit is one more hidden enum (enums.ts), + // reverse-mapped the same symbolic way rather than a hardcoded + // positional table (7.0.2 renumbers React/ReactNative relative to + // 5.9.3; see enums.ts's JsxEmit comment). Unlike the other enums here, + // JsxEmit's tsconfig spelling isn't a plain lowercase of its key + // (ReactNative -> "react-native", ReactJSX -> "react-jsx", ReactJSXDev + // -> "react-jsxdev") — the enumKeyOf lookup still comes from the + // enum's own symbolic reverse mapping (never a hardcoded number), only + // the KEY-NAME-TO-SPELLING step below is a fixed table. + case "jsx": { + const jsxKey = enumKeyOf(JsxEmit as never, value as number); + const jsxSpelling: Record = { + None: "none", + Preserve: "preserve", + React: "react", + ReactNative: "react-native", + ReactJSX: "react-jsx", + ReactJSXDev: "react-jsxdev", + }; + out[key] = (jsxKey && jsxSpelling[jsxKey]) ?? value; + break; + } default: { if (typeof value === "number" && key !== "maxNodeModuleJsDepth") { throw new Error(`ts7 createProgram: unhandled enum-valued compiler option '${key}'`); diff --git a/packages/compiler/src/frontend/types.ts b/packages/compiler/src/frontend/types.ts index 6a32fafb0..3a74a3a32 100644 --- a/packages/compiler/src/frontend/types.ts +++ b/packages/compiler/src/frontend/types.ts @@ -2,7 +2,7 @@ import * as ts from "./ts7/adapter.js"; import type { IrRecordShape, IrType, IrUnionDef } from "../ir/nodes.js"; import { arrayOf, BOOL, bytesOf, canConvertToDyn, CHILD_T, DATE_T, DYN, F64, funcOf, isSupportedArrayElem, isSupportedIndexValue, isSupportedMapKey, isSupportedMapValue, isSupportedSetElem, isUnitType, JSVAL, mapOf, NULL_T, PROCSTREAM_T, RUNTIME_EMITTER_CLASS, RUNTIME_ERROR_CLASSES, RUNTIME_STREAM_CLASSES, setOf, STRING, SYMBOL_T, typeEquals, typeKey, UNDEFINED_T, VOID } from "../ir/nodes.js"; -import { isJsSourceFile, isNodeTypesPath } from "./program.js"; +import { isJsSourceFile, isMidiTypesPath, isNodeTypesPath } from "./program.js"; import { accessorSlotProp } from "../ir/nodes.js"; // typeKey moved to ir/nodes.ts (the backend needs it too, for per-type // helper interning); re-exported here so frontend call sites keep their @@ -26,6 +26,7 @@ export const ISLAND_AMBIENT_TYPES = [ "AbortController", "AbortSignal", "Headers", + "HeadersInit", "ReadableStream", "ReadableStreamDefaultReader", "ReadableStreamDefaultController", @@ -514,6 +515,10 @@ export function formatIrType(t: IrType, shapes: ShapeRegistry, unions: UnionRegi return "Http2Stream"; case "dgramSocket": return "dgram.Socket"; + case "midiInput": + return "midi.Input"; + case "midiOutput": + return "midi.Output"; case "testCtx": return "TestContext"; case "httpReq": @@ -1210,7 +1215,7 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { // no class identity of its own. if (widened.isIntersectionType()) { const HANDLE_KINDS = new Set([ - "netServer", "netSocket", "httpReq", "httpRes", "httpClientReq", "dgramSocket", + "netServer", "netSocket", "httpReq", "httpRes", "httpClientReq", "dgramSocket", "midiInput", "midiOutput", // process.stdout's own type IS the refined intersection // `WriteStream & { fd: 1 }` — the scalar stream kind rides the same // refinement rule. @@ -1931,6 +1936,32 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { ) { return { kind: "dgramSocket" }; } + // midi.Input / midi.Output: the node-midi port classes, disambiguated by + // their fallback ambient module or by @julusian/midi's declaration path. + // The names are generic enough to collide with user classes, so this + // provenance guard is load-bearing. + if ( + psym?.name === "Input" && + checker.declarationsOf(psym).some( + (d) => + (ts.isInterfaceDeclaration(d) || ts.isClassDeclaration(d)) && + ctx.isStdlibFile(d.getSourceFile()) && + (isDeclaredInAmbientModule(d, "midi") || isMidiTypesPath(d.getSourceFile().fileName)), + ) + ) { + return { kind: "midiInput" }; + } + if ( + psym?.name === "Output" && + checker.declarationsOf(psym).some( + (d) => + (ts.isInterfaceDeclaration(d) || ts.isClassDeclaration(d)) && + ctx.isStdlibFile(d.getSourceFile()) && + (isDeclaredInAmbientModule(d, "midi") || isMidiTypesPath(d.getSourceFile().fileName)), + ) + ) { + return { kind: "midiOutput" }; + } // node:test's TestContext — the test-body parameter (`test('x', (t) => // ...)`). @types/node's `class TestContext` and the fallback // declarations' interface both live inside `declare module "node:test"` diff --git a/packages/compiler/src/index.ts b/packages/compiler/src/index.ts index 528739208..7591b28f0 100644 --- a/packages/compiler/src/index.ts +++ b/packages/compiler/src/index.ts @@ -24,7 +24,7 @@ import { import { validateSidecar } from "./library/sidecar-validate.js"; import { entryFunctionExports, type EntryExportInfo } from "./frontend/lib-exports.js"; import { entryContractFacts, type ContractFacts } from "./frontend/lib-contract.js"; -import { moduleLibAsyncSurface, moduleLibNondeterministicSurface, moduleEmbedsBuiltin, moduleEmbedsCompressedNpm, moduleUsesAssert, moduleUsesCopying, moduleUsesDc, moduleUsesDgram, moduleUsesDynAsync, moduleUsesDynInvoke, moduleUsesEmitter, moduleUsesFetch, moduleUsesFileHandle, moduleUsesFsWatch, moduleUsesHttp2, moduleUsesHttpServer, moduleUsesInspect, moduleUsesLegacyTextDecoder, moduleUsesNet, moduleUsesNodeTest, moduleUsesParseArgs, moduleUsesProcessEvents, moduleUsesQs, moduleUsesRegex, moduleUsesSearchParams, moduleUsesStream, moduleUsesSymbol, moduleUsesTls, moduleUsesTlsCa, moduleUsesZlib, type IrFfiImport, type IrLibSection, type IrModule, type IrRecordShape, type IrType, type SrcLoc } from "./ir/nodes.js"; +import { moduleLibAsyncSurface, moduleLibNondeterministicSurface, moduleEmbedsBuiltin, moduleEmbedsCompressedNpm, moduleUsesAssert, moduleUsesCopying, moduleUsesDc, moduleUsesDgram, moduleUsesDynAsync, moduleUsesDynInvoke, moduleUsesEmitter, moduleUsesFetch, moduleUsesFileHandle, moduleUsesFsWatch, moduleUsesHttp2, moduleUsesHttpServer, moduleUsesInspect, moduleUsesLegacyTextDecoder, moduleUsesMidi, moduleUsesNet, moduleUsesNodeTest, moduleUsesParseArgs, moduleUsesProcessEvents, moduleUsesQs, moduleUsesRegex, moduleUsesSearchParams, moduleUsesStream, moduleUsesSymbol, moduleUsesTls, moduleUsesTlsCa, moduleUsesZlib, type IrFfiImport, type IrLibSection, type IrModule, type IrRecordShape, type IrType, type SrcLoc } from "./ir/nodes.js"; import { serializeModule } from "./ir/serialize.js"; import { validateModule } from "./ir/validate.js"; import { canonicalBuiltinModule, checkPreflight, isNodeTypesPath, loadProgram, locOf, requiresOf, resolveNpmImport, type LoadResult } from "./frontend/program.js"; @@ -250,6 +250,7 @@ function moduleWasiUnavailableSurface(mod: IrModule): { surface: string; loc: Sr ["h2.", "network sockets (WASI Preview 1 has no socket API)"], ["dgram.", "network sockets (WASI Preview 1 has no socket API)"], ["dns.", "network sockets (WASI Preview 1 has no socket API)"], + ["midi.", "MIDI devices (WASI Preview 1 has no MIDI API)"], ["tls.", "network sockets (WASI Preview 1 has no socket API)"], ["fetch.", "network-backed fetch (WASI Preview 1 has no socket API)"], ["fs.watch", "filesystem watching (WASI Preview 1 has no notification API)"], @@ -264,6 +265,8 @@ function moduleWasiUnavailableSurface(mod: IrModule): { surface: string; loc: Sr ["http2Session", "network sockets (WASI Preview 1 has no socket API)"], ["http2Stream", "network sockets (WASI Preview 1 has no socket API)"], ["dgramSocket", "network sockets (WASI Preview 1 has no socket API)"], + ["midiInput", "MIDI devices (WASI Preview 1 has no MIDI API)"], + ["midiOutput", "MIDI devices (WASI Preview 1 has no MIDI API)"], ["fsWatcher", "filesystem watching (WASI Preview 1 has no notification API)"], ["httpReq", "network sockets (WASI Preview 1 has no socket API)"], ["httpRes", "network sockets (WASI Preview 1 has no socket API)"], @@ -452,11 +455,10 @@ function detectAutoPackages( } for (const { spec, loc } of edges) { if (isRelativeSpecifier(spec) || spec.startsWith("node:") || spec.startsWith("#")) continue; - // Bare builtin names ("fs", "path") are the builtin machinery's - // business (and the SC4005 async_free gate's, in library mode) — - // never npm candidates. Auto keeps its original path (the - // @types/node answer skips them below), byte-for-byte. - if (mode === "lib" && canonicalBuiltinModule(spec) !== null) continue; + // Bare builtin names ("fs", "path", and the Node-compatible "midi" + // package surface) are the builtin machinery's business — never npm + // candidates, even when a package supplies the declarations. + if (canonicalBuiltinModule(spec) !== null) continue; const npm = resolveNpmImport(sf.fileName, spec); if (npm !== null && isNodeTypesPath(npm.typesFile)) continue; if (npm === null) { @@ -894,6 +896,7 @@ function executableNativeFeatures( http: moduleUsesHttpServer(mod), http2: moduleUsesHttp2(mod), dgram: moduleUsesDgram(mod), + midi: moduleUsesMidi(mod), watch: moduleUsesFsWatch(mod), foreignFfi: hasForeignFfiCallback(mod.ffiImports ?? []), nodeTest: moduleUsesNodeTest(mod), @@ -952,6 +955,7 @@ async function compileExecutableNative( http: features.http, http2: features.http2, dgram: features.dgram, + midi: features.midi, watch: features.watch, foreignFfi: features.foreignFfi, nodeTest: features.nodeTest, diff --git a/packages/compiler/src/ir/nodes.ts b/packages/compiler/src/ir/nodes.ts index 784804e37..f35abfc1b 100644 --- a/packages/compiler/src/ir/nodes.ts +++ b/packages/compiler/src/ir/nodes.ts @@ -157,6 +157,20 @@ export type IrType = * lean allocation, no trace header. Same container rules: union arms * fine, arrays/maps/JSON fenced. */ | { kind: "dgramSocket" } + /** A node:midi input port handle (scr_midi.c — linked only when the IR + * uses the midi surface, the moduleUsesMidi switch). Heap, refcounted, + * MUTABLE like dgramSocket: the loop's midi hook delivers time-stamped + * messages and fires its listeners. An OPEN input is a live source that + * holds the loop alive (the bound-socket story); listeners are held only + * until the handle settles (closePort, or the exit-time cleanup) — the + * dgramSocket ownership story, so lean allocation, no trace header. Same + * container rules: union arms fine, arrays/maps/JSON fenced. */ + | { kind: "midiInput" } + /** A node:midi output port handle (scr_midi.c — same unit as midiInput). + * Heap, refcounted like midiInput, but an output NEVER holds the loop + * alive (sendMessage is fire-and-forget, like a connected dgram send). + * No listeners — lean, no trace header. */ + | { kind: "midiOutput" } /** A node:test TestContext handle (scr_test.c — linked only when the * IR uses the node:test surface). Heap, refcounted, no cycles (the * runner tree owns the children; the parent edge is a borrowed @@ -320,7 +334,7 @@ export const REF_TRUTHY_KINDS: ReadonlySet = new Set([ // constant-true answer. "symbol", "date", "array", "map", "set", "regex", "url", "searchParams", "stats", "fileHandle", "spawnRes", "child", - "netServer", "netSocket", "http2Session", "http2Stream", "dgramSocket", "testCtx", "httpReq", "httpRes", "httpClientReq", + "netServer", "netSocket", "http2Session", "http2Stream", "dgramSocket", "midiInput", "midiOutput", "testCtx", "httpReq", "httpRes", "httpClientReq", "secureCtx", "fsWatcher", "childStream", "procStream", "bytes", "func", "object", "record", "promise", // A generator object is a JS object: always truthy. "generator", @@ -346,6 +360,8 @@ export const NETSOCKET_T: IrType = { kind: "netSocket" }; export const HTTP2SESSION_T: IrType = { kind: "http2Session" }; export const HTTP2STREAM_T: IrType = { kind: "http2Stream" }; export const DGRAMSOCK_T: IrType = { kind: "dgramSocket" }; +export const MIDIIN_T: IrType = { kind: "midiInput" }; +export const MIDIOUT_T: IrType = { kind: "midiOutput" }; export const TESTCTX_T: IrType = { kind: "testCtx" }; export const HTTPREQ_T: IrType = { kind: "httpReq" }; export const HTTPRES_T: IrType = { kind: "httpRes" }; @@ -558,6 +574,8 @@ export function typeKey(t: IrType): string { case "http2Session": case "http2Stream": case "dgramSocket": + case "midiInput": + case "midiOutput": case "testCtx": case "httpReq": case "httpRes": @@ -677,6 +695,10 @@ export function isRefCounted(t: IrType): boolean { t.kind === "http2Session" || t.kind === "http2Stream" || t.kind === "dgramSocket" || + // midi input/output handles are refcounted like dgramSocket (listeners + // drop at closePort, so lean allocation — see the IrType comment). + t.kind === "midiInput" || + t.kind === "midiOutput" || // TestContext handles are refcounted like dgramSocket (the runner // tree owns children; no cycles through the handle). t.kind === "testCtx" || @@ -2022,6 +2044,13 @@ export type IrLibFn = * round() is half-away-from-zero and floor(x+0.5) drifts at the * epsilon boundary). Borrow nothing; never throw. */ | "math.abs" + | "math.sin" + | "math.cos" + | "math.sqrt" + | "math.exp" + | "math.log" + | "math.pow" + | "math.fround" | "math.round" /** Math.trunc / Math.ceil — C trunc()/ceil() ARE the JS operations * (NaN/±0/±Infinity pass through bit-exactly; ceil(-0.5) is -0 in IEEE @@ -2469,6 +2498,26 @@ export type IrLibFn = | "dgram.onClose" | "dgram.onConnect" | "dns.lookup" + /** node:midi (scr_midi.c + the loop's midi hook — linked only when one + * of these appears on the IR; moduleUsesMidi is the switch). Input and + * Output handles construct through new*; the port surface enumerates, + * opens (real or virtual), and closes; sendArray/sendBytes marshal a + * number[] or Uint8Array to the wire; onMessage MOVES its callback into + * the input's registry and fires it (deltaTime, number[]) on the loop + * thread through the per-arity adapter (scr_midi_msg_thunk0/1/2). Opens + * and sends may-throw (bad index, closed port, no backend). */ + | "midi.newInput" + | "midi.newOutput" + | "midi.portCount" + | "midi.portName" + | "midi.openPort" + | "midi.openVirtual" + | "midi.closePort" + | "midi.isOpen" + | "midi.ignoreTypes" + | "midi.sendArray" + | "midi.sendBytes" + | "midi.onMessage" /** node:test (scr_test.c — linked only when one of these appears on * the IR; moduleUsesNodeTest is the switch, and the main epilogue asks * scr_test_exit_code() for the process's exit status). Strings are @@ -5407,6 +5456,8 @@ function isJsonSafeAt( case "http2Session": case "http2Stream": case "dgramSocket": + case "midiInput": + case "midiOutput": case "testCtx": case "httpReq": case "httpRes": @@ -6272,6 +6323,14 @@ export function moduleUsesDynAsync(mod: IrModule): boolean { found = true; return; } + // Promise values crossing the checked-dynamic boundary are boxed by + // emit-walkers through scr_dyn_new_promise_adapting(). That constructor + // lives in scr_async_dyn.c, so promise-typed IR must pull that TU in even + // when the program never awaits a dyn value. + if (node.type !== undefined && node.type.kind === "promise") { + found = true; + return; + } for (const key of Object.keys(v)) visit((v as Record)[key]); }; visit(mod); @@ -6542,6 +6601,37 @@ export function moduleUsesDgram(mod: IrModule): boolean { return found; } +/** True when the module contains any midi.* libCall — the link switch + * that pulls scr_midi.c into the binary and has the emitted main call the + * midi install/dispatch hook (cc.ts + emitter; the moduleUsesDgram shape, + * with the ALSA/CoreMIDI/WinMM link flags gated on the same answer). + * midi-free programs pay zero bytes and keep their exact link line. Same + * generic-walk shape as moduleUsesDgram. */ +export function moduleUsesMidi(mod: IrModule): boolean { + let found = false; + const visit = (v: unknown): void => { + if (found || v === null || typeof v !== "object") return; + if (Array.isArray(v)) { + for (const item of v) visit(item); + return; + } + const node = v as { kind?: unknown; fn?: unknown }; + if (node.kind === "libCall" && typeof node.fn === "string" && node.fn.startsWith("midi.")) { + found = true; + return; + } + // A midi HANDLE TYPE left behind by a fenced statement still emits a + // release call — the unit must link (the moduleUsesDgram type story). + if (node.kind === "midiInput" || node.kind === "midiOutput") { + found = true; + return; + } + for (const key of Object.keys(v)) visit((v as Record)[key]); + }; + visit(mod); + return found; +} + /** True when the module contains any http.* libCall — the link switch * that pulls scr_http.c into the binary (cc.ts; moduleUsesNet already * answers true for these, so scr_net.c comes along). */ @@ -6740,6 +6830,8 @@ const LIB_MODE_REFUSED_KINDS: ReadonlyMap = new Map([ ["http2Session", "the node:http2 surface"], ["http2Stream", "the node:http2 surface"], ["dgramSocket", "the node:dgram surface"], + ["midiInput", "the node:midi surface"], + ["midiOutput", "the node:midi surface"], ["fsWatcher", "fs.watch"], ["testCtx", "the node:test surface"], ["httpReq", "the node:http surface"], @@ -6801,6 +6893,7 @@ export function moduleLibAsyncSurface(mod: IrModule): { surface: string; loc: Sr [moduleUsesHttpServer(mod), "the node:http surface"], [moduleUsesHttp2(mod), "the node:http2 surface"], [moduleUsesDgram(mod), "the node:dgram surface"], + [moduleUsesMidi(mod), "the node:midi surface"], [moduleUsesFsWatch(mod), "fs.watch"], [moduleUsesStream(mod), "the node:stream surface"], [moduleUsesTls(mod), "the node:tls surface"], @@ -7233,6 +7326,16 @@ export const MAY_THROW_LIB_FNS: ReadonlySet = new Set([ "dgram.address", "dgram.close", "dgram.closeCb", + // node:midi synchronous throws: allocation failure on construct, a bad + // port index or absent backend on open, a virtual port where the platform + // has none (WinMM), and send on a closed output. + "midi.newInput", + "midi.newOutput", + "midi.portName", + "midi.openPort", + "midi.openVirtual", + "midi.sendArray", + "midi.sendBytes", // The assert surface: every entry point except sameValue, bytesDeepEq, // and the shape accumulator's begin/slot/test calls throws the // catchable AssertionError on failure. diff --git a/packages/compiler/src/ir/validate.ts b/packages/compiler/src/ir/validate.ts index 546de47fe..dbb55a054 100644 --- a/packages/compiler/src/ir/validate.ts +++ b/packages/compiler/src/ir/validate.ts @@ -18,7 +18,7 @@ import type { IrUnionDef, SrcLoc, } from "./nodes.js"; -import { arrayOf, BOOL, BYTES_U8, bytesOf, canAdaptDynFuncTo, canConvertToDyn, canExitIslandToType, canMarshalIntoIsland, canMarshalTypedFuncIntoIsland, CHILD_T, CHILDSTREAM_T, DATE_T, DGRAMSOCK_T, DYN, DYN_HANDLE_KINDS, F64, ffiClassType, ffiSourceParamTypes, FILEHANDLE_T, FSWATCHER_T, HTTP2SESSION_T, HTTP2STREAM_T, HTTPCLIENTREQ_T, HTTPREQ_T, HTTPRES_T, islandPromisePayloadTag, isFfiCallbackParam, isFfiContextParam, isFfiReleaseParam, isJsonSafeType, isRefCounted, isSupportedArrayElem, isSupportedIndexValue, isSupportedMapKey, isSupportedMapValue, isSupportedSetElem, isUnitType, jsOpResultKind, JSVAL, NETSERVER_T, NETSOCKET_T, PROCSTREAM_T, REF_TRUTHY_KINDS, REGEX, RUNTIME_EMITTER_CLASS, RUNTIME_ERROR_CLASSES, RUNTIME_STREAM_CLASSES, SEARCH_PARAMS_T, SECURECTX_T, shapeHasAccessorSlots, SPAWNRES_T, STATS_T, STRING, SYMBOL_T, TESTCTX_T, typeEquals, typeKey, unionFuncSetArmsOk, URL_T, VOID } from "./nodes.js"; +import { arrayOf, BOOL, BYTES_U8, bytesOf, canAdaptDynFuncTo, canConvertToDyn, canExitIslandToType, canMarshalIntoIsland, canMarshalTypedFuncIntoIsland, CHILD_T, CHILDSTREAM_T, DATE_T, DGRAMSOCK_T, DYN, DYN_HANDLE_KINDS, F64, ffiClassType, ffiSourceParamTypes, FILEHANDLE_T, FSWATCHER_T, HTTP2SESSION_T, HTTP2STREAM_T, HTTPCLIENTREQ_T, HTTPREQ_T, HTTPRES_T, islandPromisePayloadTag, isFfiCallbackParam, isFfiContextParam, isFfiReleaseParam, isJsonSafeType, isRefCounted, isSupportedArrayElem, isSupportedIndexValue, isSupportedMapKey, isSupportedMapValue, isSupportedSetElem, isUnitType, jsOpResultKind, JSVAL, MIDIIN_T, MIDIOUT_T, NETSERVER_T, NETSOCKET_T, PROCSTREAM_T, REF_TRUTHY_KINDS, REGEX, RUNTIME_EMITTER_CLASS, RUNTIME_ERROR_CLASSES, RUNTIME_STREAM_CLASSES, SEARCH_PARAMS_T, SECURECTX_T, shapeHasAccessorSlots, SPAWNRES_T, STATS_T, STRING, SYMBOL_T, TESTCTX_T, typeEquals, typeKey, unionFuncSetArmsOk, URL_T, VOID } from "./nodes.js"; /** Per-method signature for strIntrinsic: `argTypes` lists every argument * position (optional ones included); `minArgs` is how many may be omitted @@ -210,6 +210,13 @@ export const LIB_FN_SIGS: Record result — the spoke pinned the // shape): null slots. sub's result is the settled Promise the diff --git a/packages/compiler/src/library/int-infer.test.ts b/packages/compiler/src/library/int-infer.test.ts index 6e258c435..6f88a112b 100644 --- a/packages/compiler/src/library/int-infer.test.ts +++ b/packages/compiler/src/library/int-infer.test.ts @@ -49,6 +49,7 @@ const send = (value: IrExpr, callee = "send"): IrStmt => ({ const decl = (localId: string, init: IrExpr): IrStmt => ({ kind: "varDecl", localId, init, loc }); const assign = (localId: string, value: IrExpr): IrStmt => ({ kind: "assign", localId, value, loc }); const iff = (cond: IrExpr, then: IrStmt[]): IrStmt => ({ kind: "if", cond, then, else_: null, loc }); +const ret = (): IrStmt => ({ kind: "return", value: null, loc }); const forLoop = (init: IrStmt, cond: IrExpr, update: IrStmt, body: IrStmt[]): IrStmt => ({ kind: "for", init, cond, update, body, loc, }); @@ -588,6 +589,71 @@ describe("the domain's edges beyond the corpus", () => { expect(v.obligation).toBe("wholeness"); expect(v.detail).toContain("NaN"); }); + + test("the failed edge of an ordered comparison keeps NaN alive (guard clauses)", () => { + // if (a < 0) return; if (a > 100) return; send(Math.trunc(a)) — NaN + // fails BOTH guards (NaN < 0 and NaN > 100 are false), reaches the + // slot, and Math.trunc(NaN) is NaN: ¬(a < b) must not clear maybeNaN. + const v = only( + caseModule(["a"], [], [ + iff(bin("<", ref("a.0"), num(0)), [ret()]), + iff(bin(">", ref("a.0"), num(100)), [ret()]), + send(math("trunc", ref("a.0"))), + ]), + ); + expect(v.outcome).toBe("refuse"); + expect(v.obligation).toBe("wholeness"); + expect(v.detail).toContain("NaN"); + }); + + test("the else spelling of the failed edge keeps NaN alive too", () => { + const inner: IrStmt = { + kind: "if", + cond: bin(">", ref("a.0"), num(100)), + then: [], + else_: [send(math("trunc", ref("a.0")))], + loc, + }; + const v = only( + caseModule(["a"], [], [ + { kind: "if", cond: bin("<", ref("a.0"), num(0)), then: [], else_: [inner], loc }, + ]), + ); + expect(v.outcome).toBe("refuse"); + expect(v.obligation).toBe("wholeness"); + expect(v.detail).toContain("NaN"); + }); + + test("a u64 slot behind failed-edge guards refuses instead of fabricating [0, 100]", () => { + const v = only( + caseModule(["a"], [], [ + iff(bin("<", ref("a.0"), num(0)), [ret()]), + iff(bin(">", ref("a.0"), num(100)), [ret()]), + send(math("trunc", ref("a.0")), "sendU64"), + ]), + ); + expect(v.outcome).toBe("refuse"); + expect(v.obligation).toBe("wholeness"); + expect(v.detail).toContain("NaN"); + }); + + test("failed edges still refine numeric members once NaN is excluded", () => { + // if (a === a) { guards } — === held excludes NaN; the guards' failed + // edges then prove [0, 100] exactly (the negated comparison keeps + // refining the numeric members, as the refine doc comment pins). + const v = only( + caseModule(["a"], [], [ + iff(bin("===", ref("a.0"), ref("a.0")), [ + iff(bin("<", ref("a.0"), num(0)), [ret()]), + iff(bin(">", ref("a.0"), num(100)), [ret()]), + send(math("trunc", ref("a.0"))), + ]), + ]), + ); + expect(v.outcome).toBe("prove"); + expect(v.provenLo).toBe(0); + expect(v.provenHi).toBe(100); + }); }); describe("straight-line ordinary-field refinement", () => { diff --git a/packages/compiler/src/library/int-infer.ts b/packages/compiler/src/library/int-infer.ts index c9e068b9f..de65a89df 100644 --- a/packages/compiler/src/library/int-infer.ts +++ b/packages/compiler/src/library/int-infer.ts @@ -1214,9 +1214,12 @@ class FnAnalyzer { if (cond.left.type.kind !== "f64" || cond.right.type.kind !== "f64") return env; if (!this.isPure(cond.left) || !this.isPure(cond.right)) return env; const op = branch ? cond.op : NEGATE[cond.op]!; - // NaN makes < <= > >= === evaluate false, so the edge where one of - // those was TRUE proves both operands NaN-free (!== held excludes - // nothing — NaN !== x is true). + // NaN makes < <= > >= === evaluate false, so only the edge where one + // of those HELD proves both operands NaN-free (¬(a < b) does not + // imply a >= b — both are false when a is NaN, so the failed edge of + // an ordered comparison must NOT clear NaN, even though the negated + // comparison still refines the numeric members). !== is the mirror + // image: its FAILED edge means === held, which does exclude NaN. const clearNaN = branch ? cond.op !== "!==" : cond.op === "!=="; const a = this.evalPure(cond.left, env); const b = this.evalPure(cond.right, env); diff --git a/packages/compiler/surface-manifest.json b/packages/compiler/surface-manifest.json index ef28a9f01..999165e12 100644 --- a/packages/compiler/surface-manifest.json +++ b/packages/compiler/surface-manifest.json @@ -1119,6 +1119,13 @@ "status": "static", "note": "recognized module (bare and node:-prefixed specifiers)" }, + { + "id": "node-builtin.midi", + "kind": "node-builtin", + "name": "midi", + "status": "static", + "note": "recognized module (bare and node:-prefixed specifiers)" + }, { "id": "node-builtin.module", "kind": "node-builtin", @@ -2560,15 +2567,13 @@ "id": "stdlib.math.E", "kind": "stdlib", "name": "Math.E", - "status": "dynamic-only", - "code": "SC2012" + "status": "static" }, { "id": "stdlib.math.PI", "kind": "stdlib", "name": "Math.PI", - "status": "dynamic-only", - "code": "SC2012" + "status": "static" }, { "id": "stdlib.math.abs", @@ -2623,15 +2628,15 @@ "id": "stdlib.math.cos", "kind": "stdlib", "name": "Math.cos", - "status": "dynamic-only", - "code": "SC2012" + "status": "static", + "note": "compiles statically at arity 1; other declared call shapes run only in the embedded dynamic engine (SC2012 without --dynamic)" }, { "id": "stdlib.math.exp", "kind": "stdlib", "name": "Math.exp", - "status": "dynamic-only", - "code": "SC2012" + "status": "static", + "note": "compiles statically at arity 1; other declared call shapes run only in the embedded dynamic engine (SC2012 without --dynamic)" }, { "id": "stdlib.math.floor", @@ -2640,6 +2645,13 @@ "status": "static", "note": "compiles statically at arity 1" }, + { + "id": "stdlib.math.fround", + "kind": "stdlib", + "name": "Math.fround", + "status": "static", + "note": "compiles statically at arity 1" + }, { "id": "stdlib.math.hypot", "kind": "stdlib", @@ -2651,8 +2663,8 @@ "id": "stdlib.math.log", "kind": "stdlib", "name": "Math.log", - "status": "dynamic-only", - "code": "SC2012" + "status": "static", + "note": "compiles statically at arity 1; other declared call shapes run only in the embedded dynamic engine (SC2012 without --dynamic)" }, { "id": "stdlib.math.log10", @@ -2686,8 +2698,8 @@ "id": "stdlib.math.pow", "kind": "stdlib", "name": "Math.pow", - "status": "dynamic-only", - "code": "SC2012" + "status": "static", + "note": "compiles statically at arity 2; other declared call shapes run only in the embedded dynamic engine (SC2012 without --dynamic)" }, { "id": "stdlib.math.random", @@ -2714,15 +2726,15 @@ "id": "stdlib.math.sin", "kind": "stdlib", "name": "Math.sin", - "status": "dynamic-only", - "code": "SC2012" + "status": "static", + "note": "compiles statically at arity 1; other declared call shapes run only in the embedded dynamic engine (SC2012 without --dynamic)" }, { "id": "stdlib.math.sqrt", "kind": "stdlib", "name": "Math.sqrt", - "status": "dynamic-only", - "code": "SC2012" + "status": "static", + "note": "compiles statically at arity 1; other declared call shapes run only in the embedded dynamic engine (SC2012 without --dynamic)" }, { "id": "stdlib.math.tan", diff --git a/packages/compiler/test/ts7/baselines/order-parity.json b/packages/compiler/test/ts7/baselines/order-parity.json index 1291f1c21..d6f42b030 100644 --- a/packages/compiler/test/ts7/baselines/order-parity.json +++ b/packages/compiler/test/ts7/baselines/order-parity.json @@ -5089,6 +5089,12 @@ ], "diags": [] }, + "/tests/corpus/2607-math-dsp-static.js": { + "order": [ + "/tests/corpus/2607-math-dsp-static.js" + ], + "diags": [] + }, "/tests/corpus/2608-regex-named-groups.ts": { "order": [ "/tests/corpus/2608-regex-named-groups.ts" @@ -7388,6 +7394,12 @@ ], "diags": [] }, + "/tests/diagnostics/midi.ts": { + "order": [ + "/tests/diagnostics/midi.ts" + ], + "diags": [] + }, "/tests/diagnostics/mixed-compare.ts": { "order": [ "/tests/diagnostics/mixed-compare.ts" diff --git a/packages/compiler/test/ts7/program.test.ts b/packages/compiler/test/ts7/program.test.ts index c44a3863e..f4221d68d 100644 --- a/packages/compiler/test/ts7/program.test.ts +++ b/packages/compiler/test/ts7/program.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, test } from "vitest"; @@ -67,3 +67,32 @@ console.log(required.value); rmSync(dir, { recursive: true, force: true }); } }); + +test("adopts tsconfig paths/baseUrl so tsgo resolves aliased imports", () => { + const tempRoot = process.platform === "win32" ? tmpdir() : "/tmp"; + const dir = mkdtempSync(join(tempRoot, "scriptc-preflight-paths-")); + writeFileSync( + join(dir, "tsconfig.json"), + JSON.stringify({ + compilerOptions: { strictNullChecks: true, baseUrl: ".", paths: { "@/*": ["./src/*"] } }, + }), + ); + const srcDir = join(dir, "src"); + mkdirSync(srcDir); + writeFileSync(join(dir, "entry.ts"), `import { value } from "@/dep";\nconsole.log(value);\n`); + writeFileSync(join(srcDir, "dep.ts"), "export const value = 1;\n"); + const entry = join(dir, "entry.ts"); + + const load = loadProgram(entry); + try { + // Before the fix, tsgo never learns about `paths`/`baseUrl` and reports + // SC0001 "Cannot find module '@/dep'". SC1010 (own resolver has no + // opinion on bare-specifier aliases outside npm/imports-field) is a + // separate, pre-existing limitation and is unaffected by this fix. + const codes = checkPreflight(load).map((diag) => diag.code); + expect(codes).not.toContain("SC0001"); + } finally { + load.dispose(); + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/packages/runtime/src/scr_async.c b/packages/runtime/src/scr_async.c index 7f69595ac..2ae72d122 100644 --- a/packages/runtime/src/scr_async.c +++ b/packages/runtime/src/scr_async.c @@ -2185,6 +2185,19 @@ void scr_loop_set_dgram(bool (*pending)(void), void (*dispatch)(void), int (*pol scr_dgram_pollfd_fn = pollfd; } +/* The midi hook (scr_midi.c, when linked) — the dgram hook's exact shape: + * one more set of nullable slots, byte-identical loop behavior when + * unset. */ +static bool (*scr_midi_pending_fn)(void) = NULL; +static void (*scr_midi_dispatch_fn)(void) = NULL; +static int (*scr_midi_pollfd_fn)(void) = NULL; + +void scr_loop_set_midi(bool (*pending)(void), void (*dispatch)(void), int (*pollfd)(void)) { + scr_midi_pending_fn = pending; + scr_midi_dispatch_fn = dispatch; + scr_midi_pollfd_fn = pollfd; +} + /* The fs.watch hook (scr_watch.c, when linked) — the net hook's exact * shape: one more set of nullable slots, byte-identical loop behavior * when unset. */ @@ -2355,6 +2368,13 @@ bool scr_loop_run(ScrPromise *top_level) { if (scr_exc_pending()) return false; /* uncaught throw in a listener */ if (scr_ready_len > 0) continue; } + /* MIDI dispatch (scr_midi.c, when linked): arrived MIDI messages fire + * their 'message' listeners now — the dgram hook's exact station. */ + if (scr_midi_dispatch_fn != NULL) { + scr_midi_dispatch_fn(); + if (scr_exc_pending()) return false; /* uncaught throw in a listener */ + if (scr_ready_len > 0) continue; + } /* Watch dispatch (scr_watch.c, when linked): file events queued on * the unit's event backend fire their FSWatcher listeners now — the * net hook's exact station. */ @@ -2384,6 +2404,7 @@ bool scr_loop_run(ScrPromise *top_level) { (scr_events_pending_fn != NULL && scr_events_pending_fn()) || (scr_net_pending_fn != NULL && scr_net_pending_fn()) || (scr_dgram_pending_fn != NULL && scr_dgram_pending_fn()) || + (scr_midi_pending_fn != NULL && scr_midi_pending_fn()) || (scr_watch_pending_fn != NULL && scr_watch_pending_fn()) || (scr_ffi_pending_fn != NULL && scr_ffi_pending_fn()) || scr_fs_renames_pending(); @@ -2404,6 +2425,7 @@ bool scr_loop_run(ScrPromise *top_level) { bool events = scr_events_pending_fn != NULL && scr_events_pending_fn(); bool net = scr_net_pending_fn != NULL && scr_net_pending_fn(); bool dgram = scr_dgram_pending_fn != NULL && scr_dgram_pending_fn(); + bool midi = scr_midi_pending_fn != NULL && scr_midi_pending_fn(); bool watch = scr_watch_pending_fn != NULL && scr_watch_pending_fn(); bool ffi = scr_ffi_pending_fn != NULL && scr_ffi_pending_fn(); bool renames = scr_fs_renames_pending(); @@ -2413,7 +2435,7 @@ bool scr_loop_run(ScrPromise *top_level) { * Children follow the same rule: an unref'd child is still REAPED * while the loop runs (kids drives the sweeps and sleeps above) but * only reffed ones keep the process alive. */ - if (scr_reffed_timers == 0 && scr_reffed_immediates == 0 && !scr_children_reffed_pending() && !io && !events && !net && !dgram && !watch && !ffi && !renames) break; + if (scr_reffed_timers == 0 && scr_reffed_immediates == 0 && !scr_children_reffed_pending() && !io && !events && !net && !dgram && !midi && !watch && !ffi && !renames) break; /* Sleep to the earliest deadline, then run every due timer (each may * enqueue microtasks, which the next iteration drains first). Who * sleeps depends on what is pending: @@ -2454,11 +2476,11 @@ bool scr_loop_run(ScrPromise *top_level) { * on EINTR), so they re-impose a coarser cap — bounded Ctrl-C and * socket latency during a fetch, without the reap-granularity * cost. */ - else if ((evw || net || dgram || watch || ffi) && due > now + SCR_SIGNAL_POLL_MS) due = now + SCR_SIGNAL_POLL_MS; + else if ((evw || net || dgram || midi || watch || ffi) && due > now + SCR_SIGNAL_POLL_MS) due = now + SCR_SIGNAL_POLL_MS; scr_io_poll_fn(due > now ? due - now : 0); now = scr_now_ms(); if (scr_ready_len > 0) continue; /* io callbacks woke fibers */ - } else if (evw || net || dgram || watch || ffi) { + } else if (evw || net || dgram || midi || watch || ffi) { #if defined(_WIN32) || defined(__wasi__) /* The win32 arm, and WASI hosts whose poll_oneoff adapters do not * reliably wake for a closed inherited stdin pipe: the sleep is a capped nanosleep and @@ -2472,7 +2494,7 @@ bool scr_loop_run(ScrPromise *top_level) { * show up in a profile, the upgrade is a real waitable arm — * WaitForMultipleObjects over WSAEVENTs, or IOCP. */ if (evw && due > now + SCR_SIGNAL_POLL_MS) due = now + SCR_SIGNAL_POLL_MS; - if ((net || dgram || watch) && due > now + SCR_CHILD_POLL_MS) due = now + SCR_CHILD_POLL_MS; + if ((net || dgram || midi || watch) && due > now + SCR_CHILD_POLL_MS) due = now + SCR_CHILD_POLL_MS; if (ffi && due > now + SCR_CHILD_POLL_MS) due = now + SCR_CHILD_POLL_MS; if (kids && due > now + SCR_CHILD_POLL_MS) due = now + SCR_CHILD_POLL_MS; if (due > now) { @@ -2524,6 +2546,17 @@ bool scr_loop_run(ScrPromise *top_level) { due = now + SCR_SIGNAL_POLL_MS; } } + if (midi) { + /* The midi unit's poller fd — the net slot's exact story. */ + int mfd = scr_midi_pollfd_fn != NULL ? scr_midi_pollfd_fn() : -1; + if (mfd >= 0) { + fds[nfds].fd = mfd; + fds[nfds].events = POLLIN; + fds[nfds++].revents = 0; + } else if (due > now + SCR_SIGNAL_POLL_MS) { + due = now + SCR_SIGNAL_POLL_MS; + } + } if (watch) { /* The watch unit's event fd — the net slot's exact story. */ int wfd = scr_watch_pollfd_fn != NULL ? scr_watch_pollfd_fn() : -1; diff --git a/packages/runtime/src/scr_lib.c b/packages/runtime/src/scr_lib.c index a6019c7bc..232a33c32 100644 --- a/packages/runtime/src/scr_lib.c +++ b/packages/runtime/src/scr_lib.c @@ -17,7 +17,17 @@ #include "scr_runtime.h" #include -#include +#ifndef _MSC_VER +#include /* mingw-w64 ships one; MSVC: see scr_win.c shims */ +#else +/* MSVC dirent shim — declared here, defined in scr_win.c. */ +enum { DT_REG = 8, DT_DIR = 4 }; +struct dirent { char d_name[260]; unsigned char d_type; }; +typedef struct { void *_hFind; int _first; struct dirent _ent; } DIR; +DIR *opendir(const char *path); +struct dirent *readdir(DIR *d); +int closedir(DIR *d); +#endif #include #include #include @@ -44,7 +54,13 @@ #include /* _mkdir */ #include /* _isatty, _access, open/read/write/close */ #include /* getpid */ +#ifndef _MSC_VER #include /* mingw-w64 ships one: getcwd, access, isatty, ... */ +#else +#define getcwd _getcwd +#define access _access +#define isatty _isatty +#endif #include /* BEFORE windows.h (which pulls winsock 1 otherwise) */ #include /* inet_ntop, sockaddr_in6 */ #include /* GetAdaptersAddresses (os.networkInterfaces) */ diff --git a/packages/runtime/src/scr_midi.c b/packages/runtime/src/scr_midi.c new file mode 100644 index 000000000..43e1b0e06 --- /dev/null +++ b/packages/runtime/src/scr_midi.c @@ -0,0 +1,1423 @@ +/* node:midi — MIDI input/output ports over the event loop's readiness + * poller (the scr_platform.h contract — kqueue on macOS/BSD, epoll on + * Linux, WSAPoll on win32; scr_dgram.c has the seam's full story). The + * de-facto Node surface is node-midi / @julusian/midi (RtMidi under the + * hood); this unit ports its CORE messaging shape — enumerate, open + * (incl. virtual ports), receive time-stamped messages via 'message', + * send raw bytes — modeled touchpoint-for-touchpoint on scr_dgram.c. + * + * ── Design note ────────────────────────────────────────────────────── + * + * Object model. Two refcounted handle kinds, LEAN allocations (the + * ScrDgramSocket precedent, no cycle header): ScrMidiInput (a live, + * pollable source, like a bound socket) and ScrMidiOutput (fire-and- + * forget, like a connected UDP sender). Both start with a `kind` tag as + * their first member, so the shared ABI symbols take a void* handle and + * route on that tag (scr_net.c's leading-int-in-udata technique). A + * 'message' listener MOVES in (+1) and is released when the input closes + * or at the exit-time cleanup — the dgram ownership story verbatim, so a + * listener capturing its own input cannot cycle past close. + * + * Event dispatch. One poller owned by this unit (lazily created). The + * loop (scr_async.c) calls scr_midi_dispatch() at every turn top — the + * dgram hook's exact shape — draining the poller (a zero-timeout pass) + * then firing 'message' emits macrotask-style on the MAIN stack, stopping + * early when a listener enqueued microtasks or threw. Between turns the + * loop's idle poll(2) watches this unit's poller fd. + * + * The off-thread bridge (the mandatory rule). CoreMIDI and WinMM deliver + * their read callbacks on a PLATFORM thread, never the loop thread. Those + * callbacks are forbidden from touching the runtime heap (no ScrArr / + * ScrStr / closures, no refcounts) — they only COPY the raw bytes into a + * per-input, lock-guarded ring (plain libc malloc, which is thread-safe + * and is NOT the GC heap) and write ONE byte to a self-pipe whose read + * end is registered with the poller. All JS-visible work — building the + * number[], computing deltaTime, firing listeners — happens later in + * scr_midi_dispatch on the loop thread. ALSA's fds are pollable directly, + * so its "callback" is just the loop-thread decode in the same pump; it + * uses the same ring for one drain path. + * + * Read model. Consumer-like: the input's platform source stays open once + * opened (node-midi keeps the port live regardless of listeners), but the + * ring only fills while the source runs; messages fire in arrival order, + * one 'message' emit per message, the byte run delivered as a number[] + * (the node-midi shape) with deltaTime the leading f64. `once` listeners + * leave the live list before firing (the dgram snapshot discipline). + * + * Delta-time. Each input tracks the timestamp of its previous delivered + * message and reports deltaTime in SECONDS (node-midi's unit). The first + * message after open reports 0. The timestamp is captured at enqueue with + * a monotonic clock (the platform packet time where a backend has it). + * + * ignoreTypes(sysex, timing, activeSensing). Applied at fire time on the + * loop thread by inspecting the status byte (RtMidi's filter): sysex = + * 0xF0, timing = 0xF8 clock and 0xF1 MTC quarter-frame, activeSensing = + * 0xFE. node-midi's default is (true, true, true) — set at construction. + * + * Send model. sendMessage writes immediately — a MIDI message either goes + * out or it doesn't; there is no buffering. Short channel/system messages + * take the platform short path (midiOutShortMsg / a 3-byte packet); a + * SysEx run takes the long path (midiOutLongMsg / snd_midi_event / a + * variable packet). + * + * Virtual ports (the hardware-free loopback §5 relies on). POSIX only: + * ALSA creates a native sequencer port other clients subscribe to; + * CoreMIDI creates a MIDISource (an input's virtual is a destination we + * publish, an output's virtual is a source we publish). WinMM has NO + * user-space virtual ports, so openVirtualPort THROWS a clear runtime + * error there (a documented divergence). A test opens a virtual output + * named e.g. "scriptc-test", opens an input on that same virtual port, + * sends a deterministic sequence, and compares — no hardware needed. + * + * Loop liveness. An OPEN input holds the loop alive until closePort (a + * live source, like a bound socket). An output holds nothing (send is + * fire-and-forget). Inputs abandoned open at exit are released by the + * atexit cleanup, so the RC audit stays clean. There is no unref surface + * — node-midi's Input exposes none. + * + * State errors. sendMessage / openPort semantics follow node-midi: an + * out-of-range port index is a clear thrown Error; openVirtualPort on + * WinMM throws; opening an already-open handle re-opens (node-midi closes + * the previous port first — mirrored). */ +#include "scr_platform.h" +#include "scr_runtime.h" + +#include +#include +#include +#include +#include + +#if !defined(_WIN32) +#include +#include +#include +#endif + +/* ── backend selection (the scr_dgram.c platform-arm stance) ─────────── + * macOS → CoreMIDI, Linux → ALSA sequencer (only when its dev headers are + * present; this container has none, so the header-less build falls through + * to the stub and still compiles), Windows → WinMM. Anything else, and a + * Linux host without libasound-dev, links the STUB: enumeration answers + * empty, opening a port throws "no MIDI backend", so a non-MIDI platform + * build stays clean. */ +#if defined(_WIN32) +#define SCR_MIDI_WINMM 1 +#elif defined(__APPLE__) +#define SCR_MIDI_COREMIDI 1 +#elif defined(__linux__) && defined(__has_include) +#if __has_include() +#define SCR_MIDI_ALSA 1 +#endif +#endif + +#if SCR_MIDI_WINMM +#include +#include +#include /* the self-pipe socketpair emulation */ +#elif SCR_MIDI_COREMIDI +#include +#include +#elif SCR_MIDI_ALSA +#include +#include +#endif + +static void scr_midi_oom(void) { + fputs("scriptc: out of memory\n", stderr); + abort(); +} + +/* Monotonic milliseconds — the deltaTime clock. Heap-free and thread-safe + * (clock_gettime / QueryPerformanceCounter), so an off-thread producer may + * timestamp its enqueue without touching the runtime. */ +static double scr_midi_now_ms(void) { +#if SCR_MIDI_WINMM + static LARGE_INTEGER freq; + static bool have_freq = false; + if (!have_freq) { + QueryPerformanceFrequency(&freq); + have_freq = true; + } + LARGE_INTEGER c; + QueryPerformanceCounter(&c); + return (double)c.QuadPart * 1000.0 / (double)freq.QuadPart; +#else + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + return (double)ts.tv_sec * 1000.0 + (double)ts.tv_nsec / 1e6; +#endif +} + +/* ── the cross-thread lock (inputs only — producers may be off-thread) ── */ +#if SCR_MIDI_WINMM +typedef CRITICAL_SECTION ScrMidiLock; +#define SCR_MIDI_LOCK_INIT(l) InitializeCriticalSection(l) +#define SCR_MIDI_LOCK(l) EnterCriticalSection(l) +#define SCR_MIDI_UNLOCK(l) LeaveCriticalSection(l) +#define SCR_MIDI_LOCK_FINI(l) DeleteCriticalSection(l) +#else +typedef pthread_mutex_t ScrMidiLock; +#define SCR_MIDI_LOCK_INIT(l) pthread_mutex_init((l), NULL) +#define SCR_MIDI_LOCK(l) pthread_mutex_lock(l) +#define SCR_MIDI_UNLOCK(l) pthread_mutex_unlock(l) +#define SCR_MIDI_LOCK_FINI(l) pthread_mutex_destroy(l) +#endif + +/* ── the arrival ring (the off-thread hand-off) ─────────────────────── + * A FIFO of raw messages the producer fills under the lock; the loop + * thread drains it in scr_midi_dispatch. Bytes are plain malloc (libc, + * not the GC heap), so the realtime producer never allocates a runtime + * object. */ +typedef struct ScrMidiMsg { + unsigned char *bytes; /* malloc'd */ + size_t len; + double ts_ms; + struct ScrMidiMsg *next; +} ScrMidiMsg; + +/* ── listener list (the dgram snapshot discipline, restated so this unit + * links standalone) ─────────────────────────────────────────────────── */ +typedef struct { + ScrClosure *cb; + void *fn; /* the message adapter thunk (scr_midi_msg_thunk0/1/2) */ + bool once; +} ScrMidiL; + +typedef struct { + ScrMidiL *ls; + size_t n, cap; +} ScrMidiLs; + +static void scr_midi_ls_add(ScrMidiLs *l, ScrClosure *cb, void *fn, bool once) { + if (l->n == l->cap) { + l->cap = l->cap ? l->cap * 2 : 2; + l->ls = realloc(l->ls, l->cap * sizeof *l->ls); + if (!l->ls) scr_midi_oom(); + } + l->ls[l->n].cb = cb; + l->ls[l->n].fn = fn; + l->ls[l->n].once = once; + l->n++; +} + +static void scr_midi_ls_drop(ScrMidiLs *l) { + for (size_t i = 0; i < l->n; i++) scr_closure_release(l->ls[i].cb); + free(l->ls); + l->ls = NULL; + l->n = l->cap = 0; +} + +/* Snapshot for a firing pass: entries retained; `once` entries leave the + * LIVE list before their callback runs (the dgram spelling). */ +static size_t scr_midi_ls_snapshot(ScrMidiLs *l, ScrMidiL **out) { + size_t n = l->n; + if (n == 0) { + *out = NULL; + return 0; + } + ScrMidiL *snap = malloc(n * sizeof *snap); + if (!snap) scr_midi_oom(); + for (size_t i = 0; i < n; i++) { + snap[i] = l->ls[i]; + scr_closure_retain(snap[i].cb); + } + size_t w = 0; + for (size_t i = 0; i < l->n; i++) { + if (l->ls[i].once) scr_closure_release(l->ls[i].cb); + else l->ls[w++] = l->ls[i]; + } + l->n = w; + *out = snap; + return n; +} + +/* ── the handles ─────────────────────────────────────────────────────── */ + +typedef enum { SCR_MIDI_IN = 0, SCR_MIDI_OUT = 1 } ScrMidiKind; + +struct ScrMidiInput { + ScrMidiKind kind; /* SCR_MIDI_IN — FIRST member (the void* tag) */ + size_t rc; + bool open; + bool is_virtual; + bool ign_sysex, ign_timing, ign_sense; /* node-midi default: all true */ + bool have_last_ts; + double last_ts_ms; + ScrMidiLs msg_ls; + /* the arrival ring (lock-guarded head/tail; the loop drains it) */ + ScrMidiLock lock; + ScrMidiMsg *ring_head, *ring_tail; + bool lock_ready; + /* registry (open inputs hold the loop) */ + bool in_registry; + struct ScrMidiInput *next; + /* platform state */ +#if SCR_MIDI_ALSA + snd_seq_t *seq; + int seq_port; + int seq_dest_client, seq_dest_port; /* the connected source (openPort) */ + snd_midi_event_t *decoder; + int *pfds; /* registered poll fds, forgotten before close */ + int npfds; +#elif SCR_MIDI_COREMIDI + MIDIClientRef client; + MIDIPortRef port; /* the input port (openPort) */ + MIDIEndpointRef endpoint; /* the connected source, or the virtual dest */ + int pipe_r, pipe_w; /* self-pipe: producer pokes, poller watches r */ +#elif SCR_MIDI_WINMM + HMIDIIN h; + int pipe_r, pipe_w; + char sysex_buf[1024]; + MIDIHDR sysex_hdr; +#endif +}; + +struct ScrMidiOutput { + ScrMidiKind kind; /* SCR_MIDI_OUT — FIRST member (the void* tag) */ + size_t rc; + bool open; + bool is_virtual; +#if SCR_MIDI_ALSA + snd_seq_t *seq; + int seq_port; + int seq_dest_client, seq_dest_port; + snd_midi_event_t *encoder; +#elif SCR_MIDI_COREMIDI + MIDIClientRef client; + MIDIPortRef port; /* the output port (openPort) */ + MIDIEndpointRef endpoint; /* the connected destination, or virtual source */ + bool endpoint_is_virtual; +#elif SCR_MIDI_WINMM + HMIDIOUT h; +#endif +}; + +#ifdef SCR_RC_AUDIT +static long scr_midi_live = 0; +long scr_midi_live_count(void) { return scr_midi_live; } +#endif + +static ScrMidiInput *scr_midi_inputs = NULL; /* registry: +1 each */ +static ScrPoller *scr_midi_poller = NULL; + +/* ── poller plumbing (the scr_platform.h seam) ───────────────────────── */ + +static bool scr_midi_poller_init(void) { + if (scr_midi_poller != NULL) return true; + scr_midi_poller = scrp_poller_new(); + return scr_midi_poller != NULL; +} + +static void scr_midi_watch_read(int fd, void *udata, bool on) { + if (scr_midi_poller == NULL || fd < 0) return; + (void)scrp_watch_read(scr_midi_poller, fd, udata, on); +} + +/* Forget-then-close — the epoll obligation (scr_platform.h); a no-op + * forget on the kqueue side keeps macOS byte-identical. */ +static void scr_midi_forget_fd(int fd) { + if (fd < 0) return; + if (scr_midi_poller != NULL) scrp_forget(scr_midi_poller, fd); +} + +/* ── registry ────────────────────────────────────────────────────────── */ + +ScrMidiInput *scr_midi_input_retain(ScrMidiInput *s) { + if (s->rc != SIZE_MAX) s->rc++; + return s; +} +void scr_midi_input_release(ScrMidiInput *s); /* fwd */ + +static void scr_midi_register(ScrMidiInput *s) { + if (s->in_registry) return; + s->in_registry = true; + s->next = NULL; + ScrMidiInput **link = &scr_midi_inputs; + while (*link) link = &(*link)->next; + *link = scr_midi_input_retain(s); +} + +static void scr_midi_unregister(ScrMidiInput *s) { + if (!s->in_registry) return; + ScrMidiInput **link = &scr_midi_inputs; + while (*link && *link != s) link = &(*link)->next; + if (*link) { + *link = s->next; + s->next = NULL; + s->in_registry = false; + scr_midi_input_release(s); + } +} + +/* ── the arrival ring ────────────────────────────────────────────────── */ + +/* Producer side (may be OFF-THREAD on CoreMIDI/WinMM): copy the bytes and + * link them under the lock. NEVER touches the runtime heap — libc malloc + * only. Returns true if a poller poke is warranted (pipe backends write + * one byte after this). */ +static void scr_midi_ring_push(ScrMidiInput *s, const unsigned char *bytes, size_t len, + double ts_ms) { + if (len == 0) return; + ScrMidiMsg *m = malloc(sizeof *m); + if (!m) return; /* drop on exhaustion, like a full kernel MIDI queue */ + m->bytes = malloc(len); + if (!m->bytes) { + free(m); + return; + } + memcpy(m->bytes, bytes, len); + m->len = len; + m->ts_ms = ts_ms; + m->next = NULL; + SCR_MIDI_LOCK(&s->lock); + if (s->ring_tail) s->ring_tail->next = m; + else s->ring_head = m; + s->ring_tail = m; + SCR_MIDI_UNLOCK(&s->lock); +} + +/* Consumer side (LOOP THREAD only): pop one message, ownership to caller. */ +static ScrMidiMsg *scr_midi_ring_pop(ScrMidiInput *s) { + SCR_MIDI_LOCK(&s->lock); + ScrMidiMsg *m = s->ring_head; + if (m) { + s->ring_head = m->next; + if (!s->ring_head) s->ring_tail = NULL; + } + SCR_MIDI_UNLOCK(&s->lock); + return m; +} + +static bool scr_midi_ring_nonempty(ScrMidiInput *s) { + SCR_MIDI_LOCK(&s->lock); + bool has = s->ring_head != NULL; + SCR_MIDI_UNLOCK(&s->lock); + return has; +} + +static void scr_midi_ring_clear(ScrMidiInput *s) { + ScrMidiMsg *m; + while ((m = scr_midi_ring_pop(s)) != NULL) { + free(m->bytes); + free(m); + } +} + +/* The ignoreTypes filter (RtMidi's status-byte test), applied on the loop + * thread so the realtime producer stays branch-free. */ +static bool scr_midi_filtered(const ScrMidiInput *s, const unsigned char *b, size_t len) { + if (len == 0) return true; + unsigned char st = b[0]; + if (s->ign_sysex && st == 0xF0) return true; + if (s->ign_timing && (st == 0xF8 || st == 0xF1)) return true; + if (s->ign_sense && st == 0xFE) return true; + return false; +} + +/* ── the message adapters (the dgram thunk family) ───────────────────── */ + +/* The adapter signature: deltaTime as the leading f64, the byte run as a + * number[] (SCR_ELEM_F64). BORROWED to the adapter (multiple listeners see + * one message); the two-param adapter retains for its listener's owned + * param, per the universal convention. */ +void scr_midi_msg_thunk0(ScrClosure *cb, double dt, ScrArr *msg) { + (void)dt; + (void)msg; + ((void (*)(ScrClosure *))cb->fn)(cb); +} +void scr_midi_msg_thunk1(ScrClosure *cb, double dt, ScrArr *msg) { + (void)msg; + ((void (*)(ScrClosure *, double))cb->fn)(cb, dt); +} +void scr_midi_msg_thunk2(ScrClosure *cb, double dt, ScrArr *msg) { + ((void (*)(ScrClosure *, double, ScrArr *))cb->fn)(cb, dt, scr_arr_retain(msg)); +} + +/* ── platform backend forward declarations ───────────────────────────── */ + +static int scr_midi_plat_count(bool is_input); +static bool scr_midi_plat_name(bool is_input, int idx, char *buf, size_t bufsz); +static const char *scr_midi_plat_in_open(ScrMidiInput *s, int idx, const char *vname); +static void scr_midi_plat_in_close(ScrMidiInput *s); +static void scr_midi_plat_in_pump(ScrMidiInput *s); /* drain the source into the ring */ +static const char *scr_midi_plat_out_open(ScrMidiOutput *s, int idx, const char *vname); +static void scr_midi_plat_out_close(ScrMidiOutput *s); +static void scr_midi_plat_out_send(ScrMidiOutput *s, const unsigned char *bytes, size_t len); + +/* ── RC ──────────────────────────────────────────────────────────────── */ + +void scr_midi_input_release(ScrMidiInput *s) { + if (!s || s->rc == SIZE_MAX) return; + if (--s->rc == 0) { + if (s->open) scr_midi_plat_in_close(s); + scr_midi_ls_drop(&s->msg_ls); + scr_midi_ring_clear(s); + if (s->lock_ready) SCR_MIDI_LOCK_FINI(&s->lock); +#ifdef SCR_RC_AUDIT + scr_midi_live--; +#endif + free(s); + } +} + +ScrMidiOutput *scr_midi_output_retain(ScrMidiOutput *s) { + if (s->rc != SIZE_MAX) s->rc++; + return s; +} + +void scr_midi_output_release(ScrMidiOutput *s) { + if (!s || s->rc == SIZE_MAX) return; + if (--s->rc == 0) { + if (s->open) scr_midi_plat_out_close(s); +#ifdef SCR_RC_AUDIT + scr_midi_live--; +#endif + free(s); + } +} + +/* The void* RC entry points the compiler stores per handle kind. */ +void *scr_midi_input_retain_v(void *p) { return scr_midi_input_retain((ScrMidiInput *)p); } +void scr_midi_input_release_v(void *p) { scr_midi_input_release((ScrMidiInput *)p); } +void *scr_midi_output_retain_v(void *p) { return scr_midi_output_retain((ScrMidiOutput *)p); } +void scr_midi_output_release_v(void *p) { scr_midi_output_release((ScrMidiOutput *)p); } + +/* ── the surface: construction ───────────────────────────────────────── */ + +ScrMidiInput *scr_midi_input_new(void) { + ScrMidiInput *s = calloc(1, sizeof *s); + if (!s) scr_midi_oom(); + s->kind = SCR_MIDI_IN; + s->rc = 1; + s->ign_sysex = s->ign_timing = s->ign_sense = true; /* node-midi default */ + SCR_MIDI_LOCK_INIT(&s->lock); + s->lock_ready = true; +#if SCR_MIDI_COREMIDI || SCR_MIDI_WINMM + s->pipe_r = s->pipe_w = -1; +#endif +#ifdef SCR_RC_AUDIT + scr_midi_live++; +#endif + return s; +} + +ScrMidiOutput *scr_midi_output_new(void) { + ScrMidiOutput *s = calloc(1, sizeof *s); + if (!s) scr_midi_oom(); + s->kind = SCR_MIDI_OUT; + s->rc = 1; +#ifdef SCR_RC_AUDIT + scr_midi_live++; +#endif + return s; +} + +static void scr_midi_throw(const char *msg) { + scr_throw_error_msg(0 /* Error */, msg, strlen(msg)); +} + +/* getPortCount / getPortName work on a fresh handle before openPort + * (node-midi enumerates then opens — §7's confirmed stance). isInput + * selects the input vs output port namespace; the frozen ABI passes it + * explicitly so the shared symbol needs no per-handle read. */ +double scr_midi_port_count(void *handle, bool is_input) { + (void)handle; + int n = scr_midi_plat_count(is_input); + return n < 0 ? 0 : (double)n; +} + +ScrStr *scr_midi_port_name(void *handle, double idx) { + ScrMidiKind kind = *(ScrMidiKind *)handle; + char buf[256]; + if (!scr_midi_plat_name(kind == SCR_MIDI_IN, (int)idx, buf, sizeof buf)) { + /* node-midi returns "" for an out-of-range index rather than throwing. */ + return scr_str_new("", 0); + } + return scr_str_new(buf, strlen(buf)); +} + +/* ── open / close ────────────────────────────────────────────────────── */ + +void scr_midi_open_port(void *handle, double idx) { + if (!scr_midi_poller_init()) { + fputs("scriptc: event poller init failed\n", stderr); + abort(); + } + ScrMidiKind kind = *(ScrMidiKind *)handle; + if (kind == SCR_MIDI_IN) { + ScrMidiInput *s = (ScrMidiInput *)handle; + if (s->open) scr_midi_plat_in_close(s); /* node-midi re-opens */ + const char *err = scr_midi_plat_in_open(s, (int)idx, NULL); + if (err) { + scr_midi_throw(err); + return; + } + s->open = true; + s->is_virtual = false; + s->have_last_ts = false; + scr_midi_register(s); /* an open input holds the loop */ + } else { + ScrMidiOutput *s = (ScrMidiOutput *)handle; + if (s->open) scr_midi_plat_out_close(s); + const char *err = scr_midi_plat_out_open(s, (int)idx, NULL); + if (err) { + scr_midi_throw(err); + return; + } + s->open = true; + s->is_virtual = false; + } +} + +void scr_midi_open_virtual(void *handle, ScrStr *name) { + if (!scr_midi_poller_init()) { + fputs("scriptc: event poller init failed\n", stderr); + abort(); + } + const char *vname = name && name->len ? name->data : "scriptc"; + ScrMidiKind kind = *(ScrMidiKind *)handle; + if (kind == SCR_MIDI_IN) { + ScrMidiInput *s = (ScrMidiInput *)handle; + if (s->open) scr_midi_plat_in_close(s); + const char *err = scr_midi_plat_in_open(s, -1, vname); + if (err) { + scr_midi_throw(err); + return; + } + s->open = true; + s->is_virtual = true; + s->have_last_ts = false; + scr_midi_register(s); + } else { + ScrMidiOutput *s = (ScrMidiOutput *)handle; + if (s->open) scr_midi_plat_out_close(s); + const char *err = scr_midi_plat_out_open(s, -1, vname); + if (err) { + scr_midi_throw(err); + return; + } + s->open = true; + s->is_virtual = true; + } +} + +void scr_midi_close_port(void *handle) { + ScrMidiKind kind = *(ScrMidiKind *)handle; + if (kind == SCR_MIDI_IN) { + ScrMidiInput *s = (ScrMidiInput *)handle; + if (!s->open) return; /* node-midi tolerates close on a closed port */ + scr_midi_plat_in_close(s); /* forgets its fds, then closes them */ + s->open = false; + scr_midi_ring_clear(s); + scr_midi_unregister(s); /* the loop can drain */ + } else { + ScrMidiOutput *s = (ScrMidiOutput *)handle; + if (!s->open) return; + scr_midi_plat_out_close(s); + s->open = false; + } +} + +bool scr_midi_is_open(void *handle) { + ScrMidiKind kind = *(ScrMidiKind *)handle; + if (kind == SCR_MIDI_IN) return ((ScrMidiInput *)handle)->open; + return ((ScrMidiOutput *)handle)->open; +} + +void scr_midi_ignore_types(ScrMidiInput *s, bool sysex, bool timing, bool sense) { + s->ign_sysex = sysex; + s->ign_timing = timing; + s->ign_sense = sense; +} + +/* ── send ────────────────────────────────────────────────────────────── */ + +/* The ABI primitive (the frozen table's `midi.send`): raw bytes + length. */ +void scr_midi_send(ScrMidiOutput *s, const uint8_t *bytes, double len) { + if (!s->open) { + scr_midi_throw("Message sent on unopened port"); + return; + } + size_t n = len < 0 ? 0 : (size_t)len; + if (n == 0) return; + scr_midi_plat_out_send(s, bytes, n); +} + +/* Marshaling entry points for the two accepted argument shapes (the + * surfaces.ts stance: a number[] literal/variable, or a Uint8Array). Both + * narrow to the raw primitive above. */ +void scr_midi_send_array(ScrMidiOutput *s, ScrArr *message) { + size_t n = (size_t)message->len; + if (n == 0) { + if (!s->open) scr_midi_throw("Message sent on unopened port"); + return; + } + unsigned char stackbuf[64]; + unsigned char *buf = n <= sizeof stackbuf ? stackbuf : malloc(n); + if (!buf) scr_midi_oom(); + for (size_t i = 0; i < n; i++) { + double v = scr_arr_get_f64(message, (double)i); + buf[i] = (unsigned char)((int)v & 0xFF); + } + scr_midi_send(s, buf, (double)n); + if (buf != stackbuf) free(buf); +} + +void scr_midi_send_bytes(ScrMidiOutput *s, ScrBytes *message) { + size_t n = (size_t)scr_bytes_byte_len(message); + scr_midi_send(s, (const uint8_t *)message->data, (double)n); +} + +/* ── on('message') / once('message') ─────────────────────────────────── */ + +void scr_midi_on_message(ScrMidiInput *s, ScrClosure *cb, ScrMidiMsgFn fn, bool once) { + if (!s) { + scr_closure_release(cb); + return; + } + scr_midi_ls_add(&s->msg_ls, cb, (void *)fn, once); +} + +/* ── the fire path (LOOP THREAD) ─────────────────────────────────────── */ + +/* Drain one input's ring, firing 'message' for each un-filtered message. + * The number[] is built here (never off-thread); deltaTime is seconds + * since the previous DELIVERED message, 0 for the first. The handle is + * retained across the drain (a listener may closePort/release it). */ +static void scr_midi_in_fire(ScrMidiInput *s) { + scr_midi_input_retain(s); + for (;;) { + ScrMidiMsg *m = scr_midi_ring_pop(s); + if (!m) break; + if (scr_midi_filtered(s, m->bytes, m->len)) { + free(m->bytes); + free(m); + continue; + } + double dt = 0.0; + if (s->have_last_ts) dt = (m->ts_ms - s->last_ts_ms) / 1000.0; + s->last_ts_ms = m->ts_ms; + s->have_last_ts = true; + + ScrArr *arr = scr_arr_new(SCR_ELEM_F64, m->len); + for (size_t i = 0; i < m->len; i++) scr_arr_push_f64(arr, (double)m->bytes[i]); + free(m->bytes); + free(m); + + ScrMidiL *snap; + size_t nl = scr_midi_ls_snapshot(&s->msg_ls, &snap); + for (size_t i = 0; i < nl; i++) { + if (!scr_exc_pending()) ((ScrMidiMsgFn)snap[i].fn)(snap[i].cb, dt, arr); + scr_closure_release(snap[i].cb); + } + free(snap); + scr_arr_release(arr); + if (scr_exc_pending()) break; + } + scr_midi_input_release(s); +} + +/* ── the loop hooks (scr_async.c) ────────────────────────────────────── */ + +static bool scr_midi_pending(void) { + for (ScrMidiInput *s = scr_midi_inputs; s; s = s->next) { + /* An open input holds the loop (a live source); a filled ring is due + * work regardless. */ + if (s->open) return true; + if (scr_midi_ring_nonempty(s)) return true; + } + return false; +} + +static int scr_midi_pollfd(void) { + return scr_midi_poller != NULL ? scrp_poller_fd(scr_midi_poller) : -1; +} + +/* Called each loop turn (the dgram dispatch station's exact shape): + * alternate a zero-timeout poller drain — which pumps each ready input's + * platform source into its ring (ALSA decode on the loop thread; a pipe + * drain for the off-thread backends, whose bytes are already in the ring) + * — with a firing pass, stopping when a listener enqueued microtasks or + * threw. */ +static void scr_midi_dispatch(void) { + if (!scr_midi_inputs) return; + for (;;) { + if (scr_midi_poller != NULL) { + ScrPollerEvent evs[64]; + int n = scrp_drain(scr_midi_poller, evs, 64); + for (int i = 0; i < n; i++) { + ScrMidiInput *s = (ScrMidiInput *)evs[i].udata; + if (!s || !s->open) continue; /* closed earlier in this batch */ + scr_midi_plat_in_pump(s); + } + } + bool any = false; + for (ScrMidiInput *s = scr_midi_inputs; s; s = s->next) { + if (!scr_midi_ring_nonempty(s)) continue; + any = true; + scr_midi_in_fire(s); + if (scr_exc_pending()) return; + } + if (!any) return; + if (scr_loop_has_ready()) return; /* microtasks interleave first */ + } +} + +/* Exit-time cleanup (the dgram precedent): inputs a program leaves open at + * exit release their listeners and registry references so the RC audit + * sees a clean heap. */ +static void scr_midi_cleanup_atexit(void) { + while (scr_midi_inputs) { + ScrMidiInput *s = scr_midi_inputs; + if (s->open) { + scr_midi_plat_in_close(s); + s->open = false; + } + scr_midi_ls_drop(&s->msg_ls); + scr_midi_ring_clear(s); + scr_midi_unregister(s); + } +} + +void scr_midi_install(void) { + static bool installed = false; + if (installed) return; + installed = true; + atexit(scr_midi_cleanup_atexit); + scr_loop_set_midi(&scr_midi_pending, &scr_midi_dispatch, &scr_midi_pollfd); +} + +/* ══ platform backends ═══════════════════════════════════════════════════ + * Each provides: enumerate (count/name), open input/output (idx>=0 opens a + * real port; idx<0 opens a virtual port named vname), close, pump (drain a + * source into the ring), send. All error strings are returned (NULL = + * success) so the portable surface owns the throw. */ + +/* ─────────────────────────── Linux: ALSA sequencer ─────────────────── */ +#if SCR_MIDI_ALSA + +/* A shared client handle for pure ENUMERATION (getPortCount/getPortName on + * a fresh handle, before any port opens). Opened lazily, kept for the + * process; the per-handle open uses its own client. */ +static snd_seq_t *scr_midi_enum_seq(void) { + static snd_seq_t *seq = NULL; + if (seq == NULL) { + if (snd_seq_open(&seq, "default", SND_SEQ_OPEN_DUPLEX, 0) < 0) seq = NULL; + } + return seq; +} + +/* Walk every client/port, invoking `hit` for each whose capability matches + * the direction we want (input source = readable+subscribable-read; output + * sink = writable+subscribable-write). Returns the total, and fills + * client/port + name for the `want`-th match when name!=NULL. */ +static int scr_midi_alsa_walk(bool is_input, int want, int *out_client, int *out_port, + char *name, size_t namesz) { + snd_seq_t *seq = scr_midi_enum_seq(); + if (!seq) return -1; + unsigned int need = is_input ? (SND_SEQ_PORT_CAP_READ | SND_SEQ_PORT_CAP_SUBS_READ) + : (SND_SEQ_PORT_CAP_WRITE | SND_SEQ_PORT_CAP_SUBS_WRITE); + snd_seq_client_info_t *cinfo; + snd_seq_port_info_t *pinfo; + snd_seq_client_info_alloca(&cinfo); + snd_seq_port_info_alloca(&pinfo); + snd_seq_client_info_set_client(cinfo, -1); + int count = 0; + while (snd_seq_query_next_client(seq, cinfo) >= 0) { + int client = snd_seq_client_info_get_client(cinfo); + if (client == SND_SEQ_CLIENT_SYSTEM) continue; /* skip the system client */ + snd_seq_port_info_set_client(pinfo, client); + snd_seq_port_info_set_port(pinfo, -1); + while (snd_seq_query_next_port(seq, pinfo) >= 0) { + unsigned int caps = snd_seq_port_info_get_capability(pinfo); + if ((caps & need) != need) continue; + if (want == count) { + if (out_client) *out_client = client; + if (out_port) *out_port = snd_seq_port_info_get_port(pinfo); + if (name && namesz) { + snprintf(name, namesz, "%s:%d", snd_seq_client_info_get_name(cinfo), + snd_seq_port_info_get_port(pinfo)); + } + } + count++; + } + } + return count; +} + +static int scr_midi_plat_count(bool is_input) { + return scr_midi_alsa_walk(is_input, -1, NULL, NULL, NULL, 0); +} + +static bool scr_midi_plat_name(bool is_input, int idx, char *buf, size_t bufsz) { + int c = -1, p = -1; + char nm[256] = ""; + int total = scr_midi_alsa_walk(is_input, idx, &c, &p, nm, sizeof nm); + if (idx < 0 || idx >= total || nm[0] == '\0') return false; + snprintf(buf, bufsz, "%s", nm); + return true; +} + +static const char *scr_midi_plat_in_open(ScrMidiInput *s, int idx, const char *vname) { + if (snd_seq_open(&s->seq, "default", SND_SEQ_OPEN_DUPLEX, SND_SEQ_NONBLOCK) < 0) + return "MIDI: could not open ALSA sequencer"; + snd_seq_set_client_name(s->seq, vname ? vname : "scriptc-input"); + /* Our port is WRITABLE (others write to us) so it can receive. */ + s->seq_port = snd_seq_create_simple_port( + s->seq, vname ? vname : "scriptc-input", + SND_SEQ_PORT_CAP_WRITE | SND_SEQ_PORT_CAP_SUBS_WRITE, + SND_SEQ_PORT_TYPE_MIDI_GENERIC | SND_SEQ_PORT_TYPE_APPLICATION); + if (s->seq_port < 0) { + snd_seq_close(s->seq); + s->seq = NULL; + return "MIDI: could not create ALSA port"; + } + if (snd_midi_event_new(1024, &s->decoder) < 0) { + snd_seq_delete_simple_port(s->seq, s->seq_port); + snd_seq_close(s->seq); + s->seq = NULL; + return "MIDI: could not create event decoder"; + } + snd_midi_event_no_status(s->decoder, 1); /* emit full status each message */ + if (idx >= 0) { + int c = -1, p = -1; + int total = scr_midi_alsa_walk(true, idx, &c, &p, NULL, 0); + if (idx >= total) { + scr_midi_plat_in_close(s); + return "MIDI: port index out of range"; + } + s->seq_dest_client = c; + s->seq_dest_port = p; + /* Subscribe: connect the remote source to our writable port. */ + if (snd_seq_connect_from(s->seq, s->seq_port, c, p) < 0) { + scr_midi_plat_in_close(s); + return "MIDI: could not connect to input port"; + } + } + /* Register the sequencer's pollable fds with the loop poller. */ + int npfd = snd_seq_poll_descriptors_count(s->seq, POLLIN); + if (npfd > 0) { + struct pollfd *pfd = calloc((size_t)npfd, sizeof *pfd); + if (!pfd) scr_midi_oom(); + npfd = snd_seq_poll_descriptors(s->seq, pfd, (unsigned)npfd, POLLIN); + s->pfds = calloc((size_t)npfd, sizeof(int)); + if (!s->pfds) scr_midi_oom(); + s->npfds = npfd; + for (int i = 0; i < npfd; i++) { + s->pfds[i] = pfd[i].fd; + scr_midi_watch_read(pfd[i].fd, s, true); + } + free(pfd); + } + return NULL; +} + +static void scr_midi_plat_in_close(ScrMidiInput *s) { + for (int i = 0; i < s->npfds; i++) scr_midi_forget_fd(s->pfds[i]); + free(s->pfds); + s->pfds = NULL; + s->npfds = 0; + if (s->decoder) { + snd_midi_event_free(s->decoder); + s->decoder = NULL; + } + if (s->seq) { + if (s->seq_port >= 0) snd_seq_delete_simple_port(s->seq, s->seq_port); + snd_seq_close(s->seq); + s->seq = NULL; + s->seq_port = -1; + } +} + +static void scr_midi_plat_in_pump(ScrMidiInput *s) { + if (!s->seq || !s->decoder) return; + snd_seq_event_t *ev = NULL; + while (snd_seq_event_input(s->seq, &ev) >= 0 && ev != NULL) { + unsigned char buf[1024]; + long n = snd_midi_event_decode(s->decoder, buf, sizeof buf, ev); + if (n > 0) scr_midi_ring_push(s, buf, (size_t)n, scr_midi_now_ms()); + /* snd_seq_event_input returns >0 while more input is buffered; the + * loop exits when it returns -EAGAIN (no more pending). */ + } +} + +static const char *scr_midi_plat_out_open(ScrMidiOutput *s, int idx, const char *vname) { + if (snd_seq_open(&s->seq, "default", SND_SEQ_OPEN_DUPLEX, 0) < 0) + return "MIDI: could not open ALSA sequencer"; + snd_seq_set_client_name(s->seq, vname ? vname : "scriptc-output"); + /* Our port is READABLE (others read from us) so it can transmit. */ + s->seq_port = snd_seq_create_simple_port( + s->seq, vname ? vname : "scriptc-output", + SND_SEQ_PORT_CAP_READ | SND_SEQ_PORT_CAP_SUBS_READ, + SND_SEQ_PORT_TYPE_MIDI_GENERIC | SND_SEQ_PORT_TYPE_APPLICATION); + if (s->seq_port < 0) { + snd_seq_close(s->seq); + s->seq = NULL; + return "MIDI: could not create ALSA port"; + } + if (snd_midi_event_new(1024, &s->encoder) < 0) { + snd_seq_delete_simple_port(s->seq, s->seq_port); + snd_seq_close(s->seq); + s->seq = NULL; + return "MIDI: could not create event encoder"; + } + snd_midi_event_init(s->encoder); + if (idx >= 0) { + int c = -1, p = -1; + int total = scr_midi_alsa_walk(false, idx, &c, &p, NULL, 0); + if (idx >= total) { + scr_midi_plat_out_close(s); + return "MIDI: port index out of range"; + } + s->seq_dest_client = c; + s->seq_dest_port = p; + if (snd_seq_connect_to(s->seq, s->seq_port, c, p) < 0) { + scr_midi_plat_out_close(s); + return "MIDI: could not connect to output port"; + } + } + return NULL; +} + +static void scr_midi_plat_out_close(ScrMidiOutput *s) { + if (s->encoder) { + snd_midi_event_free(s->encoder); + s->encoder = NULL; + } + if (s->seq) { + if (s->seq_port >= 0) snd_seq_delete_simple_port(s->seq, s->seq_port); + snd_seq_close(s->seq); + s->seq = NULL; + s->seq_port = -1; + } +} + +static void scr_midi_plat_out_send(ScrMidiOutput *s, const unsigned char *bytes, size_t len) { + if (!s->seq || !s->encoder) return; + snd_seq_event_t ev; + size_t off = 0; + while (off < len) { + snd_seq_ev_clear(&ev); + long used = snd_midi_event_encode(s->encoder, bytes + off, (long)(len - off), &ev); + if (used <= 0) break; + off += (size_t)used; + if (ev.type == SND_SEQ_EVENT_NONE) continue; /* mid-message, no event yet */ + snd_seq_ev_set_source(&ev, s->seq_port); + snd_seq_ev_set_subs(&ev); + snd_seq_ev_set_direct(&ev); + snd_seq_event_output(s->seq, &ev); + } + snd_seq_drain_output(s->seq); +} + +/* ─────────────────────────── macOS: CoreMIDI ───────────────────────── */ +#elif SCR_MIDI_COREMIDI + +static int scr_midi_plat_count(bool is_input) { + return (int)(is_input ? MIDIGetNumberOfSources() : MIDIGetNumberOfDestinations()); +} + +static bool scr_midi_cm_name(MIDIEndpointRef ep, char *buf, size_t bufsz) { + if (ep == 0) return false; + CFStringRef cf = NULL; + if (MIDIObjectGetStringProperty(ep, kMIDIPropertyDisplayName, &cf) != noErr || !cf) + return false; + Boolean ok = CFStringGetCString(cf, buf, (CFIndex)bufsz, kCFStringEncodingUTF8); + CFRelease(cf); + return ok ? true : false; +} + +static bool scr_midi_plat_name(bool is_input, int idx, char *buf, size_t bufsz) { + ItemCount total = is_input ? MIDIGetNumberOfSources() : MIDIGetNumberOfDestinations(); + if (idx < 0 || (ItemCount)idx >= total) return false; + MIDIEndpointRef ep = + is_input ? MIDIGetSource((ItemCount)idx) : MIDIGetDestination((ItemCount)idx); + return scr_midi_cm_name(ep, buf, bufsz); +} + +/* The CoreMIDI read callback — RUNS ON A COREMIDI THREAD. It must not + * touch the runtime: it only copies bytes into the ring (libc malloc) and + * pokes the self-pipe. */ +static void scr_midi_cm_read(const MIDIPacketList *pktlist, void *readProcRefCon, + void *srcConnRefCon) { + (void)srcConnRefCon; + ScrMidiInput *s = (ScrMidiInput *)readProcRefCon; + const MIDIPacket *pkt = &pktlist->packet[0]; + double now = scr_midi_now_ms(); + for (UInt32 i = 0; i < pktlist->numPackets; i++) { + scr_midi_ring_push(s, pkt->data, pkt->length, now); + pkt = MIDIPacketNext(pkt); + } + if (s->pipe_w >= 0) { + unsigned char one = 1; + ssize_t w = write(s->pipe_w, &one, 1); /* wake the loop */ + (void)w; + } +} + +static const char *scr_midi_cm_selfpipe(ScrMidiInput *s) { + int fds[2]; + if (pipe(fds) != 0) return "MIDI: could not create wake pipe"; + fcntl(fds[0], F_SETFL, O_NONBLOCK); + fcntl(fds[0], F_SETFD, FD_CLOEXEC); + fcntl(fds[1], F_SETFD, FD_CLOEXEC); + s->pipe_r = fds[0]; + s->pipe_w = fds[1]; + scr_midi_watch_read(s->pipe_r, s, true); + return NULL; +} + +static const char *scr_midi_plat_in_open(ScrMidiInput *s, int idx, const char *vname) { + if (MIDIClientCreate(CFSTR("scriptc"), NULL, NULL, &s->client) != noErr) + return "MIDI: could not create CoreMIDI client"; + const char *pipe_err = scr_midi_cm_selfpipe(s); + if (pipe_err) { + MIDIClientDispose(s->client); + s->client = 0; + return pipe_err; + } + if (idx >= 0) { + if (MIDIInputPortCreate(s->client, CFSTR("scriptc-in"), scr_midi_cm_read, s, &s->port) != + noErr) { + scr_midi_plat_in_close(s); + return "MIDI: could not create input port"; + } + ItemCount total = MIDIGetNumberOfSources(); + if ((ItemCount)idx >= total) { + scr_midi_plat_in_close(s); + return "MIDI: port index out of range"; + } + s->endpoint = MIDIGetSource((ItemCount)idx); + if (MIDIPortConnectSource(s->port, s->endpoint, s) != noErr) { + scr_midi_plat_in_close(s); + return "MIDI: could not connect to input port"; + } + } else { + /* A virtual input is a DESTINATION we publish for others to send to. */ + CFStringRef nm = CFStringCreateWithCString(NULL, vname, kCFStringEncodingUTF8); + OSStatus rc = + MIDIDestinationCreate(s->client, nm, scr_midi_cm_read, s, &s->endpoint); + if (nm) CFRelease(nm); + if (rc != noErr) { + scr_midi_plat_in_close(s); + return "MIDI: could not create virtual input port"; + } + } + return NULL; +} + +static void scr_midi_plat_in_close(ScrMidiInput *s) { + if (s->port && s->endpoint) MIDIPortDisconnectSource(s->port, s->endpoint); + if (s->is_virtual && s->endpoint) MIDIEndpointDispose(s->endpoint); + s->endpoint = 0; + if (s->port) { + MIDIPortDispose(s->port); + s->port = 0; + } + if (s->client) { + MIDIClientDispose(s->client); + s->client = 0; + } + if (s->pipe_r >= 0) { + scr_midi_forget_fd(s->pipe_r); + close(s->pipe_r); + s->pipe_r = -1; + } + if (s->pipe_w >= 0) { + close(s->pipe_w); + s->pipe_w = -1; + } +} + +/* Loop-thread pump: the bytes are already in the ring (the read callback + * put them there); just drain the wake pipe so it stops signalling. */ +static void scr_midi_plat_in_pump(ScrMidiInput *s) { + if (s->pipe_r < 0) return; + unsigned char buf[256]; + while (read(s->pipe_r, buf, sizeof buf) > 0) { /* drain */ + } +} + +static const char *scr_midi_plat_out_open(ScrMidiOutput *s, int idx, const char *vname) { + if (MIDIClientCreate(CFSTR("scriptc"), NULL, NULL, &s->client) != noErr) + return "MIDI: could not create CoreMIDI client"; + if (idx >= 0) { + if (MIDIOutputPortCreate(s->client, CFSTR("scriptc-out"), &s->port) != noErr) { + scr_midi_plat_out_close(s); + return "MIDI: could not create output port"; + } + ItemCount total = MIDIGetNumberOfDestinations(); + if ((ItemCount)idx >= total) { + scr_midi_plat_out_close(s); + return "MIDI: port index out of range"; + } + s->endpoint = MIDIGetDestination((ItemCount)idx); + s->endpoint_is_virtual = false; + } else { + /* A virtual output is a SOURCE we publish for others to read from. */ + CFStringRef nm = CFStringCreateWithCString(NULL, vname, kCFStringEncodingUTF8); + OSStatus rc = MIDISourceCreate(s->client, nm, &s->endpoint); + if (nm) CFRelease(nm); + if (rc != noErr) { + scr_midi_plat_out_close(s); + return "MIDI: could not create virtual output port"; + } + s->endpoint_is_virtual = true; + } + return NULL; +} + +static void scr_midi_plat_out_close(ScrMidiOutput *s) { + if (s->endpoint_is_virtual && s->endpoint) MIDIEndpointDispose(s->endpoint); + s->endpoint = 0; + if (s->port) { + MIDIPortDispose(s->port); + s->port = 0; + } + if (s->client) { + MIDIClientDispose(s->client); + s->client = 0; + } +} + +static void scr_midi_plat_out_send(ScrMidiOutput *s, const unsigned char *bytes, size_t len) { + Byte storage[512 + sizeof(MIDIPacketList)]; + MIDIPacketList *pl; + Byte *heap = NULL; + if (len + sizeof(MIDIPacketList) + 16 > sizeof storage) { + heap = malloc(len + sizeof(MIDIPacketList) + 16); + if (!heap) scr_midi_oom(); + pl = (MIDIPacketList *)heap; + } else { + pl = (MIDIPacketList *)storage; + } + MIDIPacket *pkt = MIDIPacketListInit(pl); + pkt = MIDIPacketListAdd( + pl, len + sizeof(MIDIPacketList) + 16, pkt, mach_absolute_time(), len, bytes); + if (pkt) { + if (s->endpoint_is_virtual) MIDIReceived(s->endpoint, pl); /* publish on the source */ + else MIDISend(s->port, s->endpoint, pl); + } + free(heap); +} + +/* ─────────────────────────── Windows: WinMM ────────────────────────── */ +#elif SCR_MIDI_WINMM + +static int scr_midi_plat_count(bool is_input) { + return (int)(is_input ? midiInGetNumDevs() : midiOutGetNumDevs()); +} + +static bool scr_midi_plat_name(bool is_input, int idx, char *buf, size_t bufsz) { + if (idx < 0) return false; + if (is_input) { + MIDIINCAPSA caps; + if ((UINT)idx >= midiInGetNumDevs()) return false; + if (midiInGetDevCapsA((UINT_PTR)idx, &caps, sizeof caps) != MMSYSERR_NOERROR) return false; + snprintf(buf, bufsz, "%s", caps.szPname); + } else { + MIDIOUTCAPSA caps; + if ((UINT)idx >= midiOutGetNumDevs()) return false; + if (midiOutGetDevCapsA((UINT_PTR)idx, &caps, sizeof caps) != MMSYSERR_NOERROR) return false; + snprintf(buf, bufsz, "%s", caps.szPname); + } + return true; +} + +/* A loopback socketpair — the win32 self-pipe over WSAPoll (scr_loop_ + * wsapoll.c watches SOCKETs). Producer (the WinMM callback thread) writes + * one byte; the loop drains the read end. */ +static int scr_midi_win_selfpipe(int fds[2]) { + SOCKET listener = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); + if (listener == INVALID_SOCKET) return -1; + struct sockaddr_in a; + memset(&a, 0, sizeof a); + a.sin_family = AF_INET; + a.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + a.sin_port = 0; + int len = sizeof a; + if (bind(listener, (struct sockaddr *)&a, len) != 0 || listen(listener, 1) != 0 || + getsockname(listener, (struct sockaddr *)&a, &len) != 0) { + closesocket(listener); + return -1; + } + SOCKET w = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); + if (w == INVALID_SOCKET || connect(w, (struct sockaddr *)&a, len) != 0) { + closesocket(listener); + if (w != INVALID_SOCKET) closesocket(w); + return -1; + } + SOCKET r = accept(listener, NULL, NULL); + closesocket(listener); + if (r == INVALID_SOCKET) { + closesocket(w); + return -1; + } + u_long one = 1; + ioctlsocket(r, FIONBIO, &one); + fds[0] = (int)r; + fds[1] = (int)w; + return 0; +} + +/* The WinMM input callback — RUNS OFF-THREAD. Heap-free: copy to the ring, + * poke the pipe. */ +static void CALLBACK scr_midi_win_in_cb(HMIDIIN h, UINT msg, DWORD_PTR inst, DWORD_PTR p1, + DWORD_PTR p2) { + (void)h; + (void)p2; + ScrMidiInput *s = (ScrMidiInput *)inst; + double now = scr_midi_now_ms(); + if (msg == MIM_DATA) { + unsigned char b[3]; + DWORD dw = (DWORD)p1; + b[0] = (unsigned char)(dw & 0xFF); + b[1] = (unsigned char)((dw >> 8) & 0xFF); + b[2] = (unsigned char)((dw >> 16) & 0xFF); + /* Length by status: 1 byte for realtime/0xF*, else 2 or 3. Keep the + * full 3 — the ignoreTypes filter and the JS consumer read the run; + * trailing zero bytes on a 2-byte message are harmless for the common + * decoders, but trim by status class for correctness. */ + size_t n = 3; + unsigned char st = b[0]; + if (st >= 0xF8) n = 1; /* system realtime */ + else if ((st & 0xF0) == 0xC0 || (st & 0xF0) == 0xD0) n = 2; /* program/chanpress */ + else if (st == 0xF1 || st == 0xF3) n = 2; /* MTC / song select */ + scr_midi_ring_push(s, b, n, now); + } else if (msg == MIM_LONGDATA) { + MIDIHDR *hdr = (MIDIHDR *)p1; + if (hdr && hdr->dwBytesRecorded > 0) + scr_midi_ring_push(s, (unsigned char *)hdr->lpData, hdr->dwBytesRecorded, now); + /* re-queue the sysex buffer */ + if (hdr) midiInAddBuffer(s->h, hdr, sizeof *hdr); + } else { + return; + } + if (s->pipe_w >= 0) { + char one = 1; + send((SOCKET)s->pipe_w, &one, 1, 0); + } +} + +static const char *scr_midi_plat_in_open(ScrMidiInput *s, int idx, const char *vname) { + (void)vname; + if (idx < 0) return "MIDI: virtual ports are not supported on Windows (WinMM)"; + if ((UINT)idx >= midiInGetNumDevs()) return "MIDI: port index out of range"; + int fds[2]; + if (scr_midi_win_selfpipe(fds) != 0) return "MIDI: could not create wake pipe"; + s->pipe_r = fds[0]; + s->pipe_w = fds[1]; + scr_midi_watch_read(s->pipe_r, s, true); + if (midiInOpen(&s->h, (UINT)idx, (DWORD_PTR)scr_midi_win_in_cb, (DWORD_PTR)s, + CALLBACK_FUNCTION) != MMSYSERR_NOERROR) { + scr_midi_plat_in_close(s); + return "MIDI: could not open input port"; + } + memset(&s->sysex_hdr, 0, sizeof s->sysex_hdr); + s->sysex_hdr.lpData = s->sysex_buf; + s->sysex_hdr.dwBufferLength = sizeof s->sysex_buf; + midiInPrepareHeader(s->h, &s->sysex_hdr, sizeof s->sysex_hdr); + midiInAddBuffer(s->h, &s->sysex_hdr, sizeof s->sysex_hdr); + midiInStart(s->h); + return NULL; +} + +static void scr_midi_plat_in_close(ScrMidiInput *s) { + if (s->h) { + midiInStop(s->h); + midiInReset(s->h); + midiInUnprepareHeader(s->h, &s->sysex_hdr, sizeof s->sysex_hdr); + midiInClose(s->h); + s->h = NULL; + } + if (s->pipe_r >= 0) { + scr_midi_forget_fd(s->pipe_r); + closesocket((SOCKET)s->pipe_r); + s->pipe_r = -1; + } + if (s->pipe_w >= 0) { + closesocket((SOCKET)s->pipe_w); + s->pipe_w = -1; + } +} + +static void scr_midi_plat_in_pump(ScrMidiInput *s) { + if (s->pipe_r < 0) return; + char buf[256]; + while (recv((SOCKET)s->pipe_r, buf, sizeof buf, 0) > 0) { /* drain */ + } +} + +static const char *scr_midi_plat_out_open(ScrMidiOutput *s, int idx, const char *vname) { + (void)vname; + if (idx < 0) return "MIDI: virtual ports are not supported on Windows (WinMM)"; + if ((UINT)idx >= midiOutGetNumDevs()) return "MIDI: port index out of range"; + if (midiOutOpen(&s->h, (UINT)idx, 0, 0, CALLBACK_NULL) != MMSYSERR_NOERROR) + return "MIDI: could not open output port"; + return NULL; +} + +static void scr_midi_plat_out_close(ScrMidiOutput *s) { + if (s->h) { + midiOutReset(s->h); + midiOutClose(s->h); + s->h = NULL; + } +} + +static void scr_midi_plat_out_send(ScrMidiOutput *s, const unsigned char *bytes, size_t len) { + if (!s->h) return; + if (len <= 3 && bytes[0] != 0xF0) { + DWORD dw = 0; + for (size_t i = 0; i < len; i++) dw |= (DWORD)bytes[i] << (8 * i); + midiOutShortMsg(s->h, dw); + } else { + MIDIHDR hdr; + memset(&hdr, 0, sizeof hdr); + hdr.lpData = (LPSTR)bytes; + hdr.dwBufferLength = (DWORD)len; + hdr.dwBytesRecorded = (DWORD)len; + if (midiOutPrepareHeader(s->h, &hdr, sizeof hdr) == MMSYSERR_NOERROR) { + midiOutLongMsg(s->h, &hdr, sizeof hdr); + midiOutUnprepareHeader(s->h, &hdr, sizeof hdr); + } + } +} + +/* ─────────────────────────── stub (no backend) ─────────────────────── */ +#else + +static int scr_midi_plat_count(bool is_input) { + (void)is_input; + return 0; +} +static bool scr_midi_plat_name(bool is_input, int idx, char *buf, size_t bufsz) { + (void)is_input; + (void)idx; + (void)buf; + (void)bufsz; + return false; +} +static const char *scr_midi_plat_in_open(ScrMidiInput *s, int idx, const char *vname) { + (void)s; + (void)idx; + (void)vname; + return "MIDI: no MIDI backend on this platform"; +} +static void scr_midi_plat_in_close(ScrMidiInput *s) { (void)s; } +static void scr_midi_plat_in_pump(ScrMidiInput *s) { (void)s; } +static const char *scr_midi_plat_out_open(ScrMidiOutput *s, int idx, const char *vname) { + (void)s; + (void)idx; + (void)vname; + return "MIDI: no MIDI backend on this platform"; +} +static void scr_midi_plat_out_close(ScrMidiOutput *s) { (void)s; } +static void scr_midi_plat_out_send(ScrMidiOutput *s, const unsigned char *bytes, size_t len) { + (void)s; + (void)bytes; + (void)len; +} + +#endif /* backend selection */ diff --git a/packages/runtime/src/scr_path.c b/packages/runtime/src/scr_path.c index 0a21f3eb3..d9f11e209 100644 --- a/packages/runtime/src/scr_path.c +++ b/packages/runtime/src/scr_path.c @@ -22,7 +22,12 @@ #include #include #include +#ifndef _MSC_VER #include +#else +#include /* _getcwd */ +#define getcwd _getcwd +#endif /* ── a tiny growable byte buffer ─────────────────────────────────────── */ diff --git a/packages/runtime/src/scr_runtime.h b/packages/runtime/src/scr_runtime.h index d87732777..5bfd8ef7f 100644 --- a/packages/runtime/src/scr_runtime.h +++ b/packages/runtime/src/scr_runtime.h @@ -13,7 +13,12 @@ #include #include #include /* memcpy in the inline slot accessors */ +#ifdef _MSC_VER +#include /* SSIZE_T on MSVC */ +typedef SSIZE_T ssize_t; +#else #include /* ssize_t in the transport ops table */ +#endif /* ── libc shims ───────────────────────────────────────────────────────── * Win32's missing POSIX/BSD functions live in scr_win.c. Zig's musl sysroot @@ -25,6 +30,44 @@ char *stpcpy(char *dst, const char *src); void arc4random_buf(void *buf, size_t n); struct tm *gmtime_r(const time_t *t, struct tm *out); char *strcasestr(const char *hay, const char *needle); +#ifdef _MSC_VER +#include /* _MAX_PATH */ +#include /* _S_IFMT, _S_IFDIR, _S_IFREG */ +#ifndef PATH_MAX +#define PATH_MAX _MAX_PATH +#endif +#ifndef F_OK +#define F_OK 0 +#endif +#ifndef S_ISDIR +#define S_ISDIR(m) (((m) & _S_IFMT) == _S_IFDIR) +#endif +#ifndef S_ISREG +#define S_ISREG(m) (((m) & _S_IFMT) == _S_IFREG) +#endif +#ifndef S_ISLNK +#define S_ISLNK(m) (0) +#endif +#ifndef S_ISFIFO +#define S_ISFIFO(m) (0) +#endif +#ifndef S_ISSOCK +#define S_ISSOCK(m) (0) +#endif +#ifndef S_ISBLK +#define S_ISBLK(m) (0) +#endif +#ifndef S_ISCHR +#define S_ISCHR(m) (0) +#endif +#ifndef _mode_t_defined +typedef unsigned int mode_t; +#endif +#define CLOCK_REALTIME 0 +#define CLOCK_MONOTONIC 1 +int clock_gettime(int clk_id, struct timespec *ts); +int nanosleep(const struct timespec *req, struct timespec *rem); +#endif /* _MSC_VER */ #elif defined(SCR_MUSL) void arc4random_buf(void *buf, size_t n); #endif @@ -6171,6 +6214,72 @@ long scr_dgram_live_count(void); * hook's exact shape, one more nullable slot set. */ void scr_loop_set_dgram(bool (*pending)(void), void (*dispatch)(void), int (*pollfd)(void)); +/* ── node:midi (scr_midi.c — compiled only when the program uses it; + * design note atop the file). Two lean refcounted handle kinds modeled on + * ScrDgramSocket: ScrMidiInput (a live, pollable source — an OPEN input + * holds the loop, like a bound socket) and ScrMidiOutput (fire-and-forget, + * like a connected sender). Both start with a ScrMidiKind tag as their + * first member so the shared void*-handle ABI symbols route on it. The + * ALSA/CoreMIDI/WinMM backends live behind platform guards; off-thread + * platform callbacks (CoreMIDI/WinMM) only fill a lock-guarded ring and + * poke a self-pipe — all JS-visible work runs in scr_midi_dispatch on the + * loop thread. Self-contained: no symbol here needs scr_dgram.c to link. */ +typedef struct ScrMidiInput ScrMidiInput; +typedef struct ScrMidiOutput ScrMidiOutput; +/* The 'message' adapter (the dgram thunk family): deltaTime in SECONDS as + * the leading f64, the byte run as a number[] (SCR_ELEM_F64) delivered + * BORROWED (multiple listeners see one message; the two-param adapter + * retains for its listener's owned param). */ +typedef void (*ScrMidiMsgFn)(ScrClosure *cb, double deltaTime, ScrArr *message); + +/* Refcount entry points the compiler emits per handle kind (the + * scr_dgram_retain/_v pair, one set per struct). */ +ScrMidiInput *scr_midi_input_retain(ScrMidiInput *s); +void scr_midi_input_release(ScrMidiInput *s); +void *scr_midi_input_retain_v(void *p); +void scr_midi_input_release_v(void *p); +ScrMidiOutput *scr_midi_output_retain(ScrMidiOutput *s); +void scr_midi_output_release(ScrMidiOutput *s); +void *scr_midi_output_retain_v(void *p); +void scr_midi_output_release_v(void *p); + +ScrMidiInput *scr_midi_input_new(void); /* +1 */ +ScrMidiOutput *scr_midi_output_new(void); /* +1 */ +/* Enumeration works on a fresh handle before openPort (node-midi's + * enumerate-then-open). is_input selects the input vs output namespace + * (the frozen ABI passes it explicitly); port_name reads the handle tag + * and returns "" for an out-of-range index (node-midi's answer). */ +double scr_midi_port_count(void *handle, bool is_input); +ScrStr *scr_midi_port_name(void *handle, double idx); /* +1 */ +void scr_midi_open_port(void *handle, double idx); /* throws on bad index */ +void scr_midi_open_virtual(void *handle, ScrStr *name /*borrowed*/); /* throws on WinMM */ +void scr_midi_close_port(void *handle); +bool scr_midi_is_open(void *handle); +void scr_midi_ignore_types(ScrMidiInput *s, bool sysex, bool timing, bool sense); +/* send: the frozen ABI primitive is the raw byte pointer + length; the + * _array (number[]) and _bytes (Uint8Array) forms marshal to it — the two + * accepted argument shapes. All borrowed. Throws on an unopened port. */ +void scr_midi_send(ScrMidiOutput *s, const uint8_t *bytes /*borrowed*/, double len); +void scr_midi_send_array(ScrMidiOutput *s, ScrArr *message /*borrowed*/); +void scr_midi_send_bytes(ScrMidiOutput *s, ScrBytes *message /*borrowed*/); +/* on('message')/once('message'): cb MOVES in, fn is the arity adapter + * (scr_midi_msg_thunk0/1/2). See the ABI note below — this carries an fn + * adapter argument the §4 draft table omitted (the dgram on_message + * precedent), so a 0/1/2-param listener is never called with a mismatched + * C signature. */ +void scr_midi_on_message(ScrMidiInput *s, ScrClosure *cb /*moves*/, ScrMidiMsgFn fn, bool once); +/* The runtime-provided message adapters (zero/one/two-param listeners). */ +void scr_midi_msg_thunk0(ScrClosure *cb, double deltaTime, ScrArr *message); +void scr_midi_msg_thunk1(ScrClosure *cb, double deltaTime, ScrArr *message); +void scr_midi_msg_thunk2(ScrClosure *cb, double deltaTime, ScrArr *message); +void scr_midi_install(void); +#ifdef SCR_RC_AUDIT +long scr_midi_live_count(void); +#endif +/* The loop-side registration (scr_async.c, always linked) — the dgram + * hook's exact shape, one more nullable slot set. */ +void scr_loop_set_midi(bool (*pending)(void), void (*dispatch)(void), int (*pollfd)(void)); + /* ── fs.watch (scr_watch.c — compiled only when the program uses it; * design note atop the file). FSWatcher handles over the unit's own * event backend (kqueue EVFILT_VNODE on macOS/BSD, inotify on Linux): diff --git a/packages/runtime/src/scr_url.c b/packages/runtime/src/scr_url.c index b0abc6410..d94f99d59 100644 --- a/packages/runtime/src/scr_url.c +++ b/packages/runtime/src/scr_url.c @@ -33,7 +33,9 @@ #include #include #include +#ifndef _MSC_VER #include +#endif #ifdef _WIN32 #include #include diff --git a/packages/runtime/src/scr_win.c b/packages/runtime/src/scr_win.c index 3ce9535bf..276f70f64 100644 --- a/packages/runtime/src/scr_win.c +++ b/packages/runtime/src/scr_win.c @@ -13,6 +13,116 @@ #include #include +/* ── MSVC POSIX shims ──────────────────────────────────────────────── + * mingw-w64 provides POSIX headers/functions (unistd.h, dirent.h, + * clock_gettime, nanosleep). MSVC's CRT does not — these shims + * bridge the gap so the runtime compiles under both toolchains. */ +#ifdef _MSC_VER + +#ifndef CLOCK_REALTIME +#define CLOCK_REALTIME 0 +#endif +#ifndef CLOCK_MONOTONIC +#define CLOCK_MONOTONIC 1 +#endif + +int clock_gettime(int clk_id, struct timespec *ts) { + (void)clk_id; + /* QueryPerformanceCounter is the only high-res monotonic clock on + * Windows; its epoch is arbitrary but monotonic — sufficient for + * elapsed-time measurements. For CLOCK_REALTIME we use + * GetSystemTimeAsFileTime which is UTC since 1601. */ + if (clk_id == CLOCK_REALTIME) { + FILETIME ft; + GetSystemTimeAsFileTime(&ft); + ULARGE_INTEGER li; + li.LowPart = ft.dwLowDateTime; + li.HighPart = ft.dwHighDateTime; + /* FILETIME is 100-ns intervals since 1601-01-01. + * Unix epoch offset: 11644473600 seconds = 116444736000000000 * 100ns. */ + li.QuadPart -= 116444736000000000ULL; + ts->tv_sec = (time_t)(li.QuadPart / 10000000ULL); + ts->tv_nsec = (long)((li.QuadPart % 10000000ULL) * 100); + return 0; + } + /* CLOCK_MONOTONIC — QueryPerformanceCounter. */ + static LARGE_INTEGER freq = {0}; + if (freq.QuadPart == 0) QueryPerformanceFrequency(&freq); + LARGE_INTEGER now; + QueryPerformanceCounter(&now); + ts->tv_sec = (time_t)(now.QuadPart / freq.QuadPart); + ts->tv_nsec = (long)((now.QuadPart % freq.QuadPart) * 1000000000LL / freq.QuadPart); + return 0; +} + +int nanosleep(const struct timespec *req, struct timespec *rem) { + if (rem) { rem->tv_sec = 0; rem->tv_nsec = 0; } + /* Sleep takes milliseconds; ceil to avoid sleeping too short. */ + DWORD ms = (DWORD)(req->tv_sec * 1000 + (req->tv_nsec + 999999) / 1000000); + if (ms == 0) ms = 1; /* Sleep(0) yields the timeslice */ + Sleep(ms); + return 0; +} + +/* Minimal shim for MSVC — provides opendir/readdir/closedir + * and the d_type constants over FindFirstFileW/FindNextFileW. Enough + * for scr_lib.c's readdir loops; not a full POSIX emulation. */ +#include + +struct dirent { + char d_name[260]; + unsigned char d_type; +}; + +enum { DT_REG = 8, DT_DIR = 4 }; + +typedef struct { + HANDLE hFind; + WIN32_FIND_DATAW fdata; + struct dirent entry; + int first; +} DIR; + +DIR *opendir(const char *path) { + DIR *d = (DIR *)malloc(sizeof *d); + if (!d) return NULL; + /* Build wildcard path: "path\*" */ + wchar_t wpath[MAX_PATH * 2]; + MultiByteToWideChar(CP_UTF8, 0, path, -1, wpath, MAX_PATH); + wcscat(wpath, L"\\*"); + d->hFind = FindFirstFileW(wpath, &d->fdata); + d->first = 1; + if (d->hFind == INVALID_HANDLE_VALUE) { free(d); return NULL; } + return d; +} + +struct dirent *readdir(DIR *d) { + for (;;) { + if (d->first) { d->first = 0; } + else if (!FindNextFileW(d->hFind, &d->fdata)) { return NULL; } + /* Skip . and .. */ + if (d->fdata.cFileName[0] == L'.' && + (d->fdata.cFileName[1] == L'\0' || + (d->fdata.cFileName[1] == L'.' && d->fdata.cFileName[2] == L'\0'))) + continue; + WideCharToMultiByte(CP_UTF8, 0, d->fdata.cFileName, -1, + d->entry.d_name, sizeof d->entry.d_name, NULL, NULL); + d->entry.d_name[sizeof d->entry.d_name - 1] = '\0'; + d->entry.d_type = (d->fdata.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) + ? DT_DIR : DT_REG; + return &d->entry; + } +} + +int closedir(DIR *d) { + if (!d) return -1; + if (d->hFind != INVALID_HANDLE_VALUE) FindClose(d->hFind); + free(d); + return 0; +} + +#endif /* _MSC_VER */ + /* POSIX.1-2008 stpcpy: strcpy returning the END of the copy — scr_number.c * (untouchable by project rule; ryu-adjacent) builds "e+"/"e-" exponent * tails with it. */ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fc0566257..6fa1f3148 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: eslint: specifier: ^9.20.0 version: 9.39.5 + midi: + specifier: npm:@julusian/midi@^3.8.1 + version: '@julusian/midi@3.8.1' tsx: specifier: ^4.19.0 version: 4.23.0 @@ -476,6 +479,10 @@ packages: '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + '@julusian/midi@3.8.1': + resolution: {integrity: sha512-T+Ecn2pWTFu0G81PUa64Tk7yqzS6KlW61BKIaWVCBeXKXtXQ8ARgn6NcqmH6kABOM3FA8DV7XaPcw7VDBtgxKQ==} + engines: {node: '>=14.15'} + '@mapbox/node-pre-gyp@2.0.3': resolution: {integrity: sha512-uwPAhccfFJlsfCxMYTwOdVfOz3xqyj8xYL3zJj8f0pb30tLohnnFPhLuqp4/qoEz8sNxe4SESZedcBojRefIzg==} engines: {node: '>=18'} @@ -1939,6 +1946,9 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + node-addon-api@6.1.0: + resolution: {integrity: sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==} + node-fetch@2.6.7: resolution: {integrity: sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==} engines: {node: 4.x || >=6.0.0} @@ -2077,6 +2087,11 @@ packages: resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} engines: {node: '>=12'} + pkg-prebuilds@1.1.0: + resolution: {integrity: sha512-jyai+KTQ2OwbN6iRYw88XbYOMgtpoSYJpjYebx7d9ihqz3txNi3ucsBt3va0iVWe6svSlaqpijMHFF/eJCMZzg==} + engines: {node: '>= 14.15.0'} + hasBin: true + postcss@8.5.16: resolution: {integrity: sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==} engines: {node: ^10 || ^12 || >=14} @@ -2805,6 +2820,12 @@ snapshots: '@jridgewell/sourcemap-codec@1.5.5': {} + '@julusian/midi@3.8.1': + dependencies: + node-addon-api: 6.1.0 + pkg-prebuilds: 1.1.0 + tslib: 2.8.1 + '@mapbox/node-pre-gyp@2.0.3': dependencies: consola: 3.4.2 @@ -4260,6 +4281,8 @@ snapshots: natural-compare@1.4.0: {} + node-addon-api@6.1.0: {} + node-fetch@2.6.7: dependencies: whatwg-url: 5.0.0 @@ -4386,6 +4409,8 @@ snapshots: picomatch@4.0.5: {} + pkg-prebuilds@1.1.0: {} + postcss@8.5.16: dependencies: nanoid: 3.3.15 @@ -4645,8 +4670,7 @@ snapshots: ts-toolbelt@6.15.5: {} - tslib@2.8.1: - optional: true + tslib@2.8.1: {} tsx@4.21.0: dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 767319b2e..29c80c3e1 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -3,6 +3,7 @@ packages: minimumReleaseAgeExclude: - "vercel@58.1.0" allowBuilds: + "@julusian/midi": true esbuild: true overrides: "@napi-rs/wasm-runtime": "1.1.6" diff --git a/tests/corpus/2352-void-coercions.ts b/tests/corpus/2352-void-coercions.ts index 2f333667f..47d296a10 100644 --- a/tests/corpus/2352-void-coercions.ts +++ b/tests/corpus/2352-void-coercions.ts @@ -46,6 +46,13 @@ const box = { box.poke(); console.log("method unit return ok"); +// Concise void-returning arrows over conditional void calls lower as a +// branch, not a value ternary. +const branchVoid = (flag: boolean) => (flag ? box.poke() : fv()); +branchVoid(true); +branchVoid(false); +console.log("branch-void", effects); + // Async concise body over an existing promise: resolves through. async function inner(): Promise { return 42; diff --git a/tests/corpus/2607-math-dsp-static.js b/tests/corpus/2607-math-dsp-static.js new file mode 100644 index 000000000..fcddc7816 --- /dev/null +++ b/tests/corpus/2607-math-dsp-static.js @@ -0,0 +1,14 @@ +// The DSP-oriented scalar Math surface compiles without the dynamic engine. +// Transcendentals print at a precision that is stable across V8's fdlibm and +// the target libc while fround and the constants pin exact JavaScript values. +console.log( + Math.sin(1).toFixed(9), + Math.cos(1).toFixed(9), + Math.sqrt(2).toFixed(9), + Math.exp(1).toFixed(9), + Math.log(10).toFixed(9), + Math.pow(2, 0.5).toFixed(9), +); +console.log(Math.PI.toFixed(12), Math.E.toFixed(12)); +console.log(Math.fround(1 / 3), Math.fround(16777217), 1 / Math.fround(-0)); +console.log(Math.sqrt(-1), Math.log(0), Math.pow(0, -1)); diff --git a/tests/coverage-fixtures/dynamic-mix.ts b/tests/coverage-fixtures/dynamic-mix.ts index 940a289cc..88f368406 100644 --- a/tests/coverage-fixtures/dynamic-mix.ts +++ b/tests/coverage-fixtures/dynamic-mix.ts @@ -5,7 +5,7 @@ // fixes. const v: any = 21; const doubled = v * 2; -const root = Math.sqrt(81); +const root = Math.cbrt(27); const up = (19.99).toPrecision(3); const parsed = Number.parseFloat("1.5"); // the global's string form is static now; the Number static keeps the island const raw = __island_eval("6 * 7"); diff --git a/tests/coverage-fixtures/midi-enumerate.ts b/tests/coverage-fixtures/midi-enumerate.ts new file mode 100644 index 000000000..e0b25b6c3 --- /dev/null +++ b/tests/coverage-fixtures/midi-enumerate.ts @@ -0,0 +1,24 @@ +// A fully static node:midi enumerate program: construct the port handles, +// read the port counts (node-midi allows enumeration on a fresh handle +// before openPort), print them, and close. No dynamic remainder — every +// statement lowers, so coverage must pin it at 100% static. +import { Input, Output } from "midi"; + +const input = new Input(); +const output = new Output(); + +const inputPorts = input.getPortCount(); +const outputPorts = output.getPortCount(); + +console.log("inputs", inputPorts); +console.log("outputs", outputPorts); + +for (let i = 0; i < inputPorts; i++) { + console.log("input", i, input.getPortName(i)); +} +for (let i = 0; i < outputPorts; i++) { + console.log("output", i, output.getPortName(i)); +} + +input.closePort(); +output.closePort(); diff --git a/tests/diagnostics/dynamic-surface.ts b/tests/diagnostics/dynamic-surface.ts index ad7cb07f6..40203b503 100644 --- a/tests/diagnostics/dynamic-surface.ts +++ b/tests/diagnostics/dynamic-surface.ts @@ -7,8 +7,8 @@ // trim/pad variants, parseInt, isNaN, and the global parseFloat/isFinite // over exactly-typed arguments compile statically now and no longer // appear here.) -const up = Math.sqrt(2); -const tau = Math.PI * 2; +const up = Math.cbrt(8); +const tau = Math.atan2(0, -1) * 2; const price = (19.99).toPrecision(4); const swapped = "banana".replace("an", "AN"); const ch = "hello".at(0); diff --git a/tests/diagnostics/json-dyn.ts b/tests/diagnostics/json-dyn.ts index 96cd37c6f..edd5cf012 100644 --- a/tests/diagnostics/json-dyn.ts +++ b/tests/diagnostics/json-dyn.ts @@ -35,7 +35,7 @@ function localCapture(): () => number { return () => local as number; } class Holder { - data: unknown = JSON.parse("{}"); + data: unknown = JSON.parse("{}"); // unknown class fields compile as dyn storage now — no fence } const anything: any = 5; // checker-`any` bindings ride the checked-dynamic tree now — no fence const dynArray: unknown[] = []; // unknown[] IS the dyn array now — no fence (corpus 2585) @@ -49,7 +49,6 @@ function mkMaybe(): string | undefined { return undefined; } const stringifyUndef = JSON.stringify(mkMaybe()); - // Reached: unreached bodies never lower, so their rejections only exist // when something on the entry path uses them. localCapture(); diff --git a/tests/diagnostics/midi.ts b/tests/diagnostics/midi.ts new file mode 100644 index 000000000..7c3861829 --- /dev/null +++ b/tests/diagnostics/midi.ts @@ -0,0 +1,29 @@ +// node:midi lowering boundaries: what stays rejected at LOWERING with +// specific messages. The fallback declarations type the port surface +// exactly, so most misuse (a "clock" event, a string sendMessage, a wrong +// listener arity) is a type error before lowering; these are the forms that +// TYPECHECK and fence per site — the SC2020 lib fence for a message shape no +// marshaler lowers, and the SC1090 statement-position rule the dgram spoke +// shares. Each site is its own statement so all four diagnostics collect. + +import { Input, Output } from "midi"; + +const output = new Output(); + +// The static type calls this a number[], but the runtime shape is a string: +// only a cast reaches the byte-transparent marshaler fence (a number[] rides +// sendArray, a Uint8Array rides sendBytes, and nothing else lowers). +output.sendMessage("nope" as unknown as number[]); + +const input = new Input(); + +// Port calls return void — Node returns void here too — so their result +// cannot feed a binding; call them as their own statement. +const opened = input.openPort(0); + +// A message listener is called as void; an ANNOTATED value-returning arrow +// keeps its word and stays fenced (the child_process listener rule exactly). +input.on("message", (deltaTime): number => deltaTime); + +// A void-result port call in argument position is not a statement either. +console.log(input.closePort()); diff --git a/tests/fixtures/midi/cases/virtual-loopback/main.ts b/tests/fixtures/midi/cases/virtual-loopback/main.ts new file mode 100644 index 000000000..a9a2c5ce0 --- /dev/null +++ b/tests/fixtures/midi/cases/virtual-loopback/main.ts @@ -0,0 +1,57 @@ +// The hardware-free MIDI differential: a virtual-port loopback. An open +// virtual Output and an Input connected to it live in one process, so no +// real device is needed — but the pair still requires a POSIX MIDI backend +// with virtual ports (ALSA sequencer / CoreMIDI), which CI here does not +// have, so tests/harness/midi.test.ts GATES this case and skips it when no +// backend is present. On a host that has one it runs under both Node (the +// @julusian/midi dev-dep aliased to "midi") and the native binary, and the +// two stdouts must match byte-for-byte. +// +// Determinism: deltaTime is wall-clock time between messages and is NEVER +// printed; only the received message bytes are, one line per message. Ports +// are located by NAME, not index, since index ordering varies across hosts. +import { Input, Output } from "midi"; + +const PORT_NAME = "scriptc-loopback"; + +const output = new Output(); +output.openVirtualPort(PORT_NAME); + +const input = new Input(); + +// Locate the virtual output by name (index ordering is host-dependent). +let portIndex = -1; +const portCount = input.getPortCount(); +for (let i = 0; i < portCount; i++) { + if (input.getPortName(i).includes(PORT_NAME)) { + portIndex = i; + break; + } +} + +// Deliver everything (do not drop SysEx/timing/sense) so the byte stream is +// exactly what was sent. +input.ignoreTypes(false, false, false); + +const messages: number[][] = [ + [0x90, 60, 100], // note on, channel 1 + [0xb0, 7, 64], // control change (volume) + [0x80, 60, 0], // note off, channel 1 +]; + +let received = 0; +input.on("message", (_deltaTime, message) => { + // Print only the bytes — never the nondeterministic deltaTime. + console.log(message.join(" ")); + received += 1; + if (received === messages.length) { + // The open input holds the loop alive; closing both drains it and exits. + input.closePort(); + output.closePort(); + } +}); + +input.openPort(portIndex); +for (const m of messages) { + output.sendMessage(m); +} diff --git a/tests/harness/__snapshots__/coverage-dynamic-mix.txt b/tests/harness/__snapshots__/coverage-dynamic-mix.txt index 99b414c4d..1a44c1ace 100644 --- a/tests/harness/__snapshots__/coverage-dynamic-mix.txt +++ b/tests/harness/__snapshots__/coverage-dynamic-mix.txt @@ -6,7 +6,7 @@ scriptc coverage tests/coverage-fixtures/dynamic-mix.ts runs with --dynamic 5 sites (embeds a JS engine, ~620KB — static stays the default) ×1 '__island_eval' requires the embedded dynamic engine, which this build does not include SC2010 ×1 the '*' operator on 'any'-typed values runs in the embedded dynamic engine, which this build does not include SC2011 - ×1 'Math.sqrt' runs in the embedded dynamic engine, which this build does not include SC2012 + ×1 'Math.cbrt' runs in the embedded dynamic engine, which this build does not include SC2012 ×1 '.toPrecision()' on numbers runs in the embedded dynamic engine, which this build does not include SC2012 ×1 'Number.parseFloat' runs in the embedded dynamic engine, which this build does not include SC2012 diff --git a/tests/harness/__snapshots__/coverage-npm-lazy-builtin.txt b/tests/harness/__snapshots__/coverage-npm-lazy-builtin.txt index f3a9b4d7b..57f145b7b 100644 --- a/tests/harness/__snapshots__/coverage-npm-lazy-builtin.txt +++ b/tests/harness/__snapshots__/coverage-npm-lazy-builtin.txt @@ -6,9 +6,10 @@ scriptc coverage tests/fixtures/npm/cases/esbuild-require/main.ts embedded npm code imports Node builtins: node:http2 not shimmed — lazy trap (esbundled) - node:module shimmed (esbundled) + node:module partial (esbundled) node:os shimmed (esbundled) node:tty shimmed (esbundled) + (partial: the shim exists but covers only part of Node's surface; unsupported members throw at the call) (lazy trap: only reachable through require()/import() boundaries — the build embeds Node's call-time error; the call throws at runtime) builds with --dynamic — no remaining blockers (the island sites above run in the embedded engine). \ No newline at end of file diff --git a/tests/harness/__snapshots__/coverage-npm-partial-builtin.txt b/tests/harness/__snapshots__/coverage-npm-partial-builtin.txt new file mode 100644 index 000000000..78dd59ca6 --- /dev/null +++ b/tests/harness/__snapshots__/coverage-npm-partial-builtin.txt @@ -0,0 +1,11 @@ +scriptc coverage tests/fixtures/npm/cases/crypto-shims/main.ts + + statements analyzed 3 + compile statically 2 (66%) + compile dynamically 1 (33%) (island sites — the embedded engine runs them) + + embedded npm code imports Node builtins: + node:crypto partial (cryptozoo) + (partial: the shim exists but covers only part of Node's surface; unsupported members throw at the call) + + builds with --dynamic — no remaining blockers (the island sites above run in the embedded engine). \ No newline at end of file diff --git a/tests/harness/__snapshots__/dynamic-surface.ts.txt b/tests/harness/__snapshots__/dynamic-surface.ts.txt index 47310acd2..eee5f808f 100644 --- a/tests/harness/__snapshots__/dynamic-surface.ts.txt +++ b/tests/harness/__snapshots__/dynamic-surface.ts.txt @@ -1,24 +1,24 @@ -dynamic-surface.ts:10:12 - error SC2012: 'Math.sqrt' runs in the embedded dynamic engine, which this build does not include +dynamic-surface.ts:10:12 - error SC2012: 'Math.cbrt' runs in the embedded dynamic engine, which this build does not include 9 | // appear here.) - 10 | const up = Math.sqrt(2); + 10 | const up = Math.cbrt(8); | ^~~~~~~~~~~~ - 11 | const tau = Math.PI * 2; + 11 | const tau = Math.atan2(0, -1) * 2; hint: build with --dynamic to run this call in the embedded engine (adds ~620KB to the binary); static builds never include it -dynamic-surface.ts:11:13 - error SC2012: 'Math.PI' runs in the embedded dynamic engine, which this build does not include +dynamic-surface.ts:11:13 - error SC2012: 'Math.atan2' runs in the embedded dynamic engine, which this build does not include - 10 | const up = Math.sqrt(2); - 11 | const tau = Math.PI * 2; - | ^~~~~~~ + 10 | const up = Math.cbrt(8); + 11 | const tau = Math.atan2(0, -1) * 2; + | ^~~~~~~~~~~~~~~~~ 12 | const price = (19.99).toPrecision(4); hint: build with --dynamic to run this call in the embedded engine (adds ~620KB to the binary); static builds never include it dynamic-surface.ts:12:15 - error SC2012: '.toPrecision()' on numbers runs in the embedded dynamic engine, which this build does not include - 11 | const tau = Math.PI * 2; + 11 | const tau = Math.atan2(0, -1) * 2; 12 | const price = (19.99).toPrecision(4); | ^~~~~~~~~~~~~~~~~~~~~~ 13 | const swapped = "banana".replace("an", "AN"); diff --git a/tests/harness/__snapshots__/json-dyn.ts.txt b/tests/harness/__snapshots__/json-dyn.ts.txt index 9246e8dae..8bfee8cec 100644 --- a/tests/harness/__snapshots__/json-dyn.ts.txt +++ b/tests/harness/__snapshots__/json-dyn.ts.txt @@ -39,13 +39,6 @@ json-dyn.ts:28:19 - error SC1090: a checked cast of 'unknown' to 'Point' (a dyna | ^~~~~~~~~~~~~~~~~~~~~~~~~ 29 | // (casts of unknown to ADAPTABLE function types compile now — the kind -json-dyn.ts:38:3 - error SC1090: 'unknown'-typed class fields are not supported yet - - 37 | class Holder { - 38 | data: unknown = JSON.parse("{}"); - | ^~~~ - 39 | } - json-dyn.ts:42:7 - error SC2007: values of type '{ (text: string, reviver?: ((this: any, key: string, value: any) => any) | undefined): any; (text: string): unknown; }' cannot be compiled: the type declares multiple call signatures (overloads), and a compiled function value is always one concrete signature 41 | const dynArray: unknown[] = []; // unknown[] IS the dyn array now — no fence (corpus 2585) @@ -67,11 +60,4 @@ json-dyn.ts:51:39 - error SC1090: JSON.stringify of 'string | undefined' values 50 | } 51 | const stringifyUndef = JSON.stringify(mkMaybe()); | ^~~~~~~~~ - 52 | - -json-dyn.ts:59:1 - error SC1090: constructing through a class value whose class has no lowering (the class declaration itself was rejected — see its own diagnostic) is not supported yet - - 58 | // them relevant; these references are what makes them count. - 59 | new Holder(); - | ^~~~~~~~~~~~ - 60 | \ No newline at end of file + 52 | // Reached: unreached bodies never lower, so their rejections only exist \ No newline at end of file diff --git a/tests/harness/__snapshots__/midi.ts.txt b/tests/harness/__snapshots__/midi.ts.txt new file mode 100644 index 000000000..53e39eafa --- /dev/null +++ b/tests/harness/__snapshots__/midi.ts.txt @@ -0,0 +1,29 @@ +midi.ts:16:20 - error SC2020: 'sendMessage with a message that is not a number[] or Uint8Array' is part of the standard library types but has no scriptc lowering yet + + 15 | // sendArray, a Uint8Array rides sendBytes, and nothing else lowers). + 16 | output.sendMessage("nope" as unknown as number[]); + | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + 17 | + + hint: the supported message shapes are a number[] (array literal) and a Uint8Array + +midi.ts:22:16 - error SC1090: using the result of port.openPort(...) (the result is void here — call it as its own statement) is not supported yet + + 21 | // cannot feed a binding; call them as their own statement. + 22 | const opened = input.openPort(0); + | ^~~~~~~~~~~~~~~~~ + 23 | + +midi.ts:26:21 - error SC1090: listeners returning a value (make the callback body a block, or return nothing) is not supported yet + + 25 | // keeps its word and stays fenced (the child_process listener rule exactly). + 26 | input.on("message", (deltaTime): number => deltaTime); + | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + 27 | + +midi.ts:29:13 - error SC1090: using the result of port.closePort(...) (the result is void here — call it as its own statement) is not supported yet + + 28 | // A void-result port call in argument position is not a statement either. + 29 | console.log(input.closePort()); + | ^~~~~~~~~~~~~~~~~ + 30 | \ No newline at end of file diff --git a/tests/harness/coverage.test.ts b/tests/harness/coverage.test.ts index 56da37e6a..7904d3dfc 100644 --- a/tests/harness/coverage.test.ts +++ b/tests/harness/coverage.test.ts @@ -64,6 +64,15 @@ test("settled generic rest-order fences count each source statement once", () => expect(coverage.stats.statementsFailed).toBe(2); }); +test("node:midi enumerate program is fully static", () => { + // The enumerate surface (construct, getPortCount/getPortName, closePort) + // lowers with no dynamic remainder — the static-coverage floor the native + // enumerate program builds on. See tests/coverage-fixtures/midi-enumerate.ts. + const out = report(fixture("midi-enumerate.ts")); + expect(out).toContain("(100%)"); + expect(out).toContain("fully static"); +}); + test("JS inference gaps land where 'any' lands: SC2011 static, island dynamic", async () => { // The js-gap fixture's tsconfig turns noImplicitAny off, so the untyped // parameter types `any` — the static analysis reports the site as @@ -99,14 +108,59 @@ test("lazy edges inventory: unresolvable require()/import() targets mark as lazy test("lazy builtin edges mark in the builtins table, __require sites included", async () => { // The esbuild-require fixture routes external requires through the // bundle's __require helper — its literal call sites collect as require - // edges, so the builtins table lists node:os/node:tty (shimmed) and - // node:stream as a lazy trap (unshimmed, reached only by the - // never-called require) without failing the build. + // edges, so the builtins table lists node:module (partial), node:os/ + // node:tty (shimmed), and node:http2 as a lazy trap (unshimmed, reached + // only by the never-called require) without failing the build. await expect( report(join(repoRoot, "tests/fixtures/npm/cases/esbuild-require/main.ts"), { dynamic: true }), ).toMatchFileSnapshot("__snapshots__/coverage-npm-lazy-builtin.txt"); }); +test("a partial dynamic shim is reported as partial, not fully shimmed", async () => { + // The crypto-shims fixture imports node:crypto through cryptozoo. The + // island ships a crypto shim, but only the hashing/random/pbkdf2 slice; + // keys, ciphers, signing, and the rest throw at the call. The builtins + // table marks it "partial" with an explanatory note, distinct from a + // fully implemented shim (the esbuild-require snapshot pins that side). + await expect( + report(join(repoRoot, "tests/fixtures/npm/cases/crypto-shims/main.ts"), { dynamic: true }), + ).toMatchFileSnapshot("__snapshots__/coverage-npm-partial-builtin.txt"); +}); + +test("known call-time-fenced builtin shims are never reported as complete", () => { + const cases = [ + [ + join(repoRoot, "tests/fixtures/commander-calc/calc.ts"), + ["node:child_process", "node:fs", "node:process"], + ], + [ + join(repoRoot, "tests/fixtures/npm/cases/island-web-plumbing/main.ts"), + ["node:buffer", "node:dns", "node:module", "node:worker_threads"], + ], + [ + join(repoRoot, "tests/fixtures/npm/cases/misc-shims/main.ts"), + ["node:v8"], + ], + [ + join(repoRoot, "tests/fixtures/npm/cases/stream-shims/main.ts"), + ["node:stream/consumers"], + ], + [ + join(repoRoot, "tests/fixtures/fetch/cases/island-http/main.ts"), + ["node:http"], + ], + ] as const; + + for (const [entry, builtins] of cases) { + const lines = report(entry, { dynamic: true }).split("\n"); + for (const builtin of builtins) { + const row = lines.find((line) => line.trimStart().startsWith(`${builtin} `)); + expect(row, `${builtin} coverage row for ${entry}`).toBeDefined(); + expect(row!.trim().split(/\s+/).slice(0, 2)).toEqual([builtin, "partial"]); + } + } +}); + test("import fences no longer stop analysis: percentage plus module blockers", async () => { // The fenced module reports ONE grouped blocker (the import line plus // every use of its bindings carry the same message); the rest of the diff --git a/tests/harness/differential.test.ts b/tests/harness/differential.test.ts index ef52b9dfa..17eba9cb3 100644 --- a/tests/harness/differential.test.ts +++ b/tests/harness/differential.test.ts @@ -17,6 +17,7 @@ import { promisify } from "node:util"; import { describe, expect, test } from "vitest"; import ts5 from "typescript"; import { compile } from "@scriptc/compiler"; +import { oracleCacheKeyBase } from "./oracle-environment.js"; import { shardSelect, shardSuffix } from "./shard.js"; const execFileAsync = promisify(execFile); @@ -223,9 +224,10 @@ function programInputs(file: string): string[] { // Node's verdict for a corpus program is a pure function of the program bytes, // the shims, and the Node build (corpus stdout is deterministic by // construction — it must match a non-Node native binary byte-for-byte). So -// cache it, keyed by all of those plus the invocation shape (SCRIPTC_TEST_ENV -// and the cwd). Only the SPAWN is skipped: the native side always runs live -// and the comparison itself never changes. SCRIPTC_NO_CACHE=1 (or an unset +// cache it, keyed by all of those plus the invocation shape (the complete +// inherited environment and the cwd). Only the SPAWN is skipped: the native +// side always runs live and the comparison itself never changes. +// SCRIPTC_NO_CACHE=1 (or an unset // SCRIPTC_CACHE_DIR) disables the cache in both directions — no reads, no writes. // Storage shares the compile cache's root and its LRU sweep (see cc.ts). const oracleDir = @@ -239,17 +241,16 @@ function oracleKeyBase(): Promise { // The spawned `node` comes from PATH, so ask IT for its version rather than // trusting process.version (vitest's own node could differ). oracleKeyBaseMemo ??= execFileAsync("node", ["--version"]).then(({ stdout }) => - createHash("sha256") - .update("oracle-v1\0") - .update(stdout.trim()).update("\0") + oracleCacheKeyBase({ + nodeVersion: stdout.trim(), // Decorator programs run tsc's downlevel on the Node side — its // emitter version is part of the verdict. - .update(ts5.version).update("\0") - .update(readFileSync(fileURLToPath(comptimeShim))).update("\0") - .update(readFileSync(fileURLToPath(islandShim))).update("\0") - .update(process.env["SCRIPTC_TEST_ENV"] ?? "").update("\0") - .update(process.cwd()).update("\0") - .digest("hex"), + typescriptVersion: ts5.version, + comptimeShim: readFileSync(fileURLToPath(comptimeShim), "utf8"), + islandShim: readFileSync(fileURLToPath(islandShim), "utf8"), + environment: process.env, + cwd: process.cwd(), + }), ); return oracleKeyBaseMemo; } diff --git a/tests/harness/ffi.test.ts b/tests/harness/ffi.test.ts index a00e7b0ee..9764b5f69 100644 --- a/tests/harness/ffi.test.ts +++ b/tests/harness/ffi.test.ts @@ -24,7 +24,10 @@ const cacheRoot = join( flavor, ); +let cachedNativeArchive: string | undefined; + function nativeArchive(): string { + if (cachedNativeArchive !== undefined) return cachedNativeArchive; const outDir = join(cacheRoot, "native"); mkdirSync(outDir, { recursive: true }); const object = join(outDir, "native.o"); @@ -38,21 +41,72 @@ function nativeArchive(): string { object, ]); execFileSync("ar", ["rcs", archive, object]); - return archive; + cachedNativeArchive = archive; + return cachedNativeArchive; } -function manifest(archive: string): string { +function manifest(archive: string, functionNames?: readonly string[]): string { const outDir = join(cacheRoot, "manifest"); mkdirSync(outDir, { recursive: true }); const profile = JSON.parse( readFileSync(join(fixtureRoot, "profile.json"), "utf8"), - ) as { libraries: string[] }; + ) as { functions: { name: string }[]; libraries: string[] }; + if (functionNames !== undefined) { + const names = new Set(functionNames); + profile.functions = profile.functions.filter((entry) => names.has(entry.name)); + } profile.libraries = [archive]; const path = join(outDir, "profile.json"); writeFileSync(path, JSON.stringify(profile, null, 2)); return path; } +async function compileScaleFixture( + id: string, + body: readonly string[], + options: { + backend?: "c" | "llvm"; + emitIr?: boolean; + ffi?: boolean; + } = {}, +) { + const outDir = join(cacheRoot, id); + mkdirSync(outDir, { recursive: true }); + const entry = join(outDir, "main.ts"); + writeFileSync( + entry, + [ + "declare function nativeScale(value: number): number;", + ...body, + "", + ].join("\n"), + ); + const result = await compile(entry, { + outDir, + outPath: join(outDir, "program"), + backend: options.backend ?? "c", + sanitize, + ...(options.ffi === false + ? {} + : { ffiProfilePath: manifest(nativeArchive(), ["nativeScale"]) }), + emitIr: options.emitIr, + }); + return { entry, result }; +} + +function expectUndefinedAmbient(binaryPath: string): void { + const native = spawnSync(binaryPath, [], { encoding: "utf8" }); + expect({ + stdout: native.stdout, + stderr: native.stderr, + status: native.status, + }).toEqual({ + stdout: "", + stderr: "Uncaught ReferenceError: nativeScale is not defined\n", + status: 1, + }); +} + const expected = [ "42", "true false", @@ -279,6 +333,169 @@ describe.each(["c", "llvm"] as const)("outbound native FFI, %s backend", (backen ); }); +describe.each(["c", "llvm"] as const)("FFI binding initializers, %s backend", (backend) => { + test("preserves exact calls across binding and early-probe contexts", async () => { + const { result } = await compileScaleFixture( + `binding-initializer-${backend}`, + [ + "const moduleResult = nativeScale(2);", + "function main(): void {", + " const functionResult = nativeScale(21);", + " let once = nativeScale(3);", + " console.log('module:', moduleResult);", + " console.log('const:', functionResult);", + " console.log('let:', once);", + " for (const value of [1, 2]) {", + " const loopResult = nativeScale(value);", + " console.log('loop:', loopResult);", + " }", + " let assigned = 0;", + " assigned = nativeScale(5);", + " console.log('assignment:', assigned);", + " const text = nativeScale(6).toString();", + " console.log('chain:', text);", + " let calls = 0;", + " const sideEffectResult = nativeScale(++calls);", + " console.log('side effect:', sideEffectResult, calls);", + "}", + "main();", + ], + { backend, emitIr: true }, + ); + if (!result.ok) { + throw new Error( + result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"), + ); + } + + const native = spawnSync(result.binaryPath, [], { encoding: "utf8" }); + expect({ + stdout: native.stdout, + stderr: native.stderr, + status: native.status, + }).toEqual({ + stdout: [ + "module: 4", + "const: 42", + "let: 6", + "loop: 2", + "loop: 4", + "assignment: 10", + "chain: 12", + "side effect: 2 1", + "", + ].join("\n"), + stderr: "", + status: 0, + }); + + const ir = JSON.stringify(JSON.parse(readFileSync(result.irPath!, "utf8"))); + expect(ir.match(/"kind":"ffiCall"/g)).toHaveLength(7); + expect(ir).not.toContain('"fn":"global.undefRead"'); + }); +}); + +test("keeps a no-manifest ambient initializer failure ahead of its arguments", async () => { + const { result } = await compileScaleFixture( + "binding-initializer-no-manifest", + [ + "function argument(): number {", + " console.log('argument evaluated');", + " return 21;", + "}", + "const result = nativeScale(argument());", + "console.log(result);", + ], + { emitIr: true, ffi: false }, + ); + if (!result.ok) { + throw new Error( + result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"), + ); + } + + expectUndefinedAmbient(result.binaryPath); + + const ir = JSON.stringify(JSON.parse(readFileSync(result.irPath!, "utf8"))); + expect(ir).toContain('"fn":"global.undefRead"'); + expect(ir).not.toContain('"kind":"ffiCall"'); +}); + +test.each([ + { + id: "alias", + name: "an alias read", + body: [ + "const alias = nativeScale;", + "console.log(alias(21));", + ], + }, + { + id: "call-property", + name: "a .call use", + body: ["console.log(nativeScale.call(null, 21));"], + }, + { + id: "parenthesized-callee", + name: "a parenthesized callee", + body: ["console.log((nativeScale)(21));"], + }, +])("does not widen $name into a native call", async ({ id, body }) => { + const { result } = await compileScaleFixture(`indirect-${id}`, body); + if (!result.ok) { + throw new Error( + result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"), + ); + } + + expectUndefinedAmbient(result.binaryPath); +}); + +test.each([ + { + id: "optional", + name: "an optional direct call", + call: "nativeScale?.(21)", + message: "direct, non-generic calls only", + }, + { + id: "spread", + name: "a spread direct call", + call: "nativeScale(...([21] as [number]))", + message: "spread arguments do not have a fixed native ABI", + }, + { + id: "arity", + name: "a wrong-arity direct call", + call: "nativeScale()", + message: "native ABI requires exactly 1", + suppressTypeScript: true, + }, +])("keeps the existing FFI diagnostic for $name", async ({ + id, + call, + message, + suppressTypeScript, +}) => { + const { entry, result } = await compileScaleFixture( + `call-diagnostic-${id}`, + [ + "function main(): void {", + ...(suppressTypeScript ? [" // @ts-ignore exercise the native arity diagnostic"] : []), + ` const result = ${call};`, + "}", + "main();", + ], + ); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0]?.code).toBe("SC5003"); + expect(result.diagnostics[0]?.message).toContain(message); + expect(result.diagnostics[0]?.loc.file).toBe(entry); + } +}); + test("manifest validation is strict and source-facing", () => { const path = join(cacheRoot, "invalid.json"); mkdirSync(cacheRoot, { recursive: true }); @@ -799,7 +1016,8 @@ describe.each(["c", "llvm"] as const)("FFI binding identity, %s backend", (backe "declare function nativeScale(value: number): number;", "function localUse(): number {", " function nativeScale(value: number): number { return value + 1; }", - " return nativeScale(21);", + " const result = nativeScale(21);", + " return result;", "}", "console.log(localUse());", "", diff --git a/tests/harness/global-buffer-alias.test.ts b/tests/harness/global-buffer-alias.test.ts new file mode 100644 index 000000000..0a20da16d --- /dev/null +++ b/tests/harness/global-buffer-alias.test.ts @@ -0,0 +1,122 @@ +import { execFile } from "node:child_process"; +import { createHash } from "node:crypto"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { describe, expect, test } from "vitest"; +import { compile } from "@scriptc/compiler"; + +const execFileAsync = promisify(execFile); +const sanitize = process.env["SCRIPTC_SAN"] === "1"; + +interface RunResult { + stdout: Buffer; + stderr: Buffer; + exitCode: number; +} + +async function run(cmd: string, args: string[]): Promise { + try { + const { stdout, stderr } = await execFileAsync(cmd, args, { encoding: "buffer" }); + return { stdout, stderr, exitCode: 0 }; + } catch (err) { + if ( + typeof err !== "object" || err === null || + !("code" in err) || typeof err.code !== "number" || + !("stdout" in err) || !Buffer.isBuffer(err.stdout) || + !("stderr" in err) || !Buffer.isBuffer(err.stderr) + ) { + throw err; + } + return { stdout: err.stdout, stderr: err.stderr, exitCode: err.code }; + } +} + +async function compileAndCompare(source: string, backend: "c" | "llvm"): Promise { + const key = createHash("sha256") + .update(source) + .update(`${backend}-${sanitize ? "san" : "plain"}`) + .digest("hex") + .slice(0, 16); + const outDir = join(tmpdir(), "scriptc-tests", `global-buffer-alias-${key}`); + mkdirSync(outDir, { recursive: true }); + const file = join(outDir, "main.mts"); + writeFileSync(file, source); + const result = await compile(file, { + outPath: join(outDir, "program"), + outDir, + sanitize, + backend, + }); + if (!result.ok) { + throw new Error( + "guarded global Buffer alias program failed to compile:\n" + + result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"), + ); + } + const [nodeResult, nativeResult] = await Promise.all([ + run("node", ["--experimental-transform-types", "--disable-warning=ExperimentalWarning", file]), + run(result.binaryPath, []), + ]); + expect(nativeResult.stdout).toEqual(nodeResult.stdout); + expect(nativeResult.stderr).toEqual(nodeResult.stderr); + expect(nativeResult.exitCode).toBe(nodeResult.exitCode); +} + +async function compileAndExpectFence(source: string, backend: "c" | "llvm"): Promise { + const key = createHash("sha256").update(source).update(backend).digest("hex").slice(0, 16); + const outDir = join(tmpdir(), "scriptc-tests", `global-buffer-alias-fence-${key}`); + mkdirSync(outDir, { recursive: true }); + const file = join(outDir, "main.mts"); + writeFileSync(file, source); + const result = await compile(file, { + outPath: join(outDir, "program"), + outDir, + sanitize, + backend, + }); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n")).toContain( + "SC1090: the reference to 'runtimeBuffer' (a binding form with no lowering) is not supported yet", + ); +} + +describe.each(["c", "llvm"] as const)( + `guarded global Buffer alias, %s backend${sanitize ? " (sanitized)" : ""}`, + (backend) => { + test("counts UTF-8 bytes through the guarded constructor alias", async () => { + await compileAndCompare(` +interface RuntimeBuffer { + byteLength(value: string, encoding?: "utf8"): number; +} + +const runtimeBuffer = (globalThis as { Buffer?: RuntimeBuffer }).Buffer; + +function byteLength(content: string): number { + return runtimeBuffer + ? runtimeBuffer.byteLength(content, "utf8") + : content.length; +} + +console.log(byteLength("ascii")); +console.log(byteLength("é")); +console.log(byteLength("😀")); +console.log(runtimeBuffer ? runtimeBuffer.byteLength("Aé😀") : -1); +`, backend); + }); + + test("preserves unsupported member fences through the alias", async () => { + await compileAndExpectFence(` +interface RuntimeBuffer { + byteLength(value: string): number; + poolSize?: number; +} + +const runtimeBuffer = (globalThis as { Buffer?: RuntimeBuffer }).Buffer; +console.log(runtimeBuffer ? runtimeBuffer.poolSize : -1); +`, backend); + }); + }, +); diff --git a/tests/harness/island.test.ts b/tests/harness/island.test.ts index f7be561e8..0ef62ee65 100644 --- a/tests/harness/island.test.ts +++ b/tests/harness/island.test.ts @@ -338,6 +338,16 @@ console.log(__island_eval("Promise.reject(new TypeError('island second')); 'arme expect(r.stderr).toBe("Unhandled promise rejection: RangeError: static first\n"); }); + test("links the dynamic promise adapter for typed promises crossing a dynamic callback", async () => { + await build( + "typed-promise-dynamic-boundary", + `async function typed(): Promise { return 1; } +function invoke(fn: () => unknown): unknown { return fn(); } +console.log(invoke(typed)); +`, + ); + }); + test("--dynamic does not change emitted C for island-free programs", async () => { const source = `function greet(who: string): string { return "hello " + who; diff --git a/tests/harness/library-int.test.ts b/tests/harness/library-int.test.ts index 596b8be71..bf00c5d79 100644 --- a/tests/harness/library-int.test.ts +++ b/tests/harness/library-int.test.ts @@ -198,6 +198,19 @@ const CORPUS: CorpusCase[] = [ slot: "exports.send.params[0]", evidence: ["NaN"], }, + { + name: "nan-survives-failed-guard-edge", + // NaN < 0 and NaN > 100 are both false, so a NaN dividend (a = 0) + // falls through BOTH guard clauses into the slot: the failed edge of + // an ordered comparison excludes nothing. + body: `const q = a / a;\nif (q < 0) return;\nif (q > 100) return;\nsend(Math.trunc(q));`, + param: true, + expected: "refuse", + obligation: "wholeness", + code: "SC4022", + slot: "exports.send.params[0]", + evidence: ["NaN"], + }, { name: "infinity-reaches-slot", body: `send(1 / 0);`, diff --git a/tests/harness/library-mode.test.ts b/tests/harness/library-mode.test.ts index ad93d3628..e2bff9258 100644 --- a/tests/harness/library-mode.test.ts +++ b/tests/harness/library-mode.test.ts @@ -937,32 +937,32 @@ describe.each(EMISSIONS)("K14: determinism fences, %s emission", (emission) => { test("a manifest-id-keyed teachings entry attaches to that surface's own refusal", async () => { const diags = await refusal( - `export function f(): number { return Math.sin(1); }\n`, + `export function f(): number { return Math.cbrt(8); }\n`, { exports: [{ export: "f", symbol: "kx_f", params: [], returns: "f64" }], - determinism: { teachings: { "stdlib.math.sin": "trig runs in the host; request it as an effect" } }, + determinism: { teachings: { "stdlib.math.cbrt": "cube roots run in the host; request them as an effect" } }, }, emission, ); // The surface's own code, not a fence code: the id key attaches text // to the refusal that already fires. expect(diags[0]!.code).toBe("SC2012"); - expect(diags[0]!.message).toContain("Math.sin"); - expect(diags[0]!.note).toBe("from the 'refusal-fixture' profile: trig runs in the host; request it as an effect"); + expect(diags[0]!.message).toContain("Math.cbrt"); + expect(diags[0]!.note).toBe("from the 'refusal-fixture' profile: cube roots run in the host; request them as an effect"); }); test("fencing a surface the static tier refuses anyway changes only the message", async () => { const diags = await refusal( - `export function f(): number { return Math.sin(1); }\n`, + `export function f(): number { return Math.cbrt(8); }\n`, { exports: [{ export: "f", symbol: "kx_f", params: [], returns: "f64" }], - determinism: { fences: [{ id: "stdlib.math.sin", teaching: "trig is host math" }] }, + determinism: { fences: [{ id: "stdlib.math.cbrt", teaching: "cube roots are host math" }] }, }, emission, ); // The existing refusal's code survives — the fence never re-codes a // surface that already refuses; its teaching rides as the note. expect(diags[0]!.code).toBe("SC2012"); - expect(diags[0]!.note).toBe("from the 'refusal-fixture' profile: trig is host math"); + expect(diags[0]!.note).toBe("from the 'refusal-fixture' profile: cube roots are host math"); }); }); diff --git a/tests/harness/library-profile.test.ts b/tests/harness/library-profile.test.ts index 8b35d8026..f793ff559 100644 --- a/tests/harness/library-profile.test.ts +++ b/tests/harness/library-profile.test.ts @@ -356,7 +356,7 @@ describe("library profile fences", () => { fences: [ { id: "stdlib.math.random", teaching: "randomness is an effect", remediation: "ask the host" }, { prefix: "node-builtin.fs.", teaching: "files are effects" }, - { id: "stdlib.math.sin", teaching: "trig is host math", remediation: "request it as an effect" }, + { id: "stdlib.math.cbrt", teaching: "cube roots are host math", remediation: "request it as an effect" }, ], }, }), @@ -376,9 +376,9 @@ describe("library profile fences", () => { expect(fsIds).toContain("node-builtin.fs.promises.readFile"); // A fenced dynamic-only surface carries its own refusal code and no // detector: the teaching rides the refusal that already fires. - const sin = r.profile.fences[2]!.surfaces[0]!; - expect(sin.code).toBe("SC2012"); - expect(sin.detector).toBeUndefined(); + const cbrt = r.profile.fences[2]!.surfaces[0]!; + expect(cbrt.code).toBe("SC2012"); + expect(cbrt.detector).toBeUndefined(); }); test("a fence remediation feeds the trap-remediation lookup through covered codes", () => { @@ -388,7 +388,7 @@ describe("library profile fences", () => { determinism: { remediations: { SC2012: "the explicit map key wins" }, fences: [ - { id: "stdlib.math.sin", remediation: "request it as an effect" }, + { id: "stdlib.math.cbrt", remediation: "request it as an effect" }, { id: "node-builtin.crypto.createHash", remediation: "digests come from the host" }, ], }, diff --git a/tests/harness/midi.test.ts b/tests/harness/midi.test.ts new file mode 100644 index 000000000..8f2dd16ae --- /dev/null +++ b/tests/harness/midi.test.ts @@ -0,0 +1,147 @@ +/* node:midi harness — two lanes, both gated on host capability. + * + * 1. The WASI refusal. wasm32-wasi is a production LLVM target, but WASI + * Preview 1 has no MIDI API, so any midi surface must fence at compile + * time with SC3002 (the socket/child-process precedent in index.ts). + * Reaching the wasi build platform needs zigcc on PATH, exactly like the + * wasm32-wasi differential lane, so this describe skips without zig. + * + * 2. The virtual-port loopback differential. The corpus is differential + * against Node, but a MIDI program that touches ports cannot be made + * byte-identical without a real MIDI stack: this CI container has no ALSA + * (the runtime compiles a stub that enumerates 0 ports and throws on + * open), and Node needs @julusian/midi (a native RtMidi addon) to answer + * at all. So this case is CAPABILITY-GATED: it runs only on a host where + * the Node baseline can actually open a virtual port pair (POSIX ALSA + * sequencer / CoreMIDI), and is skipped otherwise. It documents intent + * and validates real hardware-free loopback on a capable host; it must + * never break CI. See tests/fixtures/midi/cases/virtual-loopback/main.ts. */ +import { execFileSync, spawn, spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { mkdirSync, readFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { join } from "node:path"; +import { afterAll, beforeAll, describe, expect, test } from "vitest"; +import { compile } from "@scriptc/compiler"; + +const repoRoot = join(import.meta.dirname, "../.."); +const cacheDir = join(repoRoot, "node_modules/.cache/scriptc-tests"); +const require = createRequire(import.meta.url); + +function zigOnPath(): boolean { + try { + execFileSync("zig", ["version"], { stdio: "ignore" }); + return true; + } catch { + return false; + } +} + +describe.skipIf(!zigOnPath())("midi WASI refusal", () => { + let oldCc: string | undefined; + let oldTarget: string | undefined; + + beforeAll(() => { + oldCc = process.env["SCRIPTC_CC"]; + oldTarget = process.env["SCRIPTC_TARGET"]; + process.env["SCRIPTC_CC"] = "zigcc"; + process.env["SCRIPTC_TARGET"] = "wasm32-wasi"; + }); + + afterAll(() => { + if (oldCc === undefined) delete process.env["SCRIPTC_CC"]; + else process.env["SCRIPTC_CC"] = oldCc; + if (oldTarget === undefined) delete process.env["SCRIPTC_TARGET"]; + else process.env["SCRIPTC_TARGET"] = oldTarget; + }); + + test("a midi surface fences before linking with SC3002", async () => { + const entry = join(repoRoot, "tests/coverage-fixtures/midi-enumerate.ts"); + const outDir = join(cacheDir, "midi-wasi"); + mkdirSync(outDir, { recursive: true }); + const result = await compile(entry, { outDir, outPath: join(outDir, "program.wasm") }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0]?.code).toBe("SC3002"); + expect(result.diagnostics[0]?.message).toMatch(/MIDI/); + } + }); +}); + +/** Whether this host can run the virtual-port loopback: the "midi" dev-dep + * (aliased to @julusian/midi) must resolve AND actually open a virtual + * output/input pair — which needs a POSIX MIDI backend with virtual ports. + * Windows WinMM has no user-space virtual ports, so it is excluded. When the + * Node baseline can do this, the native ALSA/CoreMIDI backend on the same + * host has virtual ports too. Any failure (missing addon, no ALSA) → skip. */ +function midiLoopbackAvailable(): boolean { + if (process.platform === "win32") return false; + try { + require.resolve("midi"); + } catch { + return false; + } + const probe = [ + 'const midi = require("midi");', + 'const out = new midi.Output();', + 'out.openVirtualPort("scriptc-probe");', + 'const inp = new midi.Input();', + 'let seen = false;', + 'for (let i = 0; i < inp.getPortCount(); i++) {', + ' if (inp.getPortName(i).includes("scriptc-probe")) seen = true;', + '}', + 'inp.closePort();', + 'out.closePort();', + 'process.exit(seen ? 0 : 1);', + ].join(""); + const res = spawnSync(process.execPath, ["-e", probe], { stdio: "ignore", timeout: 15_000 }); + return res.status === 0; +} + +async function buildLoopback(entry: string): Promise { + const key = createHash("sha256").update(readFileSync(entry)).digest("hex").slice(0, 16); + const outDir = join(cacheDir, `midi-loopback-${key}`); + mkdirSync(outDir, { recursive: true }); + const result = await compile(entry, { outPath: join(outDir, "program"), outDir, backend: "c" }); + if (!result.ok) { + throw new Error( + "midi loopback fixture failed to compile:\n" + + result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"), + ); + } + return result.binaryPath; +} + +function runLane(cmd: string, args: string[]): Promise<{ stdout: string; exitCode: number }> { + return new Promise((resolve, reject) => { + const child = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"] }); + const out: Buffer[] = []; + let errText = ""; + const timer = setTimeout(() => { + child.kill("SIGKILL"); + reject(new Error(`midi loopback timed out\nstderr:\n${errText}`)); + }, 30_000); + child.stdout.on("data", (c: Buffer) => out.push(c)); + child.stderr.on("data", (c: Buffer) => (errText += c.toString("utf8"))); + child.on("close", (code, signal) => { + clearTimeout(timer); + if (signal) reject(new Error(`midi loopback died to ${signal}\nstderr:\n${errText}`)); + else resolve({ stdout: Buffer.concat(out).toString("utf8"), exitCode: code ?? 0 }); + }); + }); +} + +describe.skipIf(!midiLoopbackAvailable())("midi virtual-port loopback differential", () => { + const entry = join(repoRoot, "tests/fixtures/midi/cases/virtual-loopback/main.ts"); + + test("native loopback matches Node byte-for-byte", async () => { + const binary = await buildLoopback(entry); + // Sequential, not parallel: both lanes open a virtual MIDI port named the + // same, so keep the host's port table uncontended between the two runs. + const nodeRes = await runLane("node", [entry]); + const nativeRes = await runLane(binary, []); + expect(nativeRes.stdout).toBe(nodeRes.stdout); + expect(nativeRes.exitCode).toBe(nodeRes.exitCode); + }, 120_000); +}); diff --git a/tests/harness/oracle-environment.test.ts b/tests/harness/oracle-environment.test.ts new file mode 100644 index 000000000..92327f774 --- /dev/null +++ b/tests/harness/oracle-environment.test.ts @@ -0,0 +1,46 @@ +import { expect, test } from "vitest"; +import { oracleCacheKeyBase, oracleEnvironmentFingerprint } from "./oracle-environment.js"; + +test("oracle environment fingerprint covers arbitrary output-affecting variables", () => { + const base = oracleEnvironmentFingerprint({ NODE_ENV: "development", SCRIPTC_NEVER: "no" }); + + expect(oracleEnvironmentFingerprint({ NODE_ENV: "production", SCRIPTC_NEVER: "no" })).not.toBe(base); + expect(oracleEnvironmentFingerprint({ NODE_ENV: "development", SCRIPTC_NEVER: "yes" })).not.toBe(base); + expect(oracleEnvironmentFingerprint({ NODE_ENV: "development", SCRIPTC_NEVER: "no", EXTRA: "value" })).not.toBe(base); +}); + +test("oracle environment fingerprint is independent of insertion order", () => { + expect(oracleEnvironmentFingerprint({ NODE_ENV: "production", PATH: "/bin", EMPTY: "" })).toBe( + oracleEnvironmentFingerprint({ EMPTY: "", PATH: "/bin", NODE_ENV: "production" }), + ); +}); + +test("oracle environment fingerprint distinguishes missing, unset, and empty variables", () => { + expect(oracleEnvironmentFingerprint({})).not.toBe(oracleEnvironmentFingerprint({ VALUE: undefined })); + expect(oracleEnvironmentFingerprint({ VALUE: undefined })).not.toBe( + oracleEnvironmentFingerprint({ VALUE: "" }), + ); +}); + +test("oracle environment fingerprint length-frames keys and values", () => { + expect(oracleEnvironmentFingerprint({ "A:B": "C;D" })).not.toBe( + oracleEnvironmentFingerprint({ A: "B:C;D" }), + ); +}); + +test("oracle cache key invalidates when corpus output-affecting variables change", () => { + const inputs = { + nodeVersion: "v24.0.0", + typescriptVersion: "5.9.0", + comptimeShim: "comptime", + islandShim: "island", + cwd: "/repo", + }; + const base = oracleCacheKeyBase({ + ...inputs, + environment: { NODE_ENV: "development", SCRIPTC_NEVER: "no" }, + }); + + expect(oracleCacheKeyBase({ ...inputs, environment: { NODE_ENV: "production", SCRIPTC_NEVER: "no" } })).not.toBe(base); + expect(oracleCacheKeyBase({ ...inputs, environment: { NODE_ENV: "development", SCRIPTC_NEVER: "yes" } })).not.toBe(base); +}); diff --git a/tests/harness/oracle-environment.ts b/tests/harness/oracle-environment.ts new file mode 100644 index 000000000..2486ab58e --- /dev/null +++ b/tests/harness/oracle-environment.ts @@ -0,0 +1,42 @@ +import { createHash } from "node:crypto"; + +/** + * The complete inherited environment visible to the Node oracle. Corpus + * programs may read arbitrary process.env keys directly or through imported + * modules, so an allowlist cannot soundly describe this input. Keys sort by + * UTF-16 code unit for a deterministic order; names and values are + * length-framed so missing, empty, and delimiter-containing entries remain + * distinct. + */ +export function oracleEnvironmentFingerprint(env: NodeJS.ProcessEnv): string { + return Object.keys(env) + .sort((a, b) => a < b ? -1 : a > b ? 1 : 0) + .map((key) => { + const value = env[key]; + const framedValue = value === undefined ? "unset" : `${value.length}:${value}`; + return `${key.length}:${key}:${framedValue};`; + }) + .join(""); +} + +interface OracleCacheKeyBaseInputs { + nodeVersion: string; + typescriptVersion: string; + comptimeShim: string; + islandShim: string; + environment: NodeJS.ProcessEnv; + cwd: string; +} + +/** The shared, testable base of every per-program Node oracle cache key. */ +export function oracleCacheKeyBase(inputs: OracleCacheKeyBaseInputs): string { + return createHash("sha256") + .update("oracle-v3\0") + .update(inputs.nodeVersion).update("\0") + .update(inputs.typescriptVersion).update("\0") + .update(inputs.comptimeShim).update("\0") + .update(inputs.islandShim).update("\0") + .update(oracleEnvironmentFingerprint(inputs.environment)).update("\0") + .update(inputs.cwd).update("\0") + .digest("hex"); +} diff --git a/tests/harness/surface-manifest.test.ts b/tests/harness/surface-manifest.test.ts index ac3cc2d05..22eee062d 100644 --- a/tests/harness/surface-manifest.test.ts +++ b/tests/harness/surface-manifest.test.ts @@ -114,6 +114,8 @@ const PROBES: Probe[] = [ { id: "stdlib.array.unshift", source: "const xs: number[] = [2];\nconsole.log(xs.unshift(1), xs[0]);\n" }, { id: "stdlib.array.reverse", source: "const xs: number[] = [1, 2];\nconsole.log(xs.reverse()[0]);\n" }, { id: "stdlib.math.floor", source: "console.log(Math.floor(1.5));\n" }, + { id: "stdlib.math.sqrt", source: "console.log(Math.sqrt(2));\n" }, + { id: "stdlib.math.PI", source: "console.log(Math.PI);\n" }, { id: "stdlib.map.has", source: 'const m = new Map();\nm.set("a", 1);\nconsole.log(m.has("a"));\n' }, { id: "stdlib.date.now", source: "console.log(Date.now() > 0);\n" }, { id: "stdlib.number.toFixed", source: "const n = 1.2345;\nconsole.log(n.toFixed(2));\n" }, @@ -140,8 +142,7 @@ const PROBES: Probe[] = [ { id: "node-builtin.os.EOL", source: 'import { EOL } from "node:os";\nconsole.log(EOL.length);\n' }, // status dynamic-only — refused with the entry's code statically, // analyzed clean under --dynamic - { id: "stdlib.math.sqrt", source: "console.log(Math.sqrt(2));\n" }, - { id: "stdlib.math.PI", source: "console.log(Math.PI);\n" }, + { id: "stdlib.math.cbrt", source: "console.log(Math.cbrt(8));\n" }, { id: "stdlib.string.replace", source: 'console.log("aa".replace("a", "b"));\n' }, { id: "stdlib.headers.entries", diff --git a/tests/harness/union-receiver.test.ts b/tests/harness/union-receiver.test.ts new file mode 100644 index 000000000..675c4556d --- /dev/null +++ b/tests/harness/union-receiver.test.ts @@ -0,0 +1,126 @@ +import { execFile } from "node:child_process"; +import { createHash } from "node:crypto"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { describe, expect, test } from "vitest"; +import { compile } from "@scriptc/compiler"; + +const execFileAsync = promisify(execFile); +const repoRoot = join(import.meta.dirname, "../.."); +const cacheDir = join(repoRoot, "node_modules/.cache/scriptc-tests"); +const sanitize = process.env["SCRIPTC_SAN"] === "1"; + +interface RunResult { + stdout: Buffer; + stderr: Buffer; + exitCode: number; +} + +async function run(cmd: string, args: string[]): Promise { + try { + const { stdout, stderr } = await execFileAsync(cmd, args, { encoding: "buffer" }); + return { stdout, stderr, exitCode: 0 }; + } catch (err) { + if ( + typeof err !== "object" || err === null || + !("code" in err) || typeof err.code !== "number" || + !("stdout" in err) || !Buffer.isBuffer(err.stdout) || + !("stderr" in err) || !Buffer.isBuffer(err.stderr) + ) { + throw err; + } + return { stdout: err.stdout, stderr: err.stderr, exitCode: err.code }; + } +} + +async function compileAndCompare( + name: string, + source: string, + backend: "c" | "llvm", + dynamic: boolean, +): Promise { + const key = createHash("sha256") + .update(source) + .update(`${backend}-${sanitize ? "san" : "plain"}`) + .digest("hex") + .slice(0, 16); + const outDir = join(cacheDir, `union-receiver-${key}`); + mkdirSync(outDir, { recursive: true }); + const file = join(outDir, `${name}.cjs`); + writeFileSync(file, source); + const result = await compile(file, { + outPath: join(outDir, "program"), + outDir, + sanitize, + dynamic, + backend, + }); + if (!result.ok) { + throw new Error( + "union-receiver program failed to compile:\n" + + result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"), + ); + } + const [nodeResult, nativeResult] = await Promise.all([ + run("node", [file]), + run(result.binaryPath, []), + ]); + expect(nativeResult.stdout).toEqual(nodeResult.stdout); + expect(nativeResult.stderr).toEqual(nodeResult.stderr); + expect(nativeResult.exitCode).toBe(nodeResult.exitCode); +} + +const prelude = `// @ts-check +class A { value = "A"; } +class B { value = "B"; } +/** @typedef {A | B} Item */ +const concrete = new A(); +`; + +describe.each(["c", "llvm"] as const)( + `concrete receivers behind union assertions, %s backend${sanitize ? " (sanitized)" : ""}`, + (backend) => { + test("preserves direct and optional reads without dynamic marshalling", async () => { + await compileAndCompare( + "static-reads", + `${prelude} +console.log(/** @type {Item} */ (concrete).value); +console.log(/** @type {Item} */ (concrete)?.value); +`, + backend, + false, + ); + }); + + test("preserves a concrete class receiver in a dyn object-literal argument", async () => { + await compileAndCompare( + "dyn-object-arg", + `${prelude} +const dyn = JSON.parse('{"values":[]}'); +dyn.values.push({ value: /** @type {Item} */ (concrete).value }); +console.log(dyn.values[0].value); +`, + backend, + true, + ); + }); + + test("covers direct dyn-call arguments and optional property access", async () => { + await compileAndCompare( + "dyn-call-variants", + `${prelude} +const dyn = JSON.parse('{"values":[]}'); +dyn.values.push(/** @type {Item} */ (concrete).value); +dyn.values.push({ + direct: /** @type {Item} */ (concrete).value, + optional: /** @type {Item} */ (concrete)?.value, +}); +console.log(dyn.values[0], dyn.values[1].direct, dyn.values[1].optional); +`, + backend, + true, + ); + }); + }, +); diff --git a/tests/harness/unknown-fields.test.ts b/tests/harness/unknown-fields.test.ts new file mode 100644 index 000000000..7b69a4041 --- /dev/null +++ b/tests/harness/unknown-fields.test.ts @@ -0,0 +1,105 @@ +import { execFile } from "node:child_process"; +import { createHash } from "node:crypto"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { describe, expect, test } from "vitest"; +import { compile } from "@scriptc/compiler"; + +const execFileAsync = promisify(execFile); +const repoRoot = join(import.meta.dirname, "../.."); +const cacheDir = join(repoRoot, "node_modules/.cache/scriptc-tests"); +const sanitize = process.env["SCRIPTC_SAN"] === "1"; + +interface RunResult { + stdout: Buffer; + stderr: Buffer; + exitCode: number; +} + +async function run(cmd: string, args: string[]): Promise { + try { + const { stdout, stderr } = await execFileAsync(cmd, args, { encoding: "buffer" }); + return { stdout, stderr, exitCode: 0 }; + } catch (err) { + if ( + typeof err !== "object" || err === null || + !("code" in err) || typeof err.code !== "number" || + !("stdout" in err) || !Buffer.isBuffer(err.stdout) || + !("stderr" in err) || !Buffer.isBuffer(err.stderr) + ) { + throw err; + } + return { stdout: err.stdout, stderr: err.stderr, exitCode: err.code }; + } +} + +async function compileAndCompare(name: string, source: string, backend: "c" | "llvm"): Promise { + const key = createHash("sha256") + .update(source) + .update(backend) + .update(sanitize ? "san" : "plain") + .digest("hex") + .slice(0, 16); + const outDir = join(cacheDir, key); + mkdirSync(outDir, { recursive: true }); + const file = join(outDir, `${name}.ts`); + writeFileSync(file, source); + const result = await compile(file, { + outPath: join(outDir, "program"), + outDir, + sanitize, + backend, + }); + if (!result.ok) { + throw new Error( + "unknown-fields program failed to compile:\n" + + result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"), + ); + } + const [nodeResult, nativeResult] = await Promise.all([ + run("node", ["--experimental-transform-types", "--disable-warning=ExperimentalWarning", file]), + run(result.binaryPath, []), + ]); + expect(nativeResult.stdout).toEqual(nodeResult.stdout); + expect(nativeResult.stderr).toEqual(nodeResult.stderr); + expect(nativeResult.exitCode).toBe(nodeResult.exitCode); +} + +const source = `class Base { + inherited: unknown; +} + +class Holder extends Base { + value: unknown; + initialized: unknown = { count: 3 }; + static current: unknown = "ready"; + + constructor(public argument: unknown) { + super(); + } +} + +const h = new Holder(42); +console.log(h.inherited === undefined); +h.inherited = "from base"; +console.log(h.inherited === "from base"); +console.log(h.value === undefined, h.argument === 42); +if (typeof h.initialized === "object" && h.initialized !== null && "count" in h.initialized) { + console.log((h.initialized as { count: number }).count); +} +console.log(typeof Holder.current, Holder.current); +h.value = ["a", "b"]; +if (Array.isArray(h.value)) console.log(h.value.length, h.value[1]); +Holder.current = false; +console.log(Holder.current === false); +`; + +describe.each(["c", "llvm"] as const)( + `unknown-typed class fields, %s backend${sanitize ? " (sanitized)" : ""}`, + (backend) => { + test("matches Node", async () => { + await compileAndCompare(`${backend}-backend`, source, backend); + }); + }, +);