From 95d08fe164949284a173d9cb0d087f270eae5dbd Mon Sep 17 00:00:00 2001 From: Joshua Byrd Date: Tue, 28 Jul 2026 11:26:14 +1000 Subject: [PATCH 01/54] Change build output path in README example Update README to reflect changes in build output path. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 27b91e3e5..e9f02b341 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ console.log(fib(30)); $ scriptc run fib.ts 832040 -$ scriptc build fib.ts && ls -la fib +$ scriptc build fib.ts && ls -la .scriptc/fib -rwxr-xr-x 178K fib # a self-contained native binary, ~2ms startup ``` From af144dfff42fdcdba23143889ca2b34dc4409109 Mon Sep 17 00:00:00 2001 From: swadhinbiswas Date: Tue, 28 Jul 2026 14:18:39 +0600 Subject: [PATCH 02/54] runtime: add MSVC POSIX shims for Windows native builds The runtime assumed mingw-w64 on Windows, which provides POSIX headers and functions (ssize_t, clock_gettime, nanosleep, dirent.h, unistd.h) that MSVC's CRT does not ship. Users opening VS2022 Developer Command Prompt get MSVC's bundled clang (i686-pc-windows-msvc) instead of mingw-w64, and every compilation fails with missing type/function errors. Add _MSC_VER-guarded shims in scr_win.c: - clock_gettime() over QueryPerformanceCounter (monotonic) and GetSystemTimeAsFileTime (realtime) - nanosleep() over Sleep() - opendir/readdir/closedir over FindFirstFileW/FindNextFileW - CLOCK_REALTIME, CLOCK_MONOTONIC, struct timespec declarations Guard POSIX header includes in scr_lib.c, scr_path.c, scr_url.c with _MSC_VER checks, providing CRT equivalents (_getcwd, _access, _isatty) where needed. Fixes #25 --- packages/runtime/src/scr_lib.c | 18 ++++- packages/runtime/src/scr_path.c | 5 ++ packages/runtime/src/scr_runtime.h | 13 ++++ packages/runtime/src/scr_url.c | 2 + packages/runtime/src/scr_win.c | 109 +++++++++++++++++++++++++++++ 5 files changed, 146 insertions(+), 1 deletion(-) diff --git a/packages/runtime/src/scr_lib.c b/packages/runtime/src/scr_lib.c index 0d965973d..ce40e4ef4 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_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 0d359b660..c0273c837 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 /* ── win32 libc shims (scr_win.c; see the windows portability inventory) ── * The POSIX/BSD functions the runtime calls that mingw-w64's CRT does not @@ -31,6 +36,14 @@ 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 +#define CLOCK_REALTIME 0 +#define CLOCK_MONOTONIC 1 +struct timespec { time_t tv_sec; long tv_nsec; }; +int clock_gettime(int clk_id, struct timespec *ts); +int nanosleep(const struct timespec *req, struct timespec *rem); +#endif /* _MSC_VER */ #endif /* ── process ──────────────────────────────────────────────────────────── */ diff --git a/packages/runtime/src/scr_url.c b/packages/runtime/src/scr_url.c index 777440518..e8496f8ea 100644 --- a/packages/runtime/src/scr_url.c +++ b/packages/runtime/src/scr_url.c @@ -32,7 +32,9 @@ #include #include #include +#ifndef _MSC_VER #include +#endif ScrUrl *scr_url_retain(ScrUrl *u) { if (u->rc != SIZE_MAX) u->rc++; diff --git a/packages/runtime/src/scr_win.c b/packages/runtime/src/scr_win.c index 3ce9535bf..2bb2d4dc7 100644 --- a/packages/runtime/src/scr_win.c +++ b/packages/runtime/src/scr_win.c @@ -13,6 +13,115 @@ #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_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. */ From 55cbb4d1e30d602ba8435e1d748eb5e121b962ca Mon Sep 17 00:00:00 2001 From: swadhinbiswas Date: Tue, 28 Jul 2026 14:27:01 +0600 Subject: [PATCH 03/54] ci: add MSVC verification workflow for #25 Run on push/PR to fix/msvc-posix-shims only. Tests the exact scenario from #25: compiling the runtime with MSVC's bundled clang (no mingw, no zigcc) on windows-latest, including the Map + sort pattern that bare.ts uses. Also runs a Linux corpus smoke test to verify no regressions. --- .github/workflows/msvc-verify.yml | 66 +++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 .github/workflows/msvc-verify.yml diff --git a/.github/workflows/msvc-verify.yml b/.github/workflows/msvc-verify.yml new file mode 100644 index 000000000..cce8b5430 --- /dev/null +++ b/.github/workflows/msvc-verify.yml @@ -0,0 +1,66 @@ +name: MSVC POSIX Shims + +on: + push: + branches: [fix/msvc-posix-shims] + pull_request: + branches: [fix/msvc-posix-shims] + +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" From 9bebebb44293150e2ac4cd2535d08d8a6bf905f7 Mon Sep 17 00:00:00 2001 From: swadhinbiswas Date: Tue, 28 Jul 2026 14:31:40 +0600 Subject: [PATCH 04/54] runtime: guard struct timespec and drop windows.h from header - Remove #include from scr_runtime.h's _MSC_VER block: it pulled winsock.h (via windows.h) into every TU, conflicting with winsock2.h included later in scr_lib.c - Guard struct timespec with #ifndef _TIMESPEC_DEFINED: modern UCRT already defines it, so redefinition caused C1104 errors on CI --- packages/runtime/src/scr_runtime.h | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/runtime/src/scr_runtime.h b/packages/runtime/src/scr_runtime.h index c0273c837..6af543296 100644 --- a/packages/runtime/src/scr_runtime.h +++ b/packages/runtime/src/scr_runtime.h @@ -37,10 +37,12 @@ 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 +#ifndef _TIMESPEC_DEFINED +#define _TIMESPEC_DEFINED +struct timespec { time_t tv_sec; long tv_nsec; }; +#endif #define CLOCK_REALTIME 0 #define CLOCK_MONOTONIC 1 -struct timespec { time_t tv_sec; long tv_nsec; }; int clock_gettime(int clk_id, struct timespec *ts); int nanosleep(const struct timespec *req, struct timespec *rem); #endif /* _MSC_VER */ From 042c37dbbd31d3a9da9291a879bdeccdca539105 Mon Sep 17 00:00:00 2001 From: swadhinbiswas Date: Tue, 28 Jul 2026 14:36:07 +0600 Subject: [PATCH 05/54] =?UTF-8?q?runtime:=20remove=20redundant=20struct=20?= =?UTF-8?q?timespec=20=E2=80=94=20UCRT=20already=20defines=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The _TIMESPEC_DEFINED guard didn't work because UCRT's time.h defines struct timespec (line 45) but doesn't set _TIMESPEC_DEFINED in the clang/MSVC mode we're compiling in. Remove the definition entirely — UCRT 10.0.26100.0 provides it, and scr_win.c's clock_gettime/nanosleep use it without redefining. --- packages/runtime/src/scr_runtime.h | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/runtime/src/scr_runtime.h b/packages/runtime/src/scr_runtime.h index 6af543296..93969ed20 100644 --- a/packages/runtime/src/scr_runtime.h +++ b/packages/runtime/src/scr_runtime.h @@ -37,10 +37,6 @@ 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 -#ifndef _TIMESPEC_DEFINED -#define _TIMESPEC_DEFINED -struct timespec { time_t tv_sec; long tv_nsec; }; -#endif #define CLOCK_REALTIME 0 #define CLOCK_MONOTONIC 1 int clock_gettime(int clk_id, struct timespec *ts); From 80d27b78c00563945cd1ed30b8bb1ec695c648a6 Mon Sep 17 00:00:00 2001 From: swadhinbiswas Date: Tue, 28 Jul 2026 14:47:02 +0600 Subject: [PATCH 06/54] runtime: add POSIX compat shims for MSVC (PATH_MAX, mode_t, S_IS*) MSVC CRT lacks several POSIX constants/types used throughout the runtime: - PATH_MAX (use _MAX_PATH from stdlib.h) - mode_t (typedef unsigned int) - F_OK (value 0) - S_ISDIR/S_ISREG macros (use _S_IFMT/_S_IFDIR/_S_IFREG from sys/stat.h) - S_ISLNK/S_ISFIFO/S_ISSOCK/S_ISBLK/S_ISCHR (stub as 0 on Windows) These are guarded by _MSC_VER so mingw-w64 and Linux are unaffected. --- packages/runtime/src/scr_runtime.h | 32 ++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/packages/runtime/src/scr_runtime.h b/packages/runtime/src/scr_runtime.h index 93969ed20..e75b903ff 100644 --- a/packages/runtime/src/scr_runtime.h +++ b/packages/runtime/src/scr_runtime.h @@ -37,6 +37,38 @@ 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); From 02c5a90604fed218924c2f60049e10eb8b9280ea Mon Sep 17 00:00:00 2001 From: swadhinbiswas Date: Tue, 28 Jul 2026 16:32:30 +0600 Subject: [PATCH 07/54] runtime: null-terminate d_name in MSVC readdir shim WideCharToMultiByte silently drops the null terminator when the UTF-8 output fills all 260 bytes of d_name. Force-terminate after the conversion to prevent out-of-bounds reads by callers. --- packages/runtime/src/scr_win.c | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/runtime/src/scr_win.c b/packages/runtime/src/scr_win.c index 2bb2d4dc7..276f70f64 100644 --- a/packages/runtime/src/scr_win.c +++ b/packages/runtime/src/scr_win.c @@ -107,6 +107,7 @@ struct dirent *readdir(DIR *d) { 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; From 9c73be4ee5d20407eb9e1f26a3b258ecd157c482 Mon Sep 17 00:00:00 2001 From: Juan Gomez Date: Tue, 28 Jul 2026 17:15:44 -0300 Subject: [PATCH 08/54] fix(compiler): preserve FFI binding initializer calls --- .../src/frontend/lowering/lower-calls.ts | 2 +- .../src/frontend/lowering/lower-namespaces.ts | 78 +++++++++++-------- .../compiler/src/frontend/lowering/lowerer.ts | 9 +++ tests/harness/ffi.test.ts | 57 +++++++++++++- 4 files changed, 109 insertions(+), 37 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index 766fb2976..5791e74a1 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -2684,7 +2684,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)) { // TypeScript resolved this call to a distinct local declaration. // The manifest owns only the exact validated ambient binding; a // same-named function with a body remains ordinary scriptc code. diff --git a/packages/compiler/src/frontend/lowering/lower-namespaces.ts b/packages/compiler/src/frontend/lowering/lower-namespaces.ts index 0ac9def7f..303d3400f 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,53 @@ 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)) { + 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/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index dcd2999c2..95d5f9035 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -1264,6 +1264,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 diff --git a/tests/harness/ffi.test.ts b/tests/harness/ffi.test.ts index 644f103ea..8b922de21 100644 --- a/tests/harness/ffi.test.ts +++ b/tests/harness/ffi.test.ts @@ -38,12 +38,16 @@ function nativeArchive(): string { return archive; } -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)); @@ -82,6 +86,55 @@ 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("stores the result of a manifest-bound call in a function-local const", async () => { + const outDir = join(cacheRoot, `binding-initializer-${backend}`); + mkdirSync(outDir, { recursive: true }); + const entry = join(outDir, "main.ts"); + writeFileSync( + entry, + [ + "declare function nativeScale(value: number): number;", + "function main(): void {", + " const result = nativeScale(21);", + " console.log('const:', result);", + "}", + "main();", + "", + ].join("\n"), + ); + + const result = await compile(entry, { + outDir, + outPath: join(outDir, "program"), + backend, + sanitize, + ffiProfilePath: manifest(nativeArchive(), ["nativeScale"]), + 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: "const: 42\n", + stderr: "", + status: 0, + }); + + const ir = JSON.stringify(JSON.parse(readFileSync(result.irPath!, "utf8"))); + expect(ir).toContain('"kind":"ffiCall"'); + expect(ir).not.toContain('"fn":"global.undefRead"'); + }); +}); + test("manifest validation is strict and source-facing", () => { const path = join(cacheRoot, "invalid.json"); mkdirSync(cacheRoot, { recursive: true }); From 86b96cee29ef28a7d429b678811aed70b1714375 Mon Sep 17 00:00:00 2001 From: Juan Gomez Date: Tue, 28 Jul 2026 17:23:33 -0300 Subject: [PATCH 09/54] test(compiler): cover FFI call ownership boundaries --- tests/harness/ffi.test.ts | 213 +++++++++++++++++++++++++++++++++----- 1 file changed, 189 insertions(+), 24 deletions(-) diff --git a/tests/harness/ffi.test.ts b/tests/harness/ffi.test.ts index 8b922de21..2a31dd92e 100644 --- a/tests/harness/ffi.test.ts +++ b/tests/harness/ffi.test.ts @@ -21,7 +21,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"); @@ -35,7 +38,8 @@ function nativeArchive(): string { object, ]); execFileSync("ar", ["rcs", archive, object]); - return archive; + cachedNativeArchive = archive; + return cachedNativeArchive; } function manifest(archive: string, functionNames?: readonly string[]): string { @@ -54,6 +58,52 @@ function manifest(archive: string, functionNames?: readonly string[]): string { 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", @@ -87,31 +137,34 @@ 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("stores the result of a manifest-bound call in a function-local const", async () => { - const outDir = join(cacheRoot, `binding-initializer-${backend}`); - mkdirSync(outDir, { recursive: true }); - const entry = join(outDir, "main.ts"); - writeFileSync( - entry, + test("preserves exact calls across binding and early-probe contexts", async () => { + const { result } = await compileScaleFixture( + `binding-initializer-${backend}`, [ - "declare function nativeScale(value: number): number;", + "const moduleResult = nativeScale(2);", "function main(): void {", - " const result = nativeScale(21);", - " console.log('const:', result);", + " 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();", - "", - ].join("\n"), + ], + { backend, emitIr: true }, ); - - const result = await compile(entry, { - outDir, - outPath: join(outDir, "program"), - backend, - sanitize, - ffiProfilePath: manifest(nativeArchive(), ["nativeScale"]), - emitIr: true, - }); if (!result.ok) { throw new Error( result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"), @@ -124,17 +177,128 @@ describe.each(["c", "llvm"] as const)("FFI binding initializers, %s backend", (b stderr: native.stderr, status: native.status, }).toEqual({ - stdout: "const: 42\n", + 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).toContain('"kind":"ffiCall"'); + 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 }); @@ -290,7 +454,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());", "", From d89404c3749ff6aaa532c0d45045940949367fa7 Mon Sep 17 00:00:00 2001 From: Juan Gomez Date: Tue, 28 Jul 2026 17:50:28 -0300 Subject: [PATCH 10/54] fix(review): avoid redundant FFI symbol probes --- packages/compiler/src/frontend/lowering/lower-namespaces.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/compiler/src/frontend/lowering/lower-namespaces.ts b/packages/compiler/src/frontend/lowering/lower-namespaces.ts index 303d3400f..8ad09e7dd 100644 --- a/packages/compiler/src/frontend/lowering/lower-namespaces.ts +++ b/packages/compiler/src/frontend/lowering/lower-namespaces.ts @@ -371,7 +371,11 @@ export function ambientUndefVarRootOf(L: Lowerer, e: ts.Expression): ts.Identifi continue; } if (ts.isCallExpression(root) || ts.isNewExpression(root)) { - if (ts.isCallExpression(root) && ts.isIdentifier(root.expression)) { + 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 From 311850ea7b3c14c25a7c8944f9cc5ecb162f63f2 Mon Sep 17 00:00:00 2001 From: 3kaiu <3kaiu@users.noreply.github.com> Date: Wed, 29 Jul 2026 14:51:27 +0800 Subject: [PATCH 11/54] feat(compiler): add HeadersInit to island ambient types --- packages/compiler/src/frontend/types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/compiler/src/frontend/types.ts b/packages/compiler/src/frontend/types.ts index 51a2ec867..f56c02e6d 100644 --- a/packages/compiler/src/frontend/types.ts +++ b/packages/compiler/src/frontend/types.ts @@ -15,7 +15,7 @@ export { typeKey }; * signal), so under --dynamic they map to island handles (jsval) exactly * like npm-declared types. Checked with declaration provenance in mapType; * consumed by the lowerer's badType for the static-build wording. */ -export const ISLAND_AMBIENT_TYPES = ["Response", "RequestInit", "AbortSignal", "Headers"] as const; +export const ISLAND_AMBIENT_TYPES = ["Response", "RequestInit", "AbortSignal", "Headers", "HeadersInit"] as const; /** The frontend's record-shape interner. Records are monomorphic structural * shapes: fields sorted by name form the canonical identity, and two types From 6d126e992a590f9c8b0d2709aacd6f39a4a72660 Mon Sep 17 00:00:00 2001 From: 3kaiu <3kaiu@users.noreply.github.com> Date: Wed, 29 Jul 2026 14:51:27 +0800 Subject: [PATCH 12/54] feat(compiler): improve global crypto diagnostic and remove opaque token --- packages/compiler/src/frontend/lowering/lower-exprs.ts | 1 + packages/compiler/src/frontend/lowering/lower-stmts.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts index d0ab06c4c..f1908cb80 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -923,6 +923,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); } diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index 354c67c4a..885399873 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -1825,7 +1825,7 @@ export function lowerStmt(L: Lowerer, stmt: ts.Statement): IrStmt | IrStmt[] | n * the one split surface: its five CALL members fence by name, while * the rest (`Console` — the suite's constructor-identity probe) have * no surface to lose and bind tokens. */ - const TOKEN_OPAQUE_GLOBALS: ReadonlySet = new Set(["crypto"]); + const TOKEN_OPAQUE_GLOBALS: ReadonlySet = new Set([]); const CONSOLE_CALL_MEMBERS: ReadonlySet = new Set(["log", "info", "debug", "error", "warn"]); /** `const { subtle } = globalThis.crypto`, `const { Console } = console`, From 8e399affa425cea4dc6ac8821c0ed15364b3838d Mon Sep 17 00:00:00 2001 From: 3kaiu <3kaiu@users.noreply.github.com> Date: Wed, 29 Jul 2026 14:53:38 +0800 Subject: [PATCH 13/54] feat(compiler): support new URL(url, base) with string literals --- .../src/frontend/lowering/lower-classes.ts | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/packages/compiler/src/frontend/lowering/lower-classes.ts b/packages/compiler/src/frontend/lowering/lower-classes.ts index 7536ccaa3..2556be608 100644 --- a/packages/compiler/src/frontend/lowering/lower-classes.ts +++ b/packages/compiler/src/frontend/lowering/lower-classes.ts @@ -5004,11 +5004,30 @@ 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"); + return { kind: "libCall", fn: "url.new", args: [L.lowerExprExpecting(args[0]!, STRING)], type: URL_T, loc }; + } + } + 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", + ); + return { kind: "libCall", fn: "url.new", args: [L.lowerExprExpecting(args[0]!, STRING)], type: URL_T, loc }; + } 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, ); } From db5b1cc7e2c7a1fb33ae4996f5d943095e6024e4 Mon Sep 17 00:00:00 2001 From: Patrick Wozniak Date: Wed, 29 Jul 2026 15:49:39 +0200 Subject: [PATCH 14/54] fix: preserve guarded global Buffer aliases --- .../src/frontend/lowering/lower-exprs.ts | 7 ++ .../src/frontend/lowering/surfaces.ts | 14 ++- tests/harness/global-buffer-alias.test.ts | 91 +++++++++++++++++++ 3 files changed, 111 insertions(+), 1 deletion(-) create mode 100644 tests/harness/global-buffer-alias.test.ts diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts index edf8fc573..78e0010a2 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -2978,6 +2978,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) { diff --git a/packages/compiler/src/frontend/lowering/surfaces.ts b/packages/compiler/src/frontend/lowering/surfaces.ts index 42ff20d9e..fa5f9e28d 100644 --- a/packages/compiler/src/frontend/lowering/surfaces.ts +++ b/packages/compiler/src/frontend/lowering/surfaces.ts @@ -1510,10 +1510,19 @@ export const BUILTIN_MODULE_FENCE_HINTS: Record { + 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", `buffer-bytelength-${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( + "Buffer.byteLength 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); +} + +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); + }); + }, +); From 728d3a6beff986d65623e02ef93cf1604ba637ba Mon Sep 17 00:00:00 2001 From: Patrick Wozniak Date: Wed, 29 Jul 2026 16:19:09 +0200 Subject: [PATCH 15/54] fix: preserve concrete union receivers during lowering --- .../src/frontend/lowering/lower-exprs.ts | 22 +++- tests/harness/union-receiver.test.ts | 107 ++++++++++++++++++ 2 files changed, 126 insertions(+), 3 deletions(-) create mode 100644 tests/harness/union-receiver.test.ts diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts index edf8fc573..ea36773c6 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -9512,7 +9512,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 @@ -9523,8 +9523,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 @@ -9548,6 +9550,20 @@ 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"); } diff --git a/tests/harness/union-receiver.test.ts b/tests/harness/union-receiver.test.ts new file mode 100644 index 000000000..45807621f --- /dev/null +++ b/tests/harness/union-receiver.test.ts @@ -0,0 +1,107 @@ +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}-${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: true, + 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)( + `union receivers through dynamic calls, %s backend${sanitize ? " (sanitized)" : ""}`, + (backend) => { + 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, + ); + }); + + 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, + ); + }); + }, +); From 25f0aeb96d2e3596dbf3b117d20779fc8a37f862 Mon Sep 17 00:00:00 2001 From: Patrick Wozniak Date: Wed, 29 Jul 2026 16:19:25 +0200 Subject: [PATCH 16/54] feat: support unknown-typed class fields --- .../compiler/src/backend/emission/emitter.ts | 3 + packages/compiler/src/backend/llvm/classes.ts | 7 +- .../src/frontend/lowering/lower-classes.ts | 17 +--- tests/diagnostics/json-dyn.ts | 3 +- tests/harness/__snapshots__/json-dyn.ts.txt | 16 +--- tests/harness/unknown-fields.test.ts | 96 +++++++++++++++++++ 6 files changed, 110 insertions(+), 32 deletions(-) create mode 100644 tests/harness/unknown-fields.test.ts diff --git a/packages/compiler/src/backend/emission/emitter.ts b/packages/compiler/src/backend/emission/emitter.ts index c7a621940..54a24fcb4 100644 --- a/packages/compiler/src/backend/emission/emitter.ts +++ b/packages/compiler/src/backend/emission/emitter.ts @@ -1496,6 +1496,9 @@ export class CEmitter { 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 */`]; diff --git a/packages/compiler/src/backend/llvm/classes.ts b/packages/compiler/src/backend/llvm/classes.ts index ee6ccb732..c5386107b 100644 --- a/packages/compiler/src/backend/llvm/classes.ts +++ b/packages/compiler/src/backend/llvm/classes.ts @@ -219,10 +219,11 @@ 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/frontend/lowering/lower-classes.ts b/packages/compiler/src/frontend/lowering/lower-classes.ts index 7536ccaa3..8132a4de8 100644 --- a/packages/compiler/src/frontend/lowering/lower-classes.ts +++ b/packages/compiler/src/frontend/lowering/lower-classes.ts @@ -1229,9 +1229,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, @@ -1473,11 +1470,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 @@ -1588,10 +1583,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)) { 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/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/unknown-fields.test.ts b/tests/harness/unknown-fields.test.ts new file mode 100644 index 000000000..c16da1c46 --- /dev/null +++ b/tests/harness/unknown-fields.test.ts @@ -0,0 +1,96 @@ +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 cacheDir = join(tmpdir(), "scriptc-unknown-fields-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 Holder { + value: unknown; + initialized: unknown = { count: 3 }; + static current: unknown = "ready"; + + constructor(public argument: unknown) {} +} + +const h = new Holder(42); +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); + }); + }, +); From 16ec42405553bd534934637ddcb70a015c5f91c6 Mon Sep 17 00:00:00 2001 From: Patrick Wozniak Date: Wed, 29 Jul 2026 16:19:40 +0200 Subject: [PATCH 17/54] test: key differential oracle cache by environment --- tests/harness/differential.test.ts | 5 +++-- tests/harness/oracle-environment.test.ts | 22 +++++++++++++++++++++ tests/harness/oracle-environment.ts | 25 ++++++++++++++++++++++++ 3 files changed, 50 insertions(+), 2 deletions(-) create mode 100644 tests/harness/oracle-environment.test.ts create mode 100644 tests/harness/oracle-environment.ts diff --git a/tests/harness/differential.test.ts b/tests/harness/differential.test.ts index 0cc7ef5f2..e47e6bdf6 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 { oracleEnvironmentFingerprint } from "./oracle-environment.js"; import { shardSelect, shardSuffix } from "./shard.js"; const execFileAsync = promisify(execFile); @@ -240,14 +241,14 @@ function oracleKeyBase(): Promise { // trusting process.version (vitest's own node could differ). oracleKeyBaseMemo ??= execFileAsync("node", ["--version"]).then(({ stdout }) => createHash("sha256") - .update("oracle-v1\0") + .update("oracle-v2\0") .update(stdout.trim()).update("\0") // 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(oracleEnvironmentFingerprint(process.env)).update("\0") .update(process.cwd()).update("\0") .digest("hex"), ); diff --git a/tests/harness/oracle-environment.test.ts b/tests/harness/oracle-environment.test.ts new file mode 100644 index 000000000..16ae0ee45 --- /dev/null +++ b/tests/harness/oracle-environment.test.ts @@ -0,0 +1,22 @@ +import { expect, test } from "vitest"; +import { oracleEnvironmentFingerprint } from "./oracle-environment.js"; + +test("oracle environment fingerprint changes with Node path and home inputs", () => { + const base = oracleEnvironmentFingerprint({ HOME: "/home/one", PATH: "/bin", TMPDIR: "/tmp/one" }); + + expect(oracleEnvironmentFingerprint({ HOME: "/home/one", PATH: "/bin", TMPDIR: "/tmp/two" })).not.toBe(base); + expect(oracleEnvironmentFingerprint({ HOME: "/home/two", PATH: "/bin", TMPDIR: "/tmp/one" })).not.toBe(base); + expect(oracleEnvironmentFingerprint({ HOME: "/home/one", PATH: "/sbin", TMPDIR: "/tmp/one" })).not.toBe(base); +}); + +test("oracle environment fingerprint distinguishes unset and empty variables", () => { + expect(oracleEnvironmentFingerprint({ TMPDIR: undefined })).not.toBe( + oracleEnvironmentFingerprint({ TMPDIR: "" }), + ); +}); + +test("oracle environment fingerprint ignores unrelated variables", () => { + expect(oracleEnvironmentFingerprint({ TMPDIR: "/tmp", UNRELATED: "one" })).toBe( + oracleEnvironmentFingerprint({ TMPDIR: "/tmp", UNRELATED: "two" }), + ); +}); diff --git a/tests/harness/oracle-environment.ts b/tests/harness/oracle-environment.ts new file mode 100644 index 000000000..0838475f5 --- /dev/null +++ b/tests/harness/oracle-environment.ts @@ -0,0 +1,25 @@ +const ORACLE_ENVIRONMENT_KEYS = [ + "HOME", + "HOMEDRIVE", + "HOMEPATH", + "PATH", + "SCRIPTC_TEST_ENV", + "SystemRoot", + "TEMP", + "TMP", + "TMPDIR", + "USERPROFILE", + "windir", +]; + +/** + * Environment inputs that can change observable Node oracle output in the + * corpus without changing program bytes. Values are length-framed so unset, + * empty, and delimiter-containing variables remain distinct. + */ +export function oracleEnvironmentFingerprint(env: NodeJS.ProcessEnv): string { + return ORACLE_ENVIRONMENT_KEYS.map((key) => { + const value = env[key]; + return value === undefined ? `${key}:unset;` : `${key}:${value.length}:${value};`; + }).join(""); +} From 96bffc749280f6550e8abf1b51e286cbad433f23 Mon Sep 17 00:00:00 2001 From: Patrick Wozniak Date: Wed, 29 Jul 2026 19:17:48 +0200 Subject: [PATCH 18/54] test: cover guarded Buffer alias fences --- tests/harness/global-buffer-alias.test.ts | 35 +++++++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/tests/harness/global-buffer-alias.test.ts b/tests/harness/global-buffer-alias.test.ts index ca749287c..0a20da16d 100644 --- a/tests/harness/global-buffer-alias.test.ts +++ b/tests/harness/global-buffer-alias.test.ts @@ -39,7 +39,7 @@ async function compileAndCompare(source: string, backend: "c" | "llvm"): Promise .update(`${backend}-${sanitize ? "san" : "plain"}`) .digest("hex") .slice(0, 16); - const outDir = join(tmpdir(), "scriptc-tests", `buffer-bytelength-${key}`); + const outDir = join(tmpdir(), "scriptc-tests", `global-buffer-alias-${key}`); mkdirSync(outDir, { recursive: true }); const file = join(outDir, "main.mts"); writeFileSync(file, source); @@ -51,7 +51,7 @@ async function compileAndCompare(source: string, backend: "c" | "llvm"): Promise }); if (!result.ok) { throw new Error( - "Buffer.byteLength program failed to compile:\n" + + "guarded global Buffer alias program failed to compile:\n" + result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"), ); } @@ -64,6 +64,25 @@ async function compileAndCompare(source: string, backend: "c" | "llvm"): Promise 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) => { @@ -85,6 +104,18 @@ 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); }); }, From c1aa6bba23592c520fc69b43706ddfd3e23f6298 Mon Sep 17 00:00:00 2001 From: Patrick Wozniak Date: Wed, 29 Jul 2026 19:17:57 +0200 Subject: [PATCH 19/54] test: cover direct concrete union receivers --- tests/harness/union-receiver.test.ts | 33 ++++++++++++++++++++++------ 1 file changed, 26 insertions(+), 7 deletions(-) diff --git a/tests/harness/union-receiver.test.ts b/tests/harness/union-receiver.test.ts index 45807621f..675c4556d 100644 --- a/tests/harness/union-receiver.test.ts +++ b/tests/harness/union-receiver.test.ts @@ -34,7 +34,12 @@ async function run(cmd: string, args: string[]): Promise { } } -async function compileAndCompare(name: string, source: string, backend: "c" | "llvm"): Promise { +async function compileAndCompare( + name: string, + source: string, + backend: "c" | "llvm", + dynamic: boolean, +): Promise { const key = createHash("sha256") .update(source) .update(`${backend}-${sanitize ? "san" : "plain"}`) @@ -48,7 +53,7 @@ async function compileAndCompare(name: string, source: string, backend: "c" | "l outPath: join(outDir, "program"), outDir, sanitize, - dynamic: true, + dynamic, backend, }); if (!result.ok) { @@ -74,24 +79,37 @@ const concrete = new A(); `; describe.each(["c", "llvm"] as const)( - `union receivers through dynamic calls, %s backend${sanitize ? " (sanitized)" : ""}`, + `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} + "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} + "dyn-call-variants", + `${prelude} const dyn = JSON.parse('{"values":[]}'); dyn.values.push(/** @type {Item} */ (concrete).value); dyn.values.push({ @@ -101,6 +119,7 @@ dyn.values.push({ console.log(dyn.values[0], dyn.values[1].direct, dyn.values[1].optional); `, backend, + true, ); }); }, From c8b0f1396a198dc75cee48b120b77da132cd6bb1 Mon Sep 17 00:00:00 2001 From: Patrick Wozniak Date: Wed, 29 Jul 2026 19:18:03 +0200 Subject: [PATCH 20/54] test: cover inherited unknown fields --- packages/compiler/src/backend/emission/emitter.ts | 8 ++++---- packages/compiler/src/backend/llvm/classes.ts | 5 +++-- tests/harness/unknown-fields.test.ts | 13 +++++++++++-- 3 files changed, 18 insertions(+), 8 deletions(-) diff --git a/packages/compiler/src/backend/emission/emitter.ts b/packages/compiler/src/backend/emission/emitter.ts index 54a24fcb4..435de2a8a 100644 --- a/packages/compiler/src/backend/emission/emitter.ts +++ b/packages/compiler/src/backend/emission/emitter.ts @@ -1486,10 +1486,10 @@ 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 + * 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. 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[] { diff --git a/packages/compiler/src/backend/llvm/classes.ts b/packages/compiler/src/backend/llvm/classes.ts index c5386107b..cbf1b1896 100644 --- a/packages/compiler/src/backend/llvm/classes.ts +++ b/packages/compiler/src/backend/llvm/classes.ts @@ -213,8 +213,9 @@ 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) => { diff --git a/tests/harness/unknown-fields.test.ts b/tests/harness/unknown-fields.test.ts index c16da1c46..0aba265f9 100644 --- a/tests/harness/unknown-fields.test.ts +++ b/tests/harness/unknown-fields.test.ts @@ -66,15 +66,24 @@ async function compileAndCompare(name: string, source: string, backend: "c" | "l expect(nativeResult.exitCode).toBe(nodeResult.exitCode); } -const source = `class Holder { +const source = `class Base { + inherited: unknown; +} + +class Holder extends Base { value: unknown; initialized: unknown = { count: 3 }; static current: unknown = "ready"; - constructor(public argument: unknown) {} + 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); From 466412e9b7ff366eaab753477bd770f295152701 Mon Sep 17 00:00:00 2001 From: Patrick Wozniak Date: Wed, 29 Jul 2026 19:18:10 +0200 Subject: [PATCH 21/54] test: fingerprint the complete oracle environment --- tests/harness/differential.test.ts | 23 +++++----- tests/harness/oracle-environment.test.ts | 48 +++++++++++++++----- tests/harness/oracle-environment.ts | 57 +++++++++++++++--------- 3 files changed, 84 insertions(+), 44 deletions(-) diff --git a/tests/harness/differential.test.ts b/tests/harness/differential.test.ts index e47e6bdf6..b46e6bebe 100644 --- a/tests/harness/differential.test.ts +++ b/tests/harness/differential.test.ts @@ -17,7 +17,7 @@ import { promisify } from "node:util"; import { describe, expect, test } from "vitest"; import ts5 from "typescript"; import { compile } from "@scriptc/compiler"; -import { oracleEnvironmentFingerprint } from "./oracle-environment.js"; +import { oracleCacheKeyBase } from "./oracle-environment.js"; import { shardSelect, shardSuffix } from "./shard.js"; const execFileAsync = promisify(execFile); @@ -224,8 +224,8 @@ 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 +// 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). @@ -240,17 +240,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-v2\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(oracleEnvironmentFingerprint(process.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/oracle-environment.test.ts b/tests/harness/oracle-environment.test.ts index 16ae0ee45..92327f774 100644 --- a/tests/harness/oracle-environment.test.ts +++ b/tests/harness/oracle-environment.test.ts @@ -1,22 +1,46 @@ import { expect, test } from "vitest"; -import { oracleEnvironmentFingerprint } from "./oracle-environment.js"; +import { oracleCacheKeyBase, oracleEnvironmentFingerprint } from "./oracle-environment.js"; -test("oracle environment fingerprint changes with Node path and home inputs", () => { - const base = oracleEnvironmentFingerprint({ HOME: "/home/one", PATH: "/bin", TMPDIR: "/tmp/one" }); +test("oracle environment fingerprint covers arbitrary output-affecting variables", () => { + const base = oracleEnvironmentFingerprint({ NODE_ENV: "development", SCRIPTC_NEVER: "no" }); - expect(oracleEnvironmentFingerprint({ HOME: "/home/one", PATH: "/bin", TMPDIR: "/tmp/two" })).not.toBe(base); - expect(oracleEnvironmentFingerprint({ HOME: "/home/two", PATH: "/bin", TMPDIR: "/tmp/one" })).not.toBe(base); - expect(oracleEnvironmentFingerprint({ HOME: "/home/one", PATH: "/sbin", TMPDIR: "/tmp/one" })).not.toBe(base); + 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 distinguishes unset and empty variables", () => { - expect(oracleEnvironmentFingerprint({ TMPDIR: undefined })).not.toBe( - oracleEnvironmentFingerprint({ TMPDIR: "" }), +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 ignores unrelated variables", () => { - expect(oracleEnvironmentFingerprint({ TMPDIR: "/tmp", UNRELATED: "one" })).toBe( - oracleEnvironmentFingerprint({ TMPDIR: "/tmp", UNRELATED: "two" }), +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 index 0838475f5..2486ab58e 100644 --- a/tests/harness/oracle-environment.ts +++ b/tests/harness/oracle-environment.ts @@ -1,25 +1,42 @@ -const ORACLE_ENVIRONMENT_KEYS = [ - "HOME", - "HOMEDRIVE", - "HOMEPATH", - "PATH", - "SCRIPTC_TEST_ENV", - "SystemRoot", - "TEMP", - "TMP", - "TMPDIR", - "USERPROFILE", - "windir", -]; +import { createHash } from "node:crypto"; /** - * Environment inputs that can change observable Node oracle output in the - * corpus without changing program bytes. Values are length-framed so unset, - * empty, and delimiter-containing variables remain distinct. + * 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 ORACLE_ENVIRONMENT_KEYS.map((key) => { - const value = env[key]; - return value === undefined ? `${key}:unset;` : `${key}:${value.length}:${value};`; - }).join(""); + 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"); } From 9010c87a75e1b6198567c06b7243fd4cdcdf571c Mon Sep 17 00:00:00 2001 From: CooperSheroy Date: Sat, 1 Aug 2026 09:12:05 +0530 Subject: [PATCH 22/54] fix: lower void conditionals as statements --- .../compiler/src/frontend/lowering/lower-calls.ts | 10 ++-------- .../compiler/src/frontend/lowering/lower-stmts.ts | 12 ++++++++++++ packages/compiler/src/frontend/lowering/lowerer.ts | 11 +++++++++++ tests/corpus/2352-void-coercions.ts | 7 +++++++ 4 files changed, 32 insertions(+), 8 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index 766fb2976..0c20157e7 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -5432,14 +5432,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-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index 9d08f5ea9..3766330db 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -4142,6 +4142,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 fab1e4a93..112405546 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -5805,6 +5805,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 }; 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; From 27537e0718f9105781cfe37dbd2da52151b7cf4d Mon Sep 17 00:00:00 2001 From: Eli Sterling Date: Mon, 10 Aug 2026 05:10:25 +0000 Subject: [PATCH 23/54] Fix dynamic promise runtime gating --- packages/compiler/src/ir/nodes.ts | 8 ++++++++ tests/harness/island.test.ts | 10 ++++++++++ 2 files changed, 18 insertions(+) diff --git a/packages/compiler/src/ir/nodes.ts b/packages/compiler/src/ir/nodes.ts index 54ea11bc1..6736c4677 100644 --- a/packages/compiler/src/ir/nodes.ts +++ b/packages/compiler/src/ir/nodes.ts @@ -6074,6 +6074,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); 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; From 92a5a1c5fe1a7cabfbf816a12602b743601059bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=AA=20H=C3=B9ng=20Quang=20Minh?= <158133523+dismonjames@users.noreply.github.com> Date: Tue, 11 Aug 2026 19:59:28 +0700 Subject: [PATCH 24/54] coverage: distinguish partial dynamic shims - Mark node:crypto, node:zlib, and node:fs(+fs/promises) as partial: their island shims cover only a slice of Node's surface and the rest throw at the call. - Report partial shims as 'partial' in the --dynamic builtins table, with a note, so they are not indistinguishable from fully implemented shims. - Add a coverage snapshot for the crypto-shims fixture; full shims stay 'shimmed' (the esbuild-require snapshot pins that side). --- packages/compiler/src/coverage/report.ts | 9 ++++++- packages/compiler/src/frontend/npm.ts | 24 +++++++++++++++++++ .../coverage-npm-partial-builtin.txt | 11 +++++++++ tests/harness/coverage.test.ts | 11 +++++++++ 4 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 tests/harness/__snapshots__/coverage-npm-partial-builtin.txt 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/frontend/npm.ts b/packages/compiler/src/frontend/npm.ts index eb9a85247..1421f7e09 100644 --- a/packages/compiler/src/frontend/npm.ts +++ b/packages/compiler/src/frontend/npm.ts @@ -189,6 +189,11 @@ export interface NpmBuiltinUse { builtin: string; /** Whether the island ships a shim for it. */ shimmed: boolean; + /** Shimmed, but the island covers only PART of Node's surface: the + * implemented members work, the rest are throwing stubs. Coverage reports + * this so a partial shim is not indistinguishable from a complete one. + * Always false when not shimmed. */ + partial: boolean; /** Unshimmed AND only require()/import() edges reach it: the build * embeds the island's lazy throw at the call instead of failing — * Node's laziness for those edge kinds (Node itself would LOAD the @@ -270,6 +275,24 @@ const SHIMMED_BUILTINS = new Set([ "v8", ]); +/** Shimmed builtins whose island shim covers only PART of Node's surface: + * the members the shim carries work, the rest are honest throwing stubs + * (scr_island.c). `SHIMMED_BUILTINS` minus this set is the full-shim set, + * so coverage can tell a complete shim from a partial one without a + * per-function capability database. A partial shim still LOADS and runs + * programs that stay within its implemented slice, so it is not a blocker. */ +const PARTIAL_SHIM_BUILTINS = new Set([ + // The hashing/random/pbkdf2 slice; keys, ciphers, signing, and the rest + // throw at the call (the embedded runtime carries the hashing/random + // slice only). + "crypto", + // deflate/gzip and the buffering stream classes; brotli and zstd throw. + "zlib", + // Whole-file reads/writes (readFile/writeFile/mkdir/stat/readdir/...); + // watch, open, and incremental read/write throw. + "fs", "fs/promises", +]); + /** Node builtins importable WITHOUT the "node:" prefix — used to tell * "missing builtin shim" apart from "missing package" for bare specifiers. * ("node:"-prefixed specifiers are always builtins: the prefix cannot name @@ -971,6 +994,7 @@ export class NpmGraphBuilder { return { builtin, shimmed, + partial: shimmed && PARTIAL_SHIM_BUILTINS.has(builtin.slice(5)), lazy: !shimmed && !this.builtinsEager.has(builtin), packages: [...packages].sort(), }; 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/coverage.test.ts b/tests/harness/coverage.test.ts index 720a8ebc9..4e8ffb80a 100644 --- a/tests/harness/coverage.test.ts +++ b/tests/harness/coverage.test.ts @@ -101,6 +101,17 @@ test("lazy builtin edges mark in the builtins table, __require sites included", ).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("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 From 7252e02994dc2252565e0960424f9e3125768c3c Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Tue, 11 Aug 2026 09:06:06 -0500 Subject: [PATCH 25/54] fix: classify all partial dynamic shims --- packages/compiler/src/frontend/npm.ts | 24 +++++++++-- .../coverage-npm-lazy-builtin.txt | 3 +- tests/harness/coverage.test.ts | 40 +++++++++++++++++-- 3 files changed, 60 insertions(+), 7 deletions(-) diff --git a/packages/compiler/src/frontend/npm.ts b/packages/compiler/src/frontend/npm.ts index 1421f7e09..e8f7fdbb8 100644 --- a/packages/compiler/src/frontend/npm.ts +++ b/packages/compiler/src/frontend/npm.ts @@ -282,15 +282,33 @@ const SHIMMED_BUILTINS = new Set([ * per-function capability database. A partial shim still LOADS and runs * programs that stay within its implemented slice, so it is not a blocker. */ const PARTIAL_SHIM_BUILTINS = new Set([ + // Broad process plumbing with explicit fences: process.umask only supports + // its read form; module.register is unavailable; child_process is a + // load-only surface whose process-launching members all throw. + "process", "module", "child_process", + // Buffer is broadly implemented, but transcode remains unavailable. + "buffer", + // Whole-file reads/writes (readFile/writeFile/mkdir/stat/readdir/...); + // watch, open, and incremental read/write throw. + "fs", "fs/promises", // The hashing/random/pbkdf2 slice; keys, ciphers, signing, and the rest // throw at the call (the embedded runtime carries the hashing/random // slice only). "crypto", + // The stream classes and pipeline helpers work; the consumers submodule's + // Blob conversion remains an explicit fence. + "stream/consumers", // deflate/gzip and the buffering stream classes; brotli and zstd throw. "zlib", - // Whole-file reads/writes (readFile/writeFile/mkdir/stat/readdir/...); - // watch, open, and incremental read/write throw. - "fs", "fs/promises", + // DNS has the loadable Node shape but every resolver call fences. The + // main-thread worker plumbing is real, while Worker construction throws. + "dns", "worker_threads", + // HTTP(S) implements the request/get client slice only. net/tls provide + // address/load plumbing for it, but their direct socket surfaces throw. + "http", "https", "net", "tls", + // Startup-snapshot/heap metadata is loadable; V8 serialization, heap + // snapshots, profiling, and promise hooks remain call-time fences. + "v8", ]); /** Node builtins importable WITHOUT the "node:" prefix — used to tell 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/coverage.test.ts b/tests/harness/coverage.test.ts index 4e8ffb80a..de84bc6df 100644 --- a/tests/harness/coverage.test.ts +++ b/tests/harness/coverage.test.ts @@ -93,9 +93,9 @@ 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"); @@ -112,6 +112,40 @@ test("a partial dynamic shim is reported as partial, not fully shimmed", async ( ).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 From 955d6b18a9bab6a2f5530f2cf8d1014d90001052 Mon Sep 17 00:00:00 2001 From: codeAnqiang-ma <273298913+codeAnqiang-ma@users.noreply.github.com> Date: Thu, 13 Aug 2026 00:48:27 +0800 Subject: [PATCH 26/54] fix(compiler): keep maybeNaN on the failed edge of ordered comparisons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit refine() derived clearNaN from the negated operator, treating the failed edge of a < b as a >= b having held — the two differ exactly when a side is NaN, so guard-clause spellings let NaN be "proven" whole and cross a declared i64/u64 slot as an unchecked (int64_t) / fptosi conversion. Judge NaN exclusion by the relation that actually held on the edge; the numeric interval refinement is unchanged. Co-authored-by: Cursor --- .../compiler/src/library/int-infer.test.ts | 66 +++++++++++++++++++ packages/compiler/src/library/int-infer.ts | 10 +-- tests/harness/library-int.test.ts | 13 ++++ 3 files changed, 85 insertions(+), 4 deletions(-) diff --git a/packages/compiler/src/library/int-infer.test.ts b/packages/compiler/src/library/int-infer.test.ts index 7d74c159b..c14e6834c 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, }); @@ -507,6 +508,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 97ee14258..979878bb7 100644 --- a/packages/compiler/src/library/int-infer.ts +++ b/packages/compiler/src/library/int-infer.ts @@ -1209,10 +1209,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). - const clearNaN = op !== "!=="; + // NaN makes < <= > >= === evaluate false, so only the edge where one + // of those HELD proves both operands NaN-free; on the failed edge NaN + // survives (¬(a < b) does not imply a >= b — both are false when a is + // NaN), though the negated comparison still refines the numeric + // members. !== is the mirror image: its FAILED edge means === held. + const clearNaN = branch !== (cond.op === "!=="); const a = this.evalPure(cond.left, env); const b = this.evalPure(cond.right, env); const out = cloneEnv(env); 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);`, From 5bf752bb4b5b48d425ed3f33e70fd59f39eb7eb2 Mon Sep 17 00:00:00 2001 From: iplanwebsites <787729+iplanwebsites@users.noreply.github.com> Date: Thu, 13 Aug 2026 12:39:17 -0400 Subject: [PATCH 27/54] feat(math): lower DSP scalar functions statically --- .../src/backend/emission/emit-exprs.ts | 14 +++++++ packages/compiler/src/backend/llvm/emitter.ts | 29 +++++++++++++++ .../compiler/src/coverage/surface-manifest.ts | 9 ++++- .../src/frontend/lowering/lower-island.ts | 6 ++- .../src/frontend/lowering/surfaces.ts | 14 +++++++ packages/compiler/src/ir/nodes.ts | 7 ++++ packages/compiler/src/ir/validate.ts | 7 ++++ packages/compiler/surface-manifest.json | 37 +++++++++++-------- .../test/ts7/baselines/order-parity.json | 6 +++ tests/corpus/2607-math-dsp-static.js | 14 +++++++ tests/coverage-fixtures/dynamic-mix.ts | 2 +- tests/diagnostics/dynamic-surface.ts | 4 +- .../__snapshots__/coverage-dynamic-mix.txt | 2 +- .../__snapshots__/dynamic-surface.ts.txt | 16 ++++---- tests/harness/library-mode.test.ts | 14 +++---- tests/harness/library-profile.test.ts | 10 ++--- tests/harness/surface-manifest.test.ts | 5 ++- 17 files changed, 151 insertions(+), 45 deletions(-) create mode 100644 tests/corpus/2607-math-dsp-static.js diff --git a/packages/compiler/src/backend/emission/emit-exprs.ts b/packages/compiler/src/backend/emission/emit-exprs.ts index a710947e8..30476ad6b 100644 --- a/packages/compiler/src/backend/emission/emit-exprs.ts +++ b/packages/compiler/src/backend/emission/emit-exprs.ts @@ -3376,6 +3376,20 @@ 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)})`); + 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)})`); + 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 diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 2f78eeb94..7100c6de0 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -13729,6 +13729,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/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/frontend/lowering/lower-island.ts b/packages/compiler/src/frontend/lowering/lower-island.ts index b5a7023c4..666f578b2 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/surfaces.ts b/packages/compiler/src/frontend/lowering/surfaces.ts index 4ccf0c2e5..1743ef28d 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. */ diff --git a/packages/compiler/src/ir/nodes.ts b/packages/compiler/src/ir/nodes.ts index ca36c7cf9..35b7031f6 100644 --- a/packages/compiler/src/ir/nodes.ts +++ b/packages/compiler/src/ir/nodes.ts @@ -1930,6 +1930,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 diff --git a/packages/compiler/src/ir/validate.ts b/packages/compiler/src/ir/validate.ts index 51a52b2ee..324d1b817 100644 --- a/packages/compiler/src/ir/validate.ts +++ b/packages/compiler/src/ir/validate.ts @@ -210,6 +210,13 @@ export const LIB_FN_SIGS: Record/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" 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/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/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__/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/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 bbe81fc75..c8c7151bc 100644 --- a/tests/harness/library-profile.test.ts +++ b/tests/harness/library-profile.test.ts @@ -349,7 +349,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" }, ], }, }), @@ -369,9 +369,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", () => { @@ -381,7 +381,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/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", From c589e9da6f9391ae55c1fdf2c579a509e93e9922 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 02:59:03 +0000 Subject: [PATCH 28/54] docs: add native MIDI messaging port plan and TODO Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01EYLKF6JBn2Fozts9CGr9W6 --- docs/plans/midi-native-port.md | 269 +++++++++++++++++++++++++++++++++ 1 file changed, 269 insertions(+) create mode 100644 docs/plans/midi-native-port.md diff --git a/docs/plans/midi-native-port.md b/docs/plans/midi-native-port.md new file mode 100644 index 000000000..1b71739db --- /dev/null +++ b/docs/plans/midi-native-port.md @@ -0,0 +1,269 @@ +# Plan: Native MIDI messaging support for scriptc + +Status: proposed · Owner: compiler+runtime · Target branch: `claude/midi-native-port-plan-1mmmsq` + +## 1. Goal + +MIDI messaging is available to JavaScript today in two shapes: + +- **Web** — the [Web MIDI API](https://www.w3.org/TR/webmidi/): `navigator.requestMIDIAccess()` + yields a `MIDIAccess` with `inputs`/`outputs` maps of `MIDIInput`/`MIDIOutput` + ports; you receive with `input.onmidimessage` (a `MIDIMessageEvent` carrying a + `Uint8Array` `data`) and transmit with `output.send(data, timestamp?)`. +- **Server (Node)** — native addons over the platform MIDI stacks, the de-facto + standard being [`node-midi`](https://github.com/justinlatimer/node-midi) and its + maintained fork [`@julusian/midi`](https://github.com/Julusian/node-midi) + (RtMidi under the hood), plus the ergonomic wrapper + [`easymidi`](https://github.com/dinchak/node-easymidi). Core surface: + `new midi.Input()` / `new midi.Output()`, `getPortCount()`, `getPortName(i)`, + `openPort(i)`, `openVirtualPort(name)`, `input.on('message', (dt, msg) => …)`, + `output.sendMessage([status, d1, d2])`, `closePort()`, `ignoreTypes(...)`. + +scriptc compiles TS/JS to **native executables** (macOS/Linux/Windows) and to +**WASI** wasm. There is no MIDI surface today. This plan ports **MIDI messaging +core features** — enumerate ports, open input/output (incl. virtual ports), +receive time-stamped messages via an event, and send raw messages — to +scriptc's native runtime, exposed through a Node-shaped `node:midi` module +surface that is differential-testable against a real Node baseline. + +### Scope + +**In scope (core messaging):** +- Port enumeration: `getPortCount()`, `getPortName(index)`. +- Input: `new Input()`, `openPort(i)`, `openVirtualPort(name)`, `closePort()`, + `on('message', cb)` / `once('message', cb)`, `ignoreTypes(sysex, timing, sense)`. +- Output: `new Output()`, `openPort(i)`, `openVirtualPort(name)`, `closePort()`, + `sendMessage(number[] | Uint8Array)`. +- Message payloads carry raw bytes (Note On/Off, CC, Program Change, Pitch Bend, + channel pressure, and SysEx as a byte run) — the runtime is byte-transparent; + it does not parse or validate message semantics. A thin optional decode helper + (note/CC accessors) may follow but is **not** core. +- Delta-time (seconds since the previous message on that input), matching + node-midi's `message` callback first argument. + +**Out of scope (this port):** +- Browser Web MIDI in the WASI target (WASI Preview 1 has no MIDI capability — it + fences, see §6). The *API shape* is modeled to stay portable, but the wasm + target refuses MIDI at compile time like it does sockets. +- MIDI file (SMF) parsing, sequencing/clock scheduling, SysEx device protocols, + MIDI 2.0 / UMP, virtual-MIDI on Windows (WinMM has no user-space virtual ports). +- `easymidi`-style semantic event names (`noteon`, `cc`, …). Those can be a + pure-TS layer on top later; the native core stays raw-byte. + +### Why a Node-module shape (not a Web-MIDI global) + +The corpus is **differential against Node**: every program runs under Node and as +a native binary and must match stdout/stderr/exit byte-for-byte (AGENTS.md). Node +has no built-in MIDI, but `@julusian/midi` provides one under the same +`import midi from "midi"` name we target, **and** it supports `openVirtualPort`, +which gives us a hardware-free deterministic loopback for tests (open a virtual +output, open an input on that virtual port, send, receive, compare). Modeling on +the Web MIDI global would have no Node baseline to diff against. So: `node:midi` +module surface, API-compatible with node-midi/@julusian/midi. + +## 2. How scriptc adds a native module surface (the dgram template) + +`node:dgram` is the closest existing analog: an event-driven, message-oriented +device/socket handle whose reads feed the event loop. A MIDI input is +structurally the same (a pollable source delivering discrete messages), and a +MIDI output is like a connected UDP socket (`sendMessage` ≈ `send`). Every +touchpoint below is mirrored from dgram. + +| Concern | dgram implementation | MIDI equivalent to build | +| --- | --- | --- | +| Ambient types | `declare module "dgram"` / `"node:dgram"` in `ambient/scriptc-node-fallback.d.ts` | `declare module "midi"` / `"node:midi"` | +| IR handle type | `dgramSocket` in `ir/nodes.ts` (kind union, `HANDLE_KINDS`, `DGRAMSOCK_T`, refcount predicate, `moduleUsesDgram`) | `midiInput`, `midiOutput` kinds + `moduleUsesMidi` | +| Type mapping | `types.ts` maps ambient `Socket` (declared in `dgram`) → `{kind:"dgramSocket"}` | ambient `Input`/`Output` → `midiInput`/`midiOutput` | +| Lowering spoke | `lowering/lower-dgram.ts` (module fns + method calls + event listeners), dispatched from `lowerer.ts` & `lower-calls.ts` | new `lowering/lower-midi.ts`, dispatched the same way | +| Module registry | `SUPPORTED_BUILTIN_MODULES` in `frontend/shared.ts`; builtin set in `frontend/npm.ts`; keys in `surfaces.ts` | add `"midi"` to all three | +| Runtime C | `runtime/src/scr_dgram.c` over the `scr_platform.h` poller seam | new `runtime/src/scr_midi.c` (+ platform backends) | +| Build inclusion | conditional TU behind `moduleUsesDgram`/`net` in `backend/cc.ts`, flagged from `index.ts` | conditional TU behind `moduleUsesMidi` | +| WASI fence | `index.ts` refuses `dgram.`/`dgramSocket` on WASI with SC3002 | refuse `midi`/`midiInput`/`midiOutput` on WASI | +| Tests | `tests/fixtures/dgram/cases/*`, `tests/corpus/*dgram*`, `tests/harness/dgram.test.ts` | `tests/fixtures/midi/*`, corpus, `tests/harness/midi.test.ts` | +| Docs | platforms / limitations / dependencies pages under `docs/` | same pages + a MIDI note | +| Manifest | projected into `surface-manifest.json` via `pnpm manifest` | regenerate | + +## 3. Proposed API surface (ambient `.d.ts`) + +Mirrors node-midi/@julusian/midi so the Node differential baseline is a real, +installable package. + +```ts +declare module "midi" { + export class Input { + getPortCount(): number; + getPortName(port: number): string; + openPort(port: number): void; + openVirtualPort(name: string): void; // POSIX only; fences on Windows + closePort(): void; + isPortOpen(): boolean; + // sysex, timing (clock), activeSensing — each true = ignore (node-midi default true,true,true) + 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; // POSIX only; fences on Windows + closePort(): void; + isPortOpen(): boolean; + sendMessage(message: number[] | Uint8Array): void; + } +} +declare module "node:midi" { export * from "midi"; } +``` + +Constrained call forms (the surfaces.ts stance): `sendMessage` takes an array +literal or a `Uint8Array`; `on`/`once` accept only the `"message"` event with a +`(deltaTime, message)` void arrow/function of ≤2 params (the +`lowerCallbackArg` pattern from lower-dgram). Anything else fences +member-qualified with a named hint (never a silent drop). + +## 4. Runtime design (`scr_midi.c` + platform backends) + +### Handle model +`ScrMidiInput` and `ScrMidiOutput` are refcounted handles like `ScrDgramSocket`. +An **open input** holds the loop alive (a live source, like a bound socket); +an output does not (send is fire-and-forget). Both are freed on `closePort()` ++ last ref drop; the unit forgets any registered fd before closing it. + +### Event-loop integration (the `scr_platform.h` seam) +The runtime already exposes a readiness poller: `scrp_poller_new`, +`scrp_watch_read(fd,…)`, `scrp_forget(fd)`, `scrp_drain(...)` (kqueue/epoll/wsapoll). +The loop (`scr_async.c`) will call a new `scr_midi_dispatch()` each turn, exactly +as it calls `scr_dgram_dispatch()`. + +- **Linux — ALSA sequencer (`libasound`).** `snd_seq_open`, create a port, + subscribe. ALSA exposes pollable fds via `snd_seq_poll_descriptors()` → + register each with `scrp_watch_read`; on readiness `snd_seq_event_input()` and + translate seq events to raw MIDI bytes (`snd_midi_event_decode`). Virtual ports + are native (an ALSA port other clients connect to). **Container note:** ALSA + dev headers are absent here (`/usr/include/alsa/asoundlib.h` missing) and CI has + no sound stack — the Linux backend is written behind the seam and validated on a + host with ALSA; loopback tests use the virtual-port pair so no hardware is needed. +- **macOS — CoreMIDI (`-framework CoreMIDI`).** `MIDIClientCreate`, + `MIDIInputPortCreate` with a read callback that fires **on a CoreMIDI thread**. + Bridge to the loop with a self-pipe/`eventfd`: the callback enqueues the packet + on a mutex-guarded ring and writes one byte; the pipe read-end is registered + with `scrp_watch_read`, so `scr_midi_dispatch` drains the ring on the loop + thread and fires JS listeners there (never call into the runtime from the + CoreMIDI thread). `MIDISourceCreate`/`MIDIDestinationCreate` back virtual ports. +- **Windows — WinMM (`winmm.lib`).** `midiInOpen` with a callback (also + off-thread → same self-pipe bridge over `scr_loop_wsapoll.c`), `midiInAddBuffer` + for SysEx, `midiOutShortMsg`/`midiOutLongMsg` to send. **No virtual ports** on + WinMM → `openVirtualPort` fences at runtime with a clear error (documented + divergence; WinRT MIDI is a later option). + +### Delta-time +Each input tracks the timestamp of its previous delivered message and reports +`deltaTime` in **seconds** (node-midi's unit). First message after open reports +`0`. Use the platform timestamp where available (CoreMIDI packet time, ALSA +tick/real-time), else the loop clock. + +### ABI contract (lowering ⇄ runtime) — keep parallel prototypes integrable +The lowering emits `IrLibFn` calls; the runtime implements these exact symbols. +Draft (finalize in the front-matter task, then freeze for the runtime task): + +| lib fn id | C symbol | signature (conceptual) | +| --- | --- | --- | +| `midi.newInput` | `scr_midi_input_new` | `() -> ScrMidiInput*` | +| `midi.newOutput` | `scr_midi_output_new` | `() -> ScrMidiOutput*` | +| `midi.portCount` | `scr_midi_port_count` | `(handle, isInput) -> f64` | +| `midi.portName` | `scr_midi_port_name` | `(handle, idx) -> ScrString*` | +| `midi.openPort` | `scr_midi_open_port` | `(handle, idx) -> void` | +| `midi.openVirtual` | `scr_midi_open_virtual` | `(handle, ScrString* name) -> void` | +| `midi.closePort` | `scr_midi_close_port` | `(handle) -> void` | +| `midi.isOpen` | `scr_midi_is_open` | `(handle) -> bool` | +| `midi.ignoreTypes` | `scr_midi_ignore_types` | `(input, b,b,b) -> void` | +| `midi.send` | `scr_midi_send` | `(output, bytes*, len) -> void` | +| `midi.onMessage` | `scr_midi_on_message` | `(input, closure, once) -> void` | +| `midi.dispatch` | `scr_midi_dispatch` | loop hook (internal) | + +Message bytes are delivered to the JS closure as a `number[]` (the node-midi +shape) built by the runtime, with `deltaTime` as the first f64 argument. + +## 5. Testing strategy (hardware-free, differential) + +The blocker for MIDI tests is "no hardware, must match Node byte-for-byte." +Solved by **virtual-port loopback**, supported by both `@julusian/midi` (Node +baseline) and the POSIX runtime backends: + +1. Node baseline fixture uses `import midi from "midi"` (dev-dep `@julusian/midi`). +2. Program opens a virtual **Output** named e.g. `scriptc-test`, opens an + **Input** and connects it to that virtual port, sends a deterministic + sequence, prints each received message (and a fixed/synthetic deltaTime so + output is stable), then closes. +3. Harness runs it under Node and native; stdout must match. + +Determinism guards: print `message` bytes only (not wall-clock deltaTime — round +or replace with a monotonic counter in the test program); enumerate ports by a +name filter, not index, since index ordering varies. Gate the corpus case on +platform capability (POSIX virtual ports) like other capability-gated cases. +Windows and CI-without-ALSA lanes get compile-coverage + fence tests only. + +Also: fence/diagnostics snapshot tests (unsupported event names, bad +`sendMessage` args, `openVirtualPort` on Windows, any MIDI use on WASI → SC3002). + +## 6. WASI / web boundary +WASI Preview 1 has no MIDI capability. Follow the socket precedent in +`index.ts`: refuse `midi`/`midiInput`/`midiOutput` at compile time for the wasm +target with SC3002 and a message pointing at the platform-support page. Document +that Web MIDI (browser) is a separate runtime not covered by the WASI target. + +## 7. Risks & open questions +- **ALSA/CoreMIDI/WinMM link flags** must be added conditionally only when a + program uses MIDI (don't burden every binary). Mirror the fetch/curl + conditional-link precedent in `cc.ts`. +- **Off-thread callbacks** (CoreMIDI/WinMM) must never touch the runtime heap; + the self-pipe bridge is mandatory. Reference-count audit (the sanitized lane) + will catch violations. +- **CI has no ALSA/sound** → Linux native MIDI validated on a real host; CI keeps + fence + compile tests. Flag this to maintainers. +- **deltaTime nondeterminism** → tests must not print raw timing. +- Decide whether `getPortCount`/`getPortName` also work on a fresh handle before + `openPort` (node-midi allows it — enumerate then open). Plan: yes. + +--- + +## TODO checklist + +### Phase 0 — Design freeze +- [ ] Confirm API shape against installed `@julusian/midi@3.8.1` (method names, arg order, defaults). +- [ ] Freeze the lowering⇄runtime ABI table (§4) so parallel work integrates. + +### Phase 1 — Compiler front (ambient + IR + types) +- [ ] Add `declare module "midi"` and `"node:midi"` to `ambient/scriptc-node-fallback.d.ts`. +- [ ] Add IR handle kinds `midiInput`/`midiOutput` in `ir/nodes.ts`: kind union, `HANDLE_KINDS`, `*_T` consts, refcount predicate, `moduleUsesMidi`, type-name mapping. +- [ ] Map ambient `Input`/`Output` (declared in `midi`) → handle kinds in `frontend/types.ts`. +- [ ] Register `"midi"` in `SUPPORTED_BUILTIN_MODULES` (`frontend/shared.ts`) and the builtin set in `frontend/npm.ts`. + +### Phase 2 — Lowering spoke +- [ ] Create `lowering/lower-midi.ts`: constructors (`new Input()`/`new Output()`), methods (`getPortCount`/`getPortName`/`openPort`/`openVirtualPort`/`closePort`/`isPortOpen`/`ignoreTypes`/`sendMessage`), and the `on`/`once` `"message"` listener (reuse the `lowerCallbackArg` shape). +- [ ] Add `midi: {}` key + fence hint in `lowering/surfaces.ts`. +- [ ] Dispatch the spoke from `lowerer.ts` and `lower-calls.ts` (module calls + method calls on the handle receivers), mirroring `lowerDgramDnsModuleCall`. +- [ ] Statement-position + arg-shape fences with named hints (no silent drops). + +### Phase 3 — Runtime C +- [ ] `runtime/src/scr_midi.c`: handle structs, refcount, loop liveness, `scr_midi_dispatch`, the ABI symbols from §4. +- [ ] Linux ALSA-seq backend (`snd_seq_*`, poll descriptors → poller, virtual ports). +- [ ] macOS CoreMIDI backend (client/ports, self-pipe bridge from the CoreMIDI thread, virtual sources/destinations). +- [ ] Windows WinMM backend (`midiIn*`/`midiOut*`, self-pipe bridge, `openVirtualPort` runtime fence). +- [ ] Wire `scr_midi_dispatch()` into the loop in `scr_async.c`. + +### Phase 4 — Build wiring +- [ ] `moduleUsesMidi` flag threaded from `index.ts` into the backend options. +- [ ] Conditional TU compilation of `scr_midi.c` in `backend/cc.ts`, with conditional platform link flags (`-lasound` / `-framework CoreMIDI` / `winmm.lib`). +- [ ] WASI fence (SC3002) for any MIDI surface in `index.ts`. + +### Phase 5 — Tests & docs +- [ ] `tests/fixtures/midi/cases/*`: virtual-port loopback differential program(s); add `@julusian/midi` dev-dep for the Node baseline. +- [ ] `tests/harness/midi.test.ts` + a `tests/corpus/*` case (capability-gated). +- [ ] Diagnostics snapshots: unsupported event, bad `sendMessage`, `openVirtualPort` on Windows, MIDI on WASI. +- [ ] Docs: platform-support, limitations, dependencies pages; CHANGELOG entry. +- [ ] Regenerate `surface-manifest.json` (`pnpm manifest`). + +### Phase 6 — Validation +- [ ] `pnpm -r build` clean; `pnpm lint` clean. +- [ ] `pnpm test:sandbox` (plain + sanitized) green; native MIDI loopback validated on a host with ALSA/CoreMIDI. From f1d5da753eb434fa0c546412dac021a056538334 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 03:07:25 +0000 Subject: [PATCH 29/54] =?UTF-8?q?feat(compiler):=20add=20node:midi=20front?= =?UTF-8?q?-matter=20=E2=80=94=20ambient=20types,=20IR=20handle=20kinds,?= =?UTF-8?q?=20C-emission=20mapping?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds declare module midi/node:midi, midiInput/midiOutput IR handle kinds, moduleUsesMidi predicate, type mapping, module registry entries, and the C-representation/retain/release mapping in the emission layer. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01EYLKF6JBn2Fozts9CGr9W6 --- .../ambient/scriptc-node-fallback.d.ts | 41 +++++++++++++ .../src/backend/emission/emit-types.ts | 20 +++++++ .../compiler/src/backend/emission/emitter.ts | 2 + packages/compiler/src/frontend/npm.ts | 2 +- packages/compiler/src/frontend/shared.ts | 2 +- packages/compiler/src/frontend/types.ts | 36 ++++++++++- packages/compiler/src/ir/nodes.ts | 60 ++++++++++++++++++- 7 files changed, 159 insertions(+), 4 deletions(-) diff --git a/packages/compiler/ambient/scriptc-node-fallback.d.ts b/packages/compiler/ambient/scriptc-node-fallback.d.ts index fee214132..4c23aafda 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 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 8820dbb90..65d81dcdc 100644 --- a/packages/compiler/src/backend/emission/emitter.ts +++ b/packages/compiler/src/backend/emission/emitter.ts @@ -1905,6 +1905,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/frontend/npm.ts b/packages/compiler/src/frontend/npm.ts index 8b919cd08..64841bd56 100644 --- a/packages/compiler/src/frontend/npm.ts +++ b/packages/compiler/src/frontend/npm.ts @@ -576,7 +576,7 @@ const KNOWN_BUILTINS = new Set([ ...SHIMMED_BUILTINS, "assert", "async_hooks", "buffer", "cluster", "console", "constants", "crypto", "dgram", "diagnostics_channel", "dns", "domain", "http", - "https", "http2", "inspector", "module", "net", "os", "perf_hooks", + "https", "http2", "inspector", "midi", "module", "net", "os", "perf_hooks", "punycode", "querystring", "readline", "repl", "stream", "string_decoder", "sys", "timers", "tls", "trace_events", "tty", "url", "util", "v8", "vm", "wasi", "worker_threads", "zlib", diff --git a/packages/compiler/src/frontend/shared.ts b/packages/compiler/src/frontend/shared.ts index 8bd5deea3..1b35589bd 100644 --- a/packages/compiler/src/frontend/shared.ts +++ b/packages/compiler/src/frontend/shared.ts @@ -57,7 +57,7 @@ export function isNodeTypesPath(file: string): boolean { * 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/types.ts b/packages/compiler/src/frontend/types.ts index f855932e2..1c2cb7fe9 100644 --- a/packages/compiler/src/frontend/types.ts +++ b/packages/compiler/src/frontend/types.ts @@ -393,6 +393,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": @@ -987,6 +991,8 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { elem.kind === "spawnRes" || elem.kind === "netSocket" || elem.kind === "dgramSocket" || + elem.kind === "midiInput" || + elem.kind === "midiOutput" || elem.kind === "testCtx" || elem.kind === "httpReq" || elem.kind === "httpRes" || @@ -1086,7 +1092,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. @@ -1800,6 +1806,34 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { ) { return { kind: "dgramSocket" }; } + // midi.Input / midi.Output: the node-midi port classes, disambiguated by + // their enclosing ambient module — @julusian/midi's `class Input` / + // `class Output` and the fallback declarations' classes both live inside + // `declare module "midi"` (isDeclaredInAmbientModule answers for the + // "midi" and "node:midi" spellings alike). The names are generic enough + // to collide with user classes, so the ambient-module 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"), + ) + ) { + 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"), + ) + ) { + 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/ir/nodes.ts b/packages/compiler/src/ir/nodes.ts index 9335ac1c5..bbf2f86fe 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" }; @@ -529,6 +545,8 @@ export function typeKey(t: IrType): string { case "http2Session": case "http2Stream": case "dgramSocket": + case "midiInput": + case "midiOutput": case "testCtx": case "httpReq": case "httpRes": @@ -648,6 +666,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" || @@ -5337,6 +5359,8 @@ function isJsonSafeAt( case "http2Session": case "http2Stream": case "dgramSocket": + case "midiInput": + case "midiOutput": case "testCtx": case "httpReq": case "httpRes": @@ -6472,6 +6496,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). */ @@ -6670,6 +6725,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"], @@ -6731,6 +6788,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"], From c663d1d113d7d4a3454a04def86b54c4570e52f3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 03:13:01 +0000 Subject: [PATCH 30/54] feat(runtime): add scr_midi.c native MIDI unit (ALSA/CoreMIDI/WinMM) Refcounted midi Input/Output handles over the event-loop poller seam, off-thread callback bridging via self-pipe, number[] message delivery with deltaTime, virtual-port loopback on POSIX, header decls and scr_async.c loop hook. Falls back to a stub backend where no MIDI stack is present. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01EYLKF6JBn2Fozts9CGr9W6 --- packages/runtime/src/scr_async.c | 43 +- packages/runtime/src/scr_midi.c | 1422 ++++++++++++++++++++++++++++ packages/runtime/src/scr_runtime.h | 66 ++ 3 files changed, 1526 insertions(+), 5 deletions(-) create mode 100644 packages/runtime/src/scr_midi.c diff --git a/packages/runtime/src/scr_async.c b/packages/runtime/src/scr_async.c index f95c67b2a..d168ff9f6 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. */ @@ -2333,6 +2346,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. */ @@ -2362,6 +2382,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_fs_renames_pending(); if (held) { @@ -2381,6 +2402,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 renames = scr_fs_renames_pending(); /* Timer liveness counts only REF'd timers: an unref'd timer stays in @@ -2389,7 +2411,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 && !renames) break; + if (scr_reffed_timers == 0 && scr_reffed_immediates == 0 && !scr_children_reffed_pending() && !io && !events && !net && !dgram && !midi && !watch && !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: @@ -2430,11 +2452,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) && due > now + SCR_SIGNAL_POLL_MS) due = now + SCR_SIGNAL_POLL_MS; + else if ((evw || net || dgram || midi || watch) && 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) { + } else if (evw || net || dgram || midi || watch) { #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 @@ -2448,7 +2470,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 (kids && due > now + SCR_CHILD_POLL_MS) due = now + SCR_CHILD_POLL_MS; if (due > now) { double wait = due - now; @@ -2465,7 +2487,7 @@ bool scr_loop_run(ScrPromise *top_level) { * events are pending); unrepresentable children keep the ~1ms reap cap * instead. Dispatch happens at the next turn's top — the poll only * decides how long to sleep. */ - struct pollfd fds[6]; + struct pollfd fds[7]; int nfds = 0; int evfds[2]; int nev = evw && scr_events_pollfds_fn != NULL ? scr_events_pollfds_fn(evfds) : 0; @@ -2499,6 +2521,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_midi.c b/packages/runtime/src/scr_midi.c new file mode 100644 index 000000000..170f84d2a --- /dev/null +++ b/packages/runtime/src/scr_midi.c @@ -0,0 +1,1422 @@ +/* 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, 0, 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_runtime.h b/packages/runtime/src/scr_runtime.h index 60b2dfff1..80281e88b 100644 --- a/packages/runtime/src/scr_runtime.h +++ b/packages/runtime/src/scr_runtime.h @@ -6064,6 +6064,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): From 013019fd50b09971ad5dc2f34e446369415bd21a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 03:13:29 +0000 Subject: [PATCH 31/54] docs: reconcile MIDI ABI table with runtime prototype (thunks, send marshalers, install hook) Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01EYLKF6JBn2Fozts9CGr9W6 --- docs/plans/midi-native-port.md | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/docs/plans/midi-native-port.md b/docs/plans/midi-native-port.md index 1b71739db..a2c1fdeda 100644 --- a/docs/plans/midi-native-port.md +++ b/docs/plans/midi-native-port.md @@ -177,13 +177,29 @@ Draft (finalize in the front-matter task, then freeze for the runtime task): | `midi.closePort` | `scr_midi_close_port` | `(handle) -> void` | | `midi.isOpen` | `scr_midi_is_open` | `(handle) -> bool` | | `midi.ignoreTypes` | `scr_midi_ignore_types` | `(input, b,b,b) -> void` | -| `midi.send` | `scr_midi_send` | `(output, bytes*, len) -> void` | -| `midi.onMessage` | `scr_midi_on_message` | `(input, closure, once) -> void` | -| `midi.dispatch` | `scr_midi_dispatch` | loop hook (internal) | +| `midi.send` (array) | `scr_midi_send_array` | `(output, ScrArr* number[]) -> void` | +| `midi.send` (bytes) | `scr_midi_send_bytes` | `(output, ScrBytes* Uint8Array) -> void` | +| `midi.onMessage` | `scr_midi_on_message` | `(input, closure, ScrMidiMsgFn fn, once) -> void` | +| `midi.dispatch` | `scr_midi_dispatch` | loop hook (internal, static) | Message bytes are delivered to the JS closure as a `number[]` (the node-midi shape) built by the runtime, with `deltaTime` as the first f64 argument. +**Reconciled during prototyping (both mirror the dgram spoke exactly):** +- `sendMessage` lowers to two marshalers picked by argument type — + `scr_midi_send_array` for a `number[]` and `scr_midi_send_bytes` for a + `Uint8Array` — over a raw `scr_midi_send(out, bytes*, len)` primitive + (parallel to dgram's `send_str`/`send_bytes`). +- `on/once('message')` passes an adapter-thunk pointer selected by the + listener's declared param count (`scr_midi_msg_thunk0/1/2`), because a user + closure's compiled C arity (0/1/2 params) can't be invoked through one fixed + signature — exactly dgram's `msg_thunk0/1` mechanism. +- The runtime registers its loop hook via `scr_loop_set_midi(...)` from + `scr_midi_install()`; generated `main` must call `scr_midi_install()` under + `moduleUsesMidi`, like `scr_dgram_install()`. +- Refcount symbols the C-emission layer calls: `scr_midi_input_retain/release`, + `scr_midi_output_retain/release`, and their `_v` void* variants. + ## 5. Testing strategy (hardware-free, differential) The blocker for MIDI tests is "no hardware, must match Node byte-for-byte." From 01f0452621218540e19883127c6c7159263fe3a3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 03:18:44 +0000 Subject: [PATCH 32/54] feat(compiler): add node:midi lowering spoke lower-midi.ts lowers new Input()/Output() constructors and the port method surface (getPortCount/getPortName/openPort/openVirtualPort/closePort/ isPortOpen/ignoreTypes/sendMessage/on-once message) to the midi.* lib calls, wired into lowerNew and the method-call dispatch; surfaces.ts fence hint. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01EYLKF6JBn2Fozts9CGr9W6 --- .../src/frontend/lowering/lower-calls.ts | 9 + .../src/frontend/lowering/lower-midi.ts | 334 ++++++++++++++++++ .../compiler/src/frontend/lowering/lowerer.ts | 22 +- .../src/frontend/lowering/surfaces.ts | 7 + 4 files changed, 371 insertions(+), 1 deletion(-) create mode 100644 packages/compiler/src/frontend/lowering/lower-midi.ts diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index 8667621c8..a9036cf4b 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -3561,6 +3561,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). @@ -4156,6 +4161,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) ?? 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/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index dd7d162e7..ab990bd38 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -100,6 +100,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"; @@ -7799,7 +7800,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 { @@ -8018,6 +8019,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). @@ -8244,6 +8250,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 4ccf0c2e5..f6749598a 100644 --- a/packages/compiler/src/frontend/lowering/surfaces.ts +++ b/packages/compiler/src/frontend/lowering/surfaces.ts @@ -805,6 +805,13 @@ export const BUILTIN_MODULE_FNS: Record Date: Fri, 14 Aug 2026 03:22:37 +0000 Subject: [PATCH 33/54] feat(compiler): wire midi.* lib fns through emitter and validator Adds the midi.* ids to IrLibFn, the emitter dispatch mapping each to its scr_midi_* symbol (with per-arity onMessage thunk selection and input-only loop liveness), the may-throw set, and the lib-fn signature table. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01EYLKF6JBn2Fozts9CGr9W6 --- .../src/backend/emission/emit-exprs.ts | 48 +++++++++++++++++++ packages/compiler/src/ir/nodes.ts | 30 ++++++++++++ packages/compiler/src/ir/validate.ts | 17 ++++++- 3 files changed, 94 insertions(+), 1 deletion(-) diff --git a/packages/compiler/src/backend/emission/emit-exprs.ts b/packages/compiler/src/backend/emission/emit-exprs.ts index 6093c0a6d..8174a574b 100644 --- a/packages/compiler/src/backend/emission/emit-exprs.ts +++ b/packages/compiler/src/backend/emission/emit-exprs.ts @@ -4296,6 +4296,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/ir/nodes.ts b/packages/compiler/src/ir/nodes.ts index bbf2f86fe..3dc0cb61f 100644 --- a/packages/compiler/src/ir/nodes.ts +++ b/packages/compiler/src/ir/nodes.ts @@ -2429,6 +2429,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 @@ -7221,6 +7241,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 f7f99e815..3db4f795c 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, isJsonSafeType, isRefCounted, 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, 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, MIDIIN_T, MIDIOUT_T, isJsonSafeType, isRefCounted, 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, 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 @@ -404,6 +404,21 @@ export const LIB_FN_SIGS: Record result — the spoke pinned the // shape): null slots. sub's result is the settled Promise the From 77e14f91b58b14ee9659a6eca8ccb5b673345c78 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 03:26:29 +0000 Subject: [PATCH 34/54] feat(compiler): wire node:midi build inclusion, install hook, and WASI fence Threads moduleUsesMidi into the backend opts, compiles scr_midi.c and links the platform MIDI stack (ALSA where present / CoreMIDI / WinMM) conditionally, emits scr_midi_install() into generated main, and refuses the MIDI surface on the WASI target (SC3002). End-to-end: an enumerate program builds and runs natively via the C backend (LLVM defers node surfaces, as dgram does). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01EYLKF6JBn2Fozts9CGr9W6 --- packages/compiler/src/backend/cc.ts | 28 ++++++++++++++++++- .../compiler/src/backend/emission/emitter.ts | 5 +++- packages/compiler/src/index.ts | 7 ++++- 3 files changed, 37 insertions(+), 3 deletions(-) diff --git a/packages/compiler/src/backend/cc.ts b/packages/compiler/src/backend/cc.ts index 94a00daa2..15c962c17 100644 --- a/packages/compiler/src/backend/cc.ts +++ b/packages/compiler/src/backend/cc.ts @@ -341,6 +341,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. */ @@ -3595,7 +3601,7 @@ export async function compileC(opts: CcOptions): Promise { // 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")), @@ -3606,6 +3612,26 @@ export async function compileC(opts: CcOptions): Promise { ...(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.nodeTest ? [rt(join(rtDir, "scr_test.c"))] : []), // The CA-store unit rides its own gate OR the tls one: scr_tls.c diff --git a/packages/compiler/src/backend/emission/emitter.ts b/packages/compiler/src/backend/emission/emitter.ts index 65d81dcdc..29188c9b9 100644 --- a/packages/compiler/src/backend/emission/emitter.ts +++ b/packages/compiler/src/backend/emission/emitter.ts @@ -38,7 +38,7 @@ import type { IrUnionDef, SrcLoc, } from "../../ir/nodes.js"; -import { ffiCallbackType, funcOf, isFfiCallbackParam, isFfiContextParam, 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, 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, type FfiCallbackAdapter } from "../ffi-callbacks.js"; import { mangleAsyncSpawn, @@ -924,6 +924,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();`] : []), diff --git a/packages/compiler/src/index.ts b/packages/compiler/src/index.ts index 5f614973c..77913026d 100644 --- a/packages/compiler/src/index.ts +++ b/packages/compiler/src/index.ts @@ -21,7 +21,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"; @@ -230,6 +230,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)"], @@ -244,6 +245,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)"], @@ -1053,6 +1056,8 @@ export async function compile(entryPath: string, opts: CompileOptions): Promise< http2: moduleUsesHttp2(lowered.module), // The link switch for scr_dgram.c: dgram.* or dns.* libCalls on the IR. dgram: moduleUsesDgram(lowered.module), + // The link switch for scr_midi.c: midi.* libCalls on the IR. + midi: moduleUsesMidi(lowered.module), // The link switch for scr_watch.c: fs.watch/watcher.* libCalls on the IR. watch: moduleUsesFsWatch(lowered.module), // The link switch for scr_test.c: test.* libCalls on the IR. From 651f34e6ad69d526d4077654c9d76303c11add5a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 03:48:29 +0000 Subject: [PATCH 35/54] test/docs: add node:midi tests, docs, manifest entry, and Node baseline dev-dep - diagnostics snapshot (SC2020 bad sendMessage shape; SC1090 void-result rules) - coverage fixture pinning the enumerate program at 100% static - capability-gated harness: WASI SC3002 refusal + virtual-port loopback differential (skipped where no ALSA/CoreMIDI backend / @julusian/midi) - platforms / limitations / introduction / how-it-works docs + CHANGELOG - regenerated surface-manifest (node-builtin.midi) and @julusian/midi dev-dep Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01EYLKF6JBn2Fozts9CGr9W6 --- CHANGELOG.md | 4 + docs/src/app/how-it-works/page.mdx | 2 +- docs/src/app/introduction/page.mdx | 2 +- docs/src/app/limitations/page.mdx | 10 ++ docs/src/app/platforms/page.mdx | 38 +++++ package.json | 1 + packages/compiler/surface-manifest.json | 7 + pnpm-lock.yaml | 28 +++- tests/coverage-fixtures/midi-enumerate.ts | 24 +++ tests/diagnostics/midi.ts | 29 ++++ .../midi/cases/virtual-loopback/main.ts | 57 +++++++ tests/harness/__snapshots__/midi.ts.txt | 29 ++++ tests/harness/coverage.test.ts | 9 ++ tests/harness/midi.test.ts | 147 ++++++++++++++++++ 14 files changed, 383 insertions(+), 4 deletions(-) create mode 100644 tests/coverage-fixtures/midi-enumerate.ts create mode 100644 tests/diagnostics/midi.ts create mode 100644 tests/fixtures/midi/cases/virtual-loopback/main.ts create mode 100644 tests/harness/__snapshots__/midi.ts.txt create mode 100644 tests/harness/midi.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a19727f6d..f462c02b5 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.30 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 90333063e..c784a011b 100644 --- a/docs/src/app/limitations/page.mdx +++ b/docs/src/app/limitations/page.mdx @@ -87,6 +87,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 2f3d86b72..ca9289339 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/surface-manifest.json b/packages/compiler/surface-manifest.json index 7158ba9f5..0654fe846 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", 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/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/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__/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 720a8ebc9..517fe3440 100644 --- a/tests/harness/coverage.test.ts +++ b/tests/harness/coverage.test.ts @@ -58,6 +58,15 @@ test("fully static JavaScript program reports 100%", () => { expect(out).toContain("fully static"); }); +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 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); +}); From 9e83e48d6680ee78f4fb9b544a08fa72e4a0d8ef Mon Sep 17 00:00:00 2001 From: iplanwebsites <787729+iplanwebsites@users.noreply.github.com> Date: Fri, 14 Aug 2026 07:34:17 -0400 Subject: [PATCH 36/54] fix(midi): validate native package loopback --- .../compiler/ambient/scriptc-node-fallback.d.ts | 4 +++- packages/compiler/src/frontend/lowering/lowerer.ts | 3 ++- packages/compiler/src/frontend/program.ts | 14 +++++++++----- packages/compiler/src/frontend/shared.ts | 8 ++++++++ packages/compiler/src/frontend/types.ts | 14 ++++++-------- packages/compiler/src/index.ts | 9 ++++----- packages/runtime/src/scr_midi.c | 3 ++- pnpm-workspace.yaml | 1 + 8 files changed, 35 insertions(+), 21 deletions(-) diff --git a/packages/compiler/ambient/scriptc-node-fallback.d.ts b/packages/compiler/ambient/scriptc-node-fallback.d.ts index 4c23aafda..c50a96327 100644 --- a/packages/compiler/ambient/scriptc-node-fallback.d.ts +++ b/packages/compiler/ambient/scriptc-node-fallback.d.ts @@ -3174,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/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index ab990bd38..d8a196c8b 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, @@ -7446,7 +7447,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); diff --git a/packages/compiler/src/frontend/program.ts b/packages/compiler/src/frontend/program.ts index 89e0e58d3..3271101b0 100644 --- a/packages/compiler/src/frontend/program.ts +++ b/packages/compiler/src/frontend/program.ts @@ -1465,13 +1465,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 — @@ -2011,7 +2011,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 @@ -2668,6 +2671,7 @@ export { builtinDefaultImportModule, canonicalBuiltinModule, fallbackDtsPath, + isMidiTypesPath, isNodeTypesPath, npmPackageNameOf, overridesDtsPath, @@ -2772,7 +2776,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/shared.ts b/packages/compiler/src/frontend/shared.ts index 1b35589bd..f9d287d75 100644 --- a/packages/compiler/src/frontend/shared.ts +++ b/packages/compiler/src/frontend/shared.ts @@ -51,6 +51,14 @@ 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, diff --git a/packages/compiler/src/frontend/types.ts b/packages/compiler/src/frontend/types.ts index 1c2cb7fe9..edf75c2c9 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, 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 @@ -1807,18 +1807,16 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { return { kind: "dgramSocket" }; } // midi.Input / midi.Output: the node-midi port classes, disambiguated by - // their enclosing ambient module — @julusian/midi's `class Input` / - // `class Output` and the fallback declarations' classes both live inside - // `declare module "midi"` (isDeclaredInAmbientModule answers for the - // "midi" and "node:midi" spellings alike). The names are generic enough - // to collide with user classes, so the ambient-module guard is load-bearing. + // 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"), + (isDeclaredInAmbientModule(d, "midi") || isMidiTypesPath(d.getSourceFile().fileName)), ) ) { return { kind: "midiInput" }; @@ -1829,7 +1827,7 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { (d) => (ts.isInterfaceDeclaration(d) || ts.isClassDeclaration(d)) && ctx.isStdlibFile(d.getSourceFile()) && - isDeclaredInAmbientModule(d, "midi"), + (isDeclaredInAmbientModule(d, "midi") || isMidiTypesPath(d.getSourceFile().fileName)), ) ) { return { kind: "midiOutput" }; diff --git a/packages/compiler/src/index.ts b/packages/compiler/src/index.ts index 77913026d..1e71d6d3a 100644 --- a/packages/compiler/src/index.ts +++ b/packages/compiler/src/index.ts @@ -435,11 +435,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) { diff --git a/packages/runtime/src/scr_midi.c b/packages/runtime/src/scr_midi.c index 170f84d2a..43e1b0e06 100644 --- a/packages/runtime/src/scr_midi.c +++ b/packages/runtime/src/scr_midi.c @@ -1193,7 +1193,8 @@ static void scr_midi_plat_out_send(ScrMidiOutput *s, const unsigned char *bytes, pl = (MIDIPacketList *)storage; } MIDIPacket *pkt = MIDIPacketListInit(pl); - pkt = MIDIPacketListAdd(pl, len + sizeof(MIDIPacketList) + 16, pkt, 0, len, bytes); + 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); 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" From b6a74de2d28b4a1c51fe13942121fdc360557dc6 Mon Sep 17 00:00:00 2001 From: iplanwebsites <787729+iplanwebsites@users.noreply.github.com> Date: Fri, 14 Aug 2026 07:52:10 -0400 Subject: [PATCH 37/54] test(midi): record TS7 order parity --- packages/compiler/test/ts7/baselines/order-parity.json | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/compiler/test/ts7/baselines/order-parity.json b/packages/compiler/test/ts7/baselines/order-parity.json index 2fab13504..edeb44b79 100644 --- a/packages/compiler/test/ts7/baselines/order-parity.json +++ b/packages/compiler/test/ts7/baselines/order-parity.json @@ -7346,6 +7346,12 @@ ], "diags": [] }, + "/tests/diagnostics/midi.ts": { + "order": [ + "/tests/diagnostics/midi.ts" + ], + "diags": [] + }, "/tests/diagnostics/mixed-compare.ts": { "order": [ "/tests/diagnostics/mixed-compare.ts" From 895247d8acb3c4421ccdb9998ebc85604be6d0b0 Mon Sep 17 00:00:00 2001 From: iplanwebsites <787729+iplanwebsites@users.noreply.github.com> Date: Fri, 14 Aug 2026 08:06:56 -0400 Subject: [PATCH 38/54] docs: remove completed MIDI implementation plan --- docs/plans/midi-native-port.md | 285 --------------------------------- 1 file changed, 285 deletions(-) delete mode 100644 docs/plans/midi-native-port.md diff --git a/docs/plans/midi-native-port.md b/docs/plans/midi-native-port.md deleted file mode 100644 index a2c1fdeda..000000000 --- a/docs/plans/midi-native-port.md +++ /dev/null @@ -1,285 +0,0 @@ -# Plan: Native MIDI messaging support for scriptc - -Status: proposed · Owner: compiler+runtime · Target branch: `claude/midi-native-port-plan-1mmmsq` - -## 1. Goal - -MIDI messaging is available to JavaScript today in two shapes: - -- **Web** — the [Web MIDI API](https://www.w3.org/TR/webmidi/): `navigator.requestMIDIAccess()` - yields a `MIDIAccess` with `inputs`/`outputs` maps of `MIDIInput`/`MIDIOutput` - ports; you receive with `input.onmidimessage` (a `MIDIMessageEvent` carrying a - `Uint8Array` `data`) and transmit with `output.send(data, timestamp?)`. -- **Server (Node)** — native addons over the platform MIDI stacks, the de-facto - standard being [`node-midi`](https://github.com/justinlatimer/node-midi) and its - maintained fork [`@julusian/midi`](https://github.com/Julusian/node-midi) - (RtMidi under the hood), plus the ergonomic wrapper - [`easymidi`](https://github.com/dinchak/node-easymidi). Core surface: - `new midi.Input()` / `new midi.Output()`, `getPortCount()`, `getPortName(i)`, - `openPort(i)`, `openVirtualPort(name)`, `input.on('message', (dt, msg) => …)`, - `output.sendMessage([status, d1, d2])`, `closePort()`, `ignoreTypes(...)`. - -scriptc compiles TS/JS to **native executables** (macOS/Linux/Windows) and to -**WASI** wasm. There is no MIDI surface today. This plan ports **MIDI messaging -core features** — enumerate ports, open input/output (incl. virtual ports), -receive time-stamped messages via an event, and send raw messages — to -scriptc's native runtime, exposed through a Node-shaped `node:midi` module -surface that is differential-testable against a real Node baseline. - -### Scope - -**In scope (core messaging):** -- Port enumeration: `getPortCount()`, `getPortName(index)`. -- Input: `new Input()`, `openPort(i)`, `openVirtualPort(name)`, `closePort()`, - `on('message', cb)` / `once('message', cb)`, `ignoreTypes(sysex, timing, sense)`. -- Output: `new Output()`, `openPort(i)`, `openVirtualPort(name)`, `closePort()`, - `sendMessage(number[] | Uint8Array)`. -- Message payloads carry raw bytes (Note On/Off, CC, Program Change, Pitch Bend, - channel pressure, and SysEx as a byte run) — the runtime is byte-transparent; - it does not parse or validate message semantics. A thin optional decode helper - (note/CC accessors) may follow but is **not** core. -- Delta-time (seconds since the previous message on that input), matching - node-midi's `message` callback first argument. - -**Out of scope (this port):** -- Browser Web MIDI in the WASI target (WASI Preview 1 has no MIDI capability — it - fences, see §6). The *API shape* is modeled to stay portable, but the wasm - target refuses MIDI at compile time like it does sockets. -- MIDI file (SMF) parsing, sequencing/clock scheduling, SysEx device protocols, - MIDI 2.0 / UMP, virtual-MIDI on Windows (WinMM has no user-space virtual ports). -- `easymidi`-style semantic event names (`noteon`, `cc`, …). Those can be a - pure-TS layer on top later; the native core stays raw-byte. - -### Why a Node-module shape (not a Web-MIDI global) - -The corpus is **differential against Node**: every program runs under Node and as -a native binary and must match stdout/stderr/exit byte-for-byte (AGENTS.md). Node -has no built-in MIDI, but `@julusian/midi` provides one under the same -`import midi from "midi"` name we target, **and** it supports `openVirtualPort`, -which gives us a hardware-free deterministic loopback for tests (open a virtual -output, open an input on that virtual port, send, receive, compare). Modeling on -the Web MIDI global would have no Node baseline to diff against. So: `node:midi` -module surface, API-compatible with node-midi/@julusian/midi. - -## 2. How scriptc adds a native module surface (the dgram template) - -`node:dgram` is the closest existing analog: an event-driven, message-oriented -device/socket handle whose reads feed the event loop. A MIDI input is -structurally the same (a pollable source delivering discrete messages), and a -MIDI output is like a connected UDP socket (`sendMessage` ≈ `send`). Every -touchpoint below is mirrored from dgram. - -| Concern | dgram implementation | MIDI equivalent to build | -| --- | --- | --- | -| Ambient types | `declare module "dgram"` / `"node:dgram"` in `ambient/scriptc-node-fallback.d.ts` | `declare module "midi"` / `"node:midi"` | -| IR handle type | `dgramSocket` in `ir/nodes.ts` (kind union, `HANDLE_KINDS`, `DGRAMSOCK_T`, refcount predicate, `moduleUsesDgram`) | `midiInput`, `midiOutput` kinds + `moduleUsesMidi` | -| Type mapping | `types.ts` maps ambient `Socket` (declared in `dgram`) → `{kind:"dgramSocket"}` | ambient `Input`/`Output` → `midiInput`/`midiOutput` | -| Lowering spoke | `lowering/lower-dgram.ts` (module fns + method calls + event listeners), dispatched from `lowerer.ts` & `lower-calls.ts` | new `lowering/lower-midi.ts`, dispatched the same way | -| Module registry | `SUPPORTED_BUILTIN_MODULES` in `frontend/shared.ts`; builtin set in `frontend/npm.ts`; keys in `surfaces.ts` | add `"midi"` to all three | -| Runtime C | `runtime/src/scr_dgram.c` over the `scr_platform.h` poller seam | new `runtime/src/scr_midi.c` (+ platform backends) | -| Build inclusion | conditional TU behind `moduleUsesDgram`/`net` in `backend/cc.ts`, flagged from `index.ts` | conditional TU behind `moduleUsesMidi` | -| WASI fence | `index.ts` refuses `dgram.`/`dgramSocket` on WASI with SC3002 | refuse `midi`/`midiInput`/`midiOutput` on WASI | -| Tests | `tests/fixtures/dgram/cases/*`, `tests/corpus/*dgram*`, `tests/harness/dgram.test.ts` | `tests/fixtures/midi/*`, corpus, `tests/harness/midi.test.ts` | -| Docs | platforms / limitations / dependencies pages under `docs/` | same pages + a MIDI note | -| Manifest | projected into `surface-manifest.json` via `pnpm manifest` | regenerate | - -## 3. Proposed API surface (ambient `.d.ts`) - -Mirrors node-midi/@julusian/midi so the Node differential baseline is a real, -installable package. - -```ts -declare module "midi" { - export class Input { - getPortCount(): number; - getPortName(port: number): string; - openPort(port: number): void; - openVirtualPort(name: string): void; // POSIX only; fences on Windows - closePort(): void; - isPortOpen(): boolean; - // sysex, timing (clock), activeSensing — each true = ignore (node-midi default true,true,true) - 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; // POSIX only; fences on Windows - closePort(): void; - isPortOpen(): boolean; - sendMessage(message: number[] | Uint8Array): void; - } -} -declare module "node:midi" { export * from "midi"; } -``` - -Constrained call forms (the surfaces.ts stance): `sendMessage` takes an array -literal or a `Uint8Array`; `on`/`once` accept only the `"message"` event with a -`(deltaTime, message)` void arrow/function of ≤2 params (the -`lowerCallbackArg` pattern from lower-dgram). Anything else fences -member-qualified with a named hint (never a silent drop). - -## 4. Runtime design (`scr_midi.c` + platform backends) - -### Handle model -`ScrMidiInput` and `ScrMidiOutput` are refcounted handles like `ScrDgramSocket`. -An **open input** holds the loop alive (a live source, like a bound socket); -an output does not (send is fire-and-forget). Both are freed on `closePort()` -+ last ref drop; the unit forgets any registered fd before closing it. - -### Event-loop integration (the `scr_platform.h` seam) -The runtime already exposes a readiness poller: `scrp_poller_new`, -`scrp_watch_read(fd,…)`, `scrp_forget(fd)`, `scrp_drain(...)` (kqueue/epoll/wsapoll). -The loop (`scr_async.c`) will call a new `scr_midi_dispatch()` each turn, exactly -as it calls `scr_dgram_dispatch()`. - -- **Linux — ALSA sequencer (`libasound`).** `snd_seq_open`, create a port, - subscribe. ALSA exposes pollable fds via `snd_seq_poll_descriptors()` → - register each with `scrp_watch_read`; on readiness `snd_seq_event_input()` and - translate seq events to raw MIDI bytes (`snd_midi_event_decode`). Virtual ports - are native (an ALSA port other clients connect to). **Container note:** ALSA - dev headers are absent here (`/usr/include/alsa/asoundlib.h` missing) and CI has - no sound stack — the Linux backend is written behind the seam and validated on a - host with ALSA; loopback tests use the virtual-port pair so no hardware is needed. -- **macOS — CoreMIDI (`-framework CoreMIDI`).** `MIDIClientCreate`, - `MIDIInputPortCreate` with a read callback that fires **on a CoreMIDI thread**. - Bridge to the loop with a self-pipe/`eventfd`: the callback enqueues the packet - on a mutex-guarded ring and writes one byte; the pipe read-end is registered - with `scrp_watch_read`, so `scr_midi_dispatch` drains the ring on the loop - thread and fires JS listeners there (never call into the runtime from the - CoreMIDI thread). `MIDISourceCreate`/`MIDIDestinationCreate` back virtual ports. -- **Windows — WinMM (`winmm.lib`).** `midiInOpen` with a callback (also - off-thread → same self-pipe bridge over `scr_loop_wsapoll.c`), `midiInAddBuffer` - for SysEx, `midiOutShortMsg`/`midiOutLongMsg` to send. **No virtual ports** on - WinMM → `openVirtualPort` fences at runtime with a clear error (documented - divergence; WinRT MIDI is a later option). - -### Delta-time -Each input tracks the timestamp of its previous delivered message and reports -`deltaTime` in **seconds** (node-midi's unit). First message after open reports -`0`. Use the platform timestamp where available (CoreMIDI packet time, ALSA -tick/real-time), else the loop clock. - -### ABI contract (lowering ⇄ runtime) — keep parallel prototypes integrable -The lowering emits `IrLibFn` calls; the runtime implements these exact symbols. -Draft (finalize in the front-matter task, then freeze for the runtime task): - -| lib fn id | C symbol | signature (conceptual) | -| --- | --- | --- | -| `midi.newInput` | `scr_midi_input_new` | `() -> ScrMidiInput*` | -| `midi.newOutput` | `scr_midi_output_new` | `() -> ScrMidiOutput*` | -| `midi.portCount` | `scr_midi_port_count` | `(handle, isInput) -> f64` | -| `midi.portName` | `scr_midi_port_name` | `(handle, idx) -> ScrString*` | -| `midi.openPort` | `scr_midi_open_port` | `(handle, idx) -> void` | -| `midi.openVirtual` | `scr_midi_open_virtual` | `(handle, ScrString* name) -> void` | -| `midi.closePort` | `scr_midi_close_port` | `(handle) -> void` | -| `midi.isOpen` | `scr_midi_is_open` | `(handle) -> bool` | -| `midi.ignoreTypes` | `scr_midi_ignore_types` | `(input, b,b,b) -> void` | -| `midi.send` (array) | `scr_midi_send_array` | `(output, ScrArr* number[]) -> void` | -| `midi.send` (bytes) | `scr_midi_send_bytes` | `(output, ScrBytes* Uint8Array) -> void` | -| `midi.onMessage` | `scr_midi_on_message` | `(input, closure, ScrMidiMsgFn fn, once) -> void` | -| `midi.dispatch` | `scr_midi_dispatch` | loop hook (internal, static) | - -Message bytes are delivered to the JS closure as a `number[]` (the node-midi -shape) built by the runtime, with `deltaTime` as the first f64 argument. - -**Reconciled during prototyping (both mirror the dgram spoke exactly):** -- `sendMessage` lowers to two marshalers picked by argument type — - `scr_midi_send_array` for a `number[]` and `scr_midi_send_bytes` for a - `Uint8Array` — over a raw `scr_midi_send(out, bytes*, len)` primitive - (parallel to dgram's `send_str`/`send_bytes`). -- `on/once('message')` passes an adapter-thunk pointer selected by the - listener's declared param count (`scr_midi_msg_thunk0/1/2`), because a user - closure's compiled C arity (0/1/2 params) can't be invoked through one fixed - signature — exactly dgram's `msg_thunk0/1` mechanism. -- The runtime registers its loop hook via `scr_loop_set_midi(...)` from - `scr_midi_install()`; generated `main` must call `scr_midi_install()` under - `moduleUsesMidi`, like `scr_dgram_install()`. -- Refcount symbols the C-emission layer calls: `scr_midi_input_retain/release`, - `scr_midi_output_retain/release`, and their `_v` void* variants. - -## 5. Testing strategy (hardware-free, differential) - -The blocker for MIDI tests is "no hardware, must match Node byte-for-byte." -Solved by **virtual-port loopback**, supported by both `@julusian/midi` (Node -baseline) and the POSIX runtime backends: - -1. Node baseline fixture uses `import midi from "midi"` (dev-dep `@julusian/midi`). -2. Program opens a virtual **Output** named e.g. `scriptc-test`, opens an - **Input** and connects it to that virtual port, sends a deterministic - sequence, prints each received message (and a fixed/synthetic deltaTime so - output is stable), then closes. -3. Harness runs it under Node and native; stdout must match. - -Determinism guards: print `message` bytes only (not wall-clock deltaTime — round -or replace with a monotonic counter in the test program); enumerate ports by a -name filter, not index, since index ordering varies. Gate the corpus case on -platform capability (POSIX virtual ports) like other capability-gated cases. -Windows and CI-without-ALSA lanes get compile-coverage + fence tests only. - -Also: fence/diagnostics snapshot tests (unsupported event names, bad -`sendMessage` args, `openVirtualPort` on Windows, any MIDI use on WASI → SC3002). - -## 6. WASI / web boundary -WASI Preview 1 has no MIDI capability. Follow the socket precedent in -`index.ts`: refuse `midi`/`midiInput`/`midiOutput` at compile time for the wasm -target with SC3002 and a message pointing at the platform-support page. Document -that Web MIDI (browser) is a separate runtime not covered by the WASI target. - -## 7. Risks & open questions -- **ALSA/CoreMIDI/WinMM link flags** must be added conditionally only when a - program uses MIDI (don't burden every binary). Mirror the fetch/curl - conditional-link precedent in `cc.ts`. -- **Off-thread callbacks** (CoreMIDI/WinMM) must never touch the runtime heap; - the self-pipe bridge is mandatory. Reference-count audit (the sanitized lane) - will catch violations. -- **CI has no ALSA/sound** → Linux native MIDI validated on a real host; CI keeps - fence + compile tests. Flag this to maintainers. -- **deltaTime nondeterminism** → tests must not print raw timing. -- Decide whether `getPortCount`/`getPortName` also work on a fresh handle before - `openPort` (node-midi allows it — enumerate then open). Plan: yes. - ---- - -## TODO checklist - -### Phase 0 — Design freeze -- [ ] Confirm API shape against installed `@julusian/midi@3.8.1` (method names, arg order, defaults). -- [ ] Freeze the lowering⇄runtime ABI table (§4) so parallel work integrates. - -### Phase 1 — Compiler front (ambient + IR + types) -- [ ] Add `declare module "midi"` and `"node:midi"` to `ambient/scriptc-node-fallback.d.ts`. -- [ ] Add IR handle kinds `midiInput`/`midiOutput` in `ir/nodes.ts`: kind union, `HANDLE_KINDS`, `*_T` consts, refcount predicate, `moduleUsesMidi`, type-name mapping. -- [ ] Map ambient `Input`/`Output` (declared in `midi`) → handle kinds in `frontend/types.ts`. -- [ ] Register `"midi"` in `SUPPORTED_BUILTIN_MODULES` (`frontend/shared.ts`) and the builtin set in `frontend/npm.ts`. - -### Phase 2 — Lowering spoke -- [ ] Create `lowering/lower-midi.ts`: constructors (`new Input()`/`new Output()`), methods (`getPortCount`/`getPortName`/`openPort`/`openVirtualPort`/`closePort`/`isPortOpen`/`ignoreTypes`/`sendMessage`), and the `on`/`once` `"message"` listener (reuse the `lowerCallbackArg` shape). -- [ ] Add `midi: {}` key + fence hint in `lowering/surfaces.ts`. -- [ ] Dispatch the spoke from `lowerer.ts` and `lower-calls.ts` (module calls + method calls on the handle receivers), mirroring `lowerDgramDnsModuleCall`. -- [ ] Statement-position + arg-shape fences with named hints (no silent drops). - -### Phase 3 — Runtime C -- [ ] `runtime/src/scr_midi.c`: handle structs, refcount, loop liveness, `scr_midi_dispatch`, the ABI symbols from §4. -- [ ] Linux ALSA-seq backend (`snd_seq_*`, poll descriptors → poller, virtual ports). -- [ ] macOS CoreMIDI backend (client/ports, self-pipe bridge from the CoreMIDI thread, virtual sources/destinations). -- [ ] Windows WinMM backend (`midiIn*`/`midiOut*`, self-pipe bridge, `openVirtualPort` runtime fence). -- [ ] Wire `scr_midi_dispatch()` into the loop in `scr_async.c`. - -### Phase 4 — Build wiring -- [ ] `moduleUsesMidi` flag threaded from `index.ts` into the backend options. -- [ ] Conditional TU compilation of `scr_midi.c` in `backend/cc.ts`, with conditional platform link flags (`-lasound` / `-framework CoreMIDI` / `winmm.lib`). -- [ ] WASI fence (SC3002) for any MIDI surface in `index.ts`. - -### Phase 5 — Tests & docs -- [ ] `tests/fixtures/midi/cases/*`: virtual-port loopback differential program(s); add `@julusian/midi` dev-dep for the Node baseline. -- [ ] `tests/harness/midi.test.ts` + a `tests/corpus/*` case (capability-gated). -- [ ] Diagnostics snapshots: unsupported event, bad `sendMessage`, `openVirtualPort` on Windows, MIDI on WASI. -- [ ] Docs: platform-support, limitations, dependencies pages; CHANGELOG entry. -- [ ] Regenerate `surface-manifest.json` (`pnpm manifest`). - -### Phase 6 — Validation -- [ ] `pnpm -r build` clean; `pnpm lint` clean. -- [ ] `pnpm test:sandbox` (plain + sanitized) green; native MIDI loopback validated on a host with ALSA/CoreMIDI. From 91ec382cc6d29d297352583a424261f1cdba1d96 Mon Sep 17 00:00:00 2001 From: Max B Date: Sat, 22 Aug 2026 11:14:24 +0200 Subject: [PATCH 39/54] fix(compiler): allow bare side-effect imports of ambient-only modules A bare `import "spec";` with no bound names 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 observe, so dropping the statement is behaviorally exact. This is the standard shape of a bundler-only stylesheet import (`import "pkg/dist/style.css";`), which previously hard-errored with SC1010 like any other unsupported import. Bound imports of the same kind of module (`import styles from "x.css"`) still fence, since something would be missing. --- packages/compiler/src/frontend/program.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/packages/compiler/src/frontend/program.ts b/packages/compiler/src/frontend/program.ts index 4dbe00a28..e4179bd01 100644 --- a/packages/compiler/src/frontend/program.ts +++ b/packages/compiler/src/frontend/program.ts @@ -2158,6 +2158,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( From 20dc90fde5c049e7f1996a3b23d6b9c36bd724d0 Mon Sep 17 00:00:00 2001 From: Max B Date: Sat, 22 Aug 2026 11:16:53 +0200 Subject: [PATCH 40/54] fix(compiler): cap rendered diagnostics to avoid RangeError on large reports renderAll() joined every diagnostic's rendered text (each with its own source-line context) into one string with no size limit. On programs with thousands of diagnostics, the joined string exceeds V8's max string length and the whole report crashes with an uncatchable-feeling RangeError instead of showing anything, including diagnostics that would have fit. Cap the render at 1000 diagnostics and note how many were omitted. --- packages/compiler/src/diagnostics/render.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) 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; } From b4eb36bed6b59744c44d6c7d5161d9ea6bbf820d Mon Sep 17 00:00:00 2001 From: Max B Date: Sat, 22 Aug 2026 11:15:51 +0200 Subject: [PATCH 41/54] fix(compiler): dynamic-import lowering resolves bare project specifiers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs in the dynamic-import codegen path's own resolver (resolveProjectImport in resolve.ts), independent from the type-checker's resolution but expected to agree with it: - dynamicImportProgramTargetOf only ever tried the checker-based resolveImport for relative/absolute specifiers, never for bare ones. A package importing its own name (`import("my-package/foo")` from within my-package, resolved via its package.json self-name "exports") is something the checker resolves fine, but the lowering path returned null unconditionally and reported "dynamic import of the program's own module ... is not part of the compiled module graph" even though the exact specifier had just resolved moments earlier. - resolveProjectImport's final fallback only tried loadAsFile then a bare isFile check, never loadAsDirectory (unlike every other resolver in this file) — so an exports/imports target landing on a directory answered null instead of falling back to its index file. Also adds a tsconfig `paths` fallback registry (setTsconfigPaths / resolveViaTsconfigPaths), consulted only when the package.json-exports walk finds nothing. It pairs with a separate, independent PR adopting a project's `paths` into the type-checker; until that lands the registry stays empty and this fallback is inert. --- .../src/frontend/lowering/lower-modules.ts | 27 +++++++- packages/compiler/src/frontend/program.ts | 11 ++- packages/compiler/src/frontend/resolve.ts | 67 ++++++++++++++++++- 3 files changed, 100 insertions(+), 5 deletions(-) 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/program.ts b/packages/compiler/src/frontend/program.ts index 4dbe00a28..d13ac6a0e 100644 --- a/packages/compiler/src/frontend/program.ts +++ b/packages/compiler/src/frontend/program.ts @@ -50,7 +50,7 @@ import { tscPassthroughDiag, unsupportedDiag, } from "../diagnostics/diagnostic.js"; -import { isNodeModulesPath, nearestInvalidPackageJsonPath, nearestPackageType, nearestPkgJsonPath, projectDtsRuntimeSibling, resolveBareModule, resolveProjectImport, resolveRelativeModule, resolveTypeDirective, setProjectRealm } from "./resolve.js"; +import { isNodeModulesPath, nearestInvalidPackageJsonPath, nearestPackageType, nearestPkgJsonPath, projectDtsRuntimeSibling, resolveBareModule, resolveProjectImport, resolveRelativeModule, resolveTypeDirective, setProjectRealm, setTsconfigPaths } from "./resolve.js"; import { probeNodeImportRefusal, probeNodeRequireRefusal } from "./npm.js"; import { isNpmStaticPackage, npmStaticActive, npmStaticFsShadow, npmStaticPackageOfPath, reportNpmStaticOffender, setNpmStaticPackages } from "./npm-static.js"; import { provenanceEntryFor, provenancePaths } from "./provenance-registry.js"; @@ -321,6 +321,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 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 From 7e81976f4fa26ad9af5f271c8f77732cfeb9d2da Mon Sep 17 00:00:00 2001 From: Max B Date: Sat, 22 Aug 2026 11:08:58 +0200 Subject: [PATCH 42/54] fix(compiler): adopt jsx and lib from the project's tsconfig in ts7 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both bugs share one root cause: the ts7 tsconfig-adoption mechanism in adoptProjectConfig7() only lets a fixed allowlist of options through, forcing everything else — including jsx and lib — to scriptc's own defaults with no override. jsx wasn't adopted at all, so any .tsx file failed type-checking with tsgo's "--jsx is not set" even when the project's tsconfig.json sets jsx. Worse, jsx also wasn't handled by serializeOptions()'s enum-to- string switch (which converts TypeScript's numeric enum-valued compiler options back to tsconfig string form), so naively adopting it crashed with "unhandled enum-valued compiler option 'jsx'" instead of just failing to type-check. lib was unconditionally FORCED to ["lib.es2025.d.ts"] with no way to widen it, so a project whose tsconfig sets "lib": ["ES2025", "DOM"] could never get DOM globals into scope, even for code reachable only through type-only imports. Fix: move the lib default from FORCED_OPTIONS (never overridable) to BASE_OPTIONS (overridden by adopted config), adopt jsx/jsxImportSource/ lib from the project's tsconfig when set, and add the missing jsx case to serializeOptions()'s enum-reverse-mapping switch. --- packages/compiler/src/frontend/program.ts | 23 ++++++++++++++++++- packages/compiler/src/frontend/ts7/program.ts | 9 ++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/packages/compiler/src/frontend/program.ts b/packages/compiler/src/frontend/program.ts index 4dbe00a28..d5f44bd43 100644 --- a/packages/compiler/src/frontend/program.ts +++ b/packages/compiler/src/frontend/program.ts @@ -79,6 +79,11 @@ import { trackedFileExists } from "./input-tracker.js"; const BASE_OPTIONS: ts.Ts7CompilerOptions = { strict: true, + // Default lib surface — overridden below by the project's own tsconfig + // `lib` when it sets one (BASE_OPTIONS loses to `adopted` in the merge). + // A project that never declares `lib` keeps exactly this narrow, no-DOM + // default. + lib: ["lib.es2025.d.ts"], }; const FORCED_OPTIONS: ts.Ts7CompilerOptions = { @@ -93,7 +98,6 @@ const FORCED_OPTIONS: ts.Ts7CompilerOptions = { // classification is intentionally independent below; this option is only // the binder's scope decision. moduleDetection: ts.ModuleDetectionKind.Force, - lib: ["lib.es2025.d.ts"], types: [], allowImportingTsExtensions: true, allowJs: true, @@ -168,6 +172,23 @@ function adoptProjectConfig7( const value = parsed.options[key]; if (value !== undefined) adopted[key] = value; } + 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)); diff --git a/packages/compiler/src/frontend/ts7/program.ts b/packages/compiler/src/frontend/ts7/program.ts index f319016f9..2230a4053 100644 --- a/packages/compiler/src/frontend/ts7/program.ts +++ b/packages/compiler/src/frontend/ts7/program.ts @@ -71,6 +71,15 @@ function serializeOptions(options: Ts7CompilerOptions): Record lib.startsWith("lib.") && lib.endsWith(".d.ts") ? lib.slice(4, -5) : lib, ); break; + // TypeScript's JsxEmit enum: None=0, Preserve=1, React=2, + // ReactNative=3, ReactJSX=4, ReactJSXDev=5 — same reverse-mapping + // need as target/module/moduleResolution/moduleDetection above, but + // JsxEmit isn't one of this adapter's mirrored enums, so the string + // table is spelled out directly instead of going through enumKeyOf. + case "jsx": + out[key] = + ["none", "preserve", "react", "react-native", "react-jsx", "react-jsxdev"][value as number] ?? value; + break; default: { if (typeof value === "number" && key !== "maxNodeModuleJsDepth") { throw new Error(`ts7 createProgram: unhandled enum-valued compiler option '${key}'`); From 357d99a72b647df30b79dd6ca64e02a8b137d1b4 Mon Sep 17 00:00:00 2001 From: Max B Date: Sat, 22 Aug 2026 11:12:07 +0200 Subject: [PATCH 43/54] fix(compiler): adopt tsconfig paths/baseUrl for the ts7 checker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit adoptProjectConfig7() only copies ADOPTED_OPTIONS (a small strictness-flag allowlist) from the project's real tsconfig.json into the options handed to the spawned tsgo (TypeScript 7 native) process. `paths` and `baseUrl` were silently dropped, so any project using tsconfig path aliases (e.g. `"@/*": ["./src/*"]`) hit `error SC0001: Cannot find module '@/foo'` even though tsgo's checker fully supports `paths`/`baseUrl` natively. Adopt `paths`, resolving relative targets to absolute paths against the real config's directory (or its `baseUrl`, when set) rather than passing them through unresolved: the synthesized virtual tsconfig that carries these options to tsgo (ts7/program.ts) is written beside the entry file, not beside the real tsconfig.json, so relative targets would otherwise resolve against the wrong base. `baseUrl` itself is not forwarded — tsgo rejects it outright as a removed option ("Option 'baseUrl' has been removed... Use '\"paths\": {\"*\": [\"./*\"]}' instead."); the absolute `paths` targets already make it unnecessary. Adds a regression test asserting SC0001 no longer fires for an aliased import once a tsconfig `paths` entry is in play. --- packages/compiler/src/frontend/program.ts | 32 +++++++++++++++++++++- packages/compiler/test/ts7/program.test.ts | 31 ++++++++++++++++++++- 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/packages/compiler/src/frontend/program.ts b/packages/compiler/src/frontend/program.ts index 4dbe00a28..76a4dc3ed 100644 --- a/packages/compiler/src/frontend/program.ts +++ b/packages/compiler/src/frontend/program.ts @@ -40,7 +40,7 @@ * that path (no snapshot pins it). */ import { builtinModules } from "node:module"; -import { dirname, resolve } from "node:path"; +import { dirname, isAbsolute, join, resolve } from "node:path"; import * as ts from "./ts7/adapter.js"; import type { ScrDiagnostic } from "../diagnostics/diagnostic.js"; import { @@ -168,6 +168,36 @@ function adoptProjectConfig7( const value = parsed.options[key]; if (value !== undefined) adopted[key] = value; } + // `paths` is a real, well-supported tsgo checker option, but `baseUrl` + // itself is not — tsgo rejects it outright ("Option 'baseUrl' has been + // removed. Please remove it from your configuration. Use '"paths": {"*": + // ["./*"]}' instead."), so it never joins `adopted` on its own. The + // synthesized virtual tsconfig (ts7/program.ts) is also written BESIDE THE + // ENTRY FILE, not beside this real tsconfig.json, so relative `paths` + // targets can't simply pass through either: they're resolved to absolute + // paths here, against the real config's resolved `baseUrl` (tsgo's own + // parser already makes that absolute — hence the isAbsolute guard rather + // than a bare join) or, absent one, the config's own directory (tsc's + // default), or tsgo resolves them against the wrong base entirely. + const rawPaths = parsed.options["paths"]; + if (rawPaths !== undefined && typeof rawPaths === "object" && rawPaths !== null) { + const configDir = dirname(configFile); + const rawBaseUrl = parsed.options["baseUrl"]; + const base = + typeof rawBaseUrl !== "string" + ? configDir + : isAbsolute(rawBaseUrl) + ? rawBaseUrl + : join(configDir, rawBaseUrl); + const abs = (p: string): string => (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 nullChecks = adopted["strictNullChecks"] ?? adopted["strict"] ?? false; if (nullChecks !== true) { diags.push(strictNullChecksFloorDiag(configFile)); 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 }); + } +}); From 4655f34058a5a6320e9b87c532c9726cfdfcefdb Mon Sep 17 00:00:00 2001 From: Max B Date: Sat, 22 Aug 2026 11:52:27 +0200 Subject: [PATCH 44/54] fix: support native builds on Windows via MinGW-w64 (clang) and zig's own bundled sysroot scriptc had no working native (non-cross-compiled) Windows build path at all: `nativePlatformArgs()` only had a `linux` branch, falling through to empty flags on win32 for both host-native drivers `resolveCc` can select (bare clang and `zig cc`) - which need OPPOSITE handling on this platform. **Bare clang**: its own default target on Windows is the MSVC ABI, which has none of the POSIX compatibility headers (dirent.h, unistd.h) or types (ssize_t) this project's runtime C sources need. Fixed by auto-discovering a MinGW-w64 install (SCRIPTC_MINGW_ROOT env var, else the common MSYS2/mingw-w64 install locations) and pointing clang at it via --target=x86_64-w64-mingw32, plus the include/lib/gcc- runtime/winpthread flags that target needs to actually link. **zig cc**: already bundles its own mingw-w64 headers/CRT for its `x86_64-windows-gnu` target (see this file's own module doc comment) and needs no external MinGW at all - `--target=x86_64-w64-mingw32` isn't even a valid zig target-query spelling and hard-errors ("UnknownOperatingSystem") if fed to it. `nativePlatformArgs` takes a `viaZig` flag so the MinGW-discovery branch only applies to the bare- clang path; zig's host-native branch gets empty targetArgs/linkArgs, matching how `zig cc` already works standalone. `resolveCc` also computes these lazily (only at the two host-native return sites), so every zig-cc cross-compile target (iOS, Android, Linux, wasm, ...) - which builds its own explicit targetArgs/linkArgs and never touches this - keeps working on a Windows host with no local MinGW at all. Verified on a real Windows machine with MSYS2/MinGW-w64 and zig 0.16 installed: - `resolveCc({}, "win32")` (bare clang) -> MinGW target/include/link flags; a minimal probe compiles AND links a real ~2MB native .exe. - `resolveCc({SCRIPTC_CC: "zigcc"}, "win32")` -> empty targetArgs/ linkArgs; `zig cc hi.c -o hi.exe` with no extra flags compiles and links a working ~780KB native .exe standalone (confirming the empty flags are correct, not just "did not crash"). - Confirmed `--target=x86_64-w64-mingw32` actively breaks `zig cc` before this fix (hard target-parse error), not just redundant. - cc-driver.test.ts's cross-compile-target tests (linux host flags, SCRIPTC_TARGET-without-zigcc rejection, x86_64-windows-gnu cross, musl, regex, --dynamic engine archive) all pass unchanged. - Could not execute the compiled native binaries end-to-end on this particular machine (a Windows Defender Exploit Guard / Attack Surface Reduction policy blocks running freshly-compiled unsigned binaries here) - a local security policy, unrelated to the compiler itself; the compile+link step (what this fix changes) completes and produces a correct binary either way. --- packages/compiler/src/backend/cc.ts | 125 ++++++++++++++++++++++++++-- 1 file changed, 117 insertions(+), 8 deletions(-) diff --git a/packages/compiler/src/backend/cc.ts b/packages/compiler/src/backend/cc.ts index cc317dec1..a9423e1bd 100644 --- a/packages/compiler/src/backend/cc.ts +++ b/packages/compiler/src/backend/cc.ts @@ -716,12 +716,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 +826,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)`); } From 763ed246865bea5b19b3fcf689526c29466468c7 Mon Sep 17 00:00:00 2001 From: Max B Date: Sat, 22 Aug 2026 11:32:16 +0200 Subject: [PATCH 45/54] fix: load JsxEmit as a hidden TS7 enum instead of a hardcoded table vercel[bot]'s review caught that the hardcoded jsx string table used TypeScript 5.9.3's JsxEmit ordering (React=2/ReactNative=3), but 7.0.2 renumbers it (ReactNative=2/React=3) - silently swapping "react" and "react-native". Verified against the real dist/enums/jsxEmit.js module. Fixed properly rather than just correcting the numbers: JsxEmit now goes through the same loadHiddenEnum + enumKeyOf symbolic reverse- mapping as ModuleResolutionKind/ModuleDetectionKind, so no numeric enum value is ever hardcoded in this file again (matching enums.ts's own stated invariant). Only the enum-key-name -> tsconfig-spelling step (ReactNative -> "react-native", etc.) stays a fixed table, since that's a spelling convention, not a value that could renumber. --- packages/compiler/src/frontend/ts7/enums.ts | 21 ++++++++++++ packages/compiler/src/frontend/ts7/program.ts | 32 +++++++++++++------ 2 files changed, 44 insertions(+), 9 deletions(-) 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 2230a4053..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,15 +71,29 @@ function serializeOptions(options: Ts7CompilerOptions): Record lib.startsWith("lib.") && lib.endsWith(".d.ts") ? lib.slice(4, -5) : lib, ); break; - // TypeScript's JsxEmit enum: None=0, Preserve=1, React=2, - // ReactNative=3, ReactJSX=4, ReactJSXDev=5 — same reverse-mapping - // need as target/module/moduleResolution/moduleDetection above, but - // JsxEmit isn't one of this adapter's mirrored enums, so the string - // table is spelled out directly instead of going through enumKeyOf. - case "jsx": - out[key] = - ["none", "preserve", "react", "react-native", "react-jsx", "react-jsxdev"][value as number] ?? value; + // 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}'`); From ab8545466ccfd877b99748696adbcb43a84ea5bc Mon Sep 17 00:00:00 2001 From: Max B Date: Sat, 22 Aug 2026 12:39:32 +0200 Subject: [PATCH 46/54] review: restore retain/release balance note, align test cache dir with repo convention (#50) --- packages/compiler/src/backend/emission/emitter.ts | 9 ++++++--- tests/harness/unknown-fields.test.ts | 4 ++-- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/compiler/src/backend/emission/emitter.ts b/packages/compiler/src/backend/emission/emitter.ts index d7f72427d..09426047f 100644 --- a/packages/compiler/src/backend/emission/emitter.ts +++ b/packages/compiler/src/backend/emission/emitter.ts @@ -1752,9 +1752,12 @@ export class CEmitter { * 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. Empty for - * every type that cannot hold undefined (tsc's SPI guards those) and for - * record shapes' construction paths, which write every field. */ + * 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 */`]; diff --git a/tests/harness/unknown-fields.test.ts b/tests/harness/unknown-fields.test.ts index 0aba265f9..7b69a4041 100644 --- a/tests/harness/unknown-fields.test.ts +++ b/tests/harness/unknown-fields.test.ts @@ -1,14 +1,14 @@ 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 cacheDir = join(tmpdir(), "scriptc-unknown-fields-tests"); +const repoRoot = join(import.meta.dirname, "../.."); +const cacheDir = join(repoRoot, "node_modules/.cache/scriptc-tests"); const sanitize = process.env["SCRIPTC_SAN"] === "1"; interface RunResult { From 1a64c1c1ea7581c289a4c7cac55bfd095ec3e9ac Mon Sep 17 00:00:00 2001 From: Max B Date: Sat, 22 Aug 2026 12:43:14 +0200 Subject: [PATCH 47/54] review: fix stale doc comment listing aliasable globals (missing Buffer) --- packages/compiler/src/frontend/lowering/surfaces.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/surfaces.ts b/packages/compiler/src/frontend/lowering/surfaces.ts index 8ef9a7f77..d60471773 100644 --- a/packages/compiler/src/frontend/lowering/surfaces.ts +++ b/packages/compiler/src/frontend/lowering/surfaces.ts @@ -1675,10 +1675,11 @@ export const BUILTIN_MODULE_FENCE_HINTS: Record Date: Sat, 22 Aug 2026 12:43:19 +0200 Subject: [PATCH 48/54] review: fix stale doc comment and remove unreachable dead-code after noLowering calls --- packages/compiler/src/frontend/lowering/lower-classes.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/lower-classes.ts b/packages/compiler/src/frontend/lowering/lower-classes.ts index ddc199f91..faae83a53 100644 --- a/packages/compiler/src/frontend/lowering/lower-classes.ts +++ b/packages/compiler/src/frontend/lowering/lower-classes.ts @@ -4980,8 +4980,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. @@ -5019,7 +5022,6 @@ export function lowerNew(L: Lowerer, expr: ts.NewExpression): IrExpr { 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"); - return { kind: "libCall", fn: "url.new", args: [L.lowerExprExpecting(args[0]!, STRING)], type: URL_T, loc }; } } L.noLowering( @@ -5027,7 +5029,6 @@ export function lowerNew(L: Lowerer, expr: ts.NewExpression): IrExpr { 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", ); - return { kind: "libCall", fn: "url.new", args: [L.lowerExprExpecting(args[0]!, STRING)], type: URL_T, loc }; } if (args.length !== 1) { L.noLowering( From 284b7cee50a614b906b7eb6cd9f979e7a3fb630a Mon Sep 17 00:00:00 2001 From: Max B Date: Sat, 22 Aug 2026 12:43:42 +0200 Subject: [PATCH 49/54] review: add missing WHY comment on IEEE-754 bit-exactness of sqrt vs transcendentals --- packages/compiler/src/backend/emission/emit-exprs.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/compiler/src/backend/emission/emit-exprs.ts b/packages/compiler/src/backend/emission/emit-exprs.ts index f0c1cb83c..3f86a3569 100644 --- a/packages/compiler/src/backend/emission/emit-exprs.ts +++ b/packages/compiler/src/backend/emission/emit-exprs.ts @@ -3522,6 +3522,14 @@ 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": @@ -3534,6 +3542,8 @@ export function emitExpr(E: CEmitter, e: IrExpr): Temp { 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": From a0a22933a84c47d5e6b89b5c6ce44d4ef3845532 Mon Sep 17 00:00:00 2001 From: Max B Date: Sat, 22 Aug 2026 12:43:52 +0200 Subject: [PATCH 50/54] review: rewrap comment to match project's ~80-char convention --- tests/harness/differential.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/harness/differential.test.ts b/tests/harness/differential.test.ts index 7b38e5bc1..17eba9cb3 100644 --- a/tests/harness/differential.test.ts +++ b/tests/harness/differential.test.ts @@ -225,8 +225,9 @@ function programInputs(file: string): string[] { // 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 (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 +// 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 = From c3d0c77c3c965a185bf79f00e18f40ea82181726 Mon Sep 17 00:00:00 2001 From: Max B Date: Sat, 22 Aug 2026 12:44:10 +0200 Subject: [PATCH 51/54] review: keep TOKEN_OPAQUE_GLOBALS crypto entry (PR-44's removal broke the pinned OPAQUE-global destructuring fence test and left the mechanism dead); keep only the well-scoped globalHints crypto message improvement --- packages/compiler/src/frontend/lowering/lower-exprs.ts | 2 +- packages/compiler/src/frontend/lowering/lower-stmts.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts index c44638b97..85f9ab8f9 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -998,7 +998,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\"`", + 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); } diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index e5fabba4f..1d6202dfd 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -2033,7 +2033,7 @@ export function isParseArgsDynCheckerType(L: Lowerer, type: ts.Type): boolean { * the one split surface: its five CALL members fence by name, while * the rest (`Console` — the suite's constructor-identity probe) have * no surface to lose and bind tokens. */ - const TOKEN_OPAQUE_GLOBALS: ReadonlySet = new Set([]); + const TOKEN_OPAQUE_GLOBALS: ReadonlySet = new Set(["crypto"]); const CONSOLE_CALL_MEMBERS: ReadonlySet = new Set(["log", "info", "debug", "error", "warn"]); /** `const { subtle } = globalThis.crypto`, `const { Console } = console`, From 2679a27bcfd05e8917c1cd5546f06f38aacd4a42 Mon Sep 17 00:00:00 2001 From: Max B Date: Sat, 22 Aug 2026 12:46:25 +0200 Subject: [PATCH 52/54] review: fix stale branch trigger in msvc-verify.yml (was scoped to the PR's own now-merged branch name, would never fire again) --- .github/workflows/msvc-verify.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/msvc-verify.yml b/.github/workflows/msvc-verify.yml index cce8b5430..151f620a6 100644 --- a/.github/workflows/msvc-verify.yml +++ b/.github/workflows/msvc-verify.yml @@ -2,9 +2,8 @@ name: MSVC POSIX Shims on: push: - branches: [fix/msvc-posix-shims] + branches: [main] pull_request: - branches: [fix/msvc-posix-shims] jobs: msvc_compile: From a4966a5f686128aaabeaaf2e706e8409c87f50fc Mon Sep 17 00:00:00 2001 From: Max B Date: Sat, 22 Aug 2026 12:46:32 +0200 Subject: [PATCH 53/54] review: add missing midi field to EarlyExecutableNativeFeatures test fixture (merge conflict fallout) --- packages/compiler/src/executable/early-cache.test.ts | 1 + 1 file changed, 1 insertion(+) 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, From e9e3fd81d627d6c023121463a7f90b71db63ec1e Mon Sep 17 00:00:00 2001 From: Max B Date: Sat, 22 Aug 2026 14:34:00 +0200 Subject: [PATCH 54/54] fix: three jsval-representation gaps in checked-dynamic lowering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found while pushing a large real-world program (next-vibe) through --dynamic. All three are gaps where a value already in the `jsval` (checked-dynamic island handle) representation hits a code path that only recognized `dyn` or a handful of other kinds, not `jsval`. 1. **Union property reads on a jsval-lowered receiver crash the compiler.** `lowerUnionProperty` (lower-exprs.ts) handles a checker-union receiver whose VALUE lowered to `dyn`/`record`/`object`, but not `jsval` (an optional-chained receiver whose narrowed arms all trace back to one dynamic/npm value) — that case fell through to `throw new Error("lowerer bug: union-typed receiver lowered to a non-union")`, an unrecoverable process crash rather than a diagnostic. Fixed by adding the missing `jsval` branch (the same generic island property read `isIslandExpr`'s own jsval handling already uses elsewhere in this file). Also improved the crash message itself to include the offending kind, file, and line — it previously gave no way to find the triggering code at all. 2. **A checked cast to a jsval-shaped target type incorrectly requires JSON-boundary validation.** In the `as T` cast lowering, a jsval receiver cast to a target type that ITSELF maps to `jsval` (an npm/ambient-declared type with no static shape — e.g. many Drizzle ORM query-builder return types) fell through the `targetTs.flags & TypeFlags.Any` fast path (that check is for the literal `any` keyword, not an alias that merely happens to map to the same representation) into `boundarySafe`'s JSON-representable- type validation, which a jsval target can never satisfy — producing `error SC1090: a checked cast of 'any' to 'any' is not supported yet` (formatIrType prints `jsval` as `"any"`, hence the confusing doubled wording). Fixed by returning the receiver unchanged when the target itself is jsval-shaped, matching the existing `Any`-keyword fast path's semantics. 3. **A computed object-literal key that folds to a compile-time constant string was rejected outright.** The 'any'-typed object-literal lowering only accepted `Identifier`/`StringLiteral` keys, rejecting the extremely common `{ [Methods.POST]: {...} }` / `{ [SomeEnum.Member]: value }` shape (a computed key referencing an enum member or other compile-time-constant string) even though this file already has `literalComputedKey`/`foldedStringKeyOf` utilities for exactly this fold, used by many other call sites in the same file. Fixed by using the same fold here; `pushProp`'s signature changed from `ts.Identifier | ts.StringLiteral` to a plain `{ text: string; node: ts.Node }` pair so a folded computed key (no single text-bearing AST node) can flow through it too. ## Verification Compiled the full next-vibe program (~600 TypeScript files) through `scriptc build --dynamic` before and after: - Fix 1 eliminated an unconditional process crash (`lowerer bug: union-typed receiver lowered to a non-union`) that previously prevented the compiler from producing ANY diagnostic output at all for this program. - Fix 2 eliminated 256 instances of the doubled "any to any" error. - Fix 3 eliminated 62 instances of the object-literal property-form error (142 computed-key sites now resolve; the remaining error-count delta reflects some of those unlocking further, unrelated downstream diagnostics rather than a 1:1 elimination). `packages/compiler` builds clean (`tsc -p tsconfig.json`, 0 errors). --- .../src/frontend/lowering/lower-exprs.ts | 65 ++++++++++++++++--- 1 file changed, 56 insertions(+), 9 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts index 48e1123db..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", @@ -7245,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", @@ -10161,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); @@ -10191,7 +10234,11 @@ export function lowerBinary(L: Lowerer, expr: ts.BinaryExpression): IrExpr { 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}`);