feat: ship the self-hosted native scriptc CLI (#585)

* feat: ship the self-hosted native scriptc CLI

- Run the shared compiler and CLI natively, including library builds and compile-time evaluation.
- Install native platform packages and ship relocatable standalone distributions.
- Preserve program semantics and reuse validated build artifacts for fast rebuilds.

* fix: complete native CLI integration and validation

- Preserve library diagnostics and run recursive compiler work on the main stack.
- Restore packaged helper permissions and exercise native npm and Wasm library builds.
- Recover interrupted Sandbox status probes within the original command deadline.
This commit is contained in:
Chris Tate
2026-09-30 02:17:56 -05:00
committed by GitHub
parent 5ffd23a807
commit ee9f0d64eb
143 changed files with 7608 additions and 4095 deletions
+13 -4
View File
@@ -178,6 +178,8 @@ jobs:
with:
version: 0.16.0
- run: CC=zig AR=zig pnpm --filter @scriptc/runtime-linux-x64-gnu build:native
- if: matrix.compiler == 'native'
run: pnpm --filter @scriptc/runtime-wasm32-wasi build:native
- run: pnpm build
- run: pnpm test ${{ matrix.test }}
@@ -299,7 +301,7 @@ jobs:
test "$(/tmp/hello)" = "hello world"
'
- name: WASI helper object/runtime-pack smoke
run: pnpm test packages/cli/test/wasi-runtime-pack.test.ts
run: pnpm test packages/cli/test/wasi-runtime-pack.test.ts tests/harness/wasm-library.test.ts
llvm_artifacts_macos_arm64:
name: test (macOS arm64 LLVM artifacts, no clang, ${{ matrix.shard }}/5)
@@ -355,22 +357,29 @@ jobs:
node scripts/verify-llvm-package.mjs "$RUNNER_TEMP/$(basename "$TARBALL")"
- name: Packed npm installation smoke
if: matrix.shard == 1
env:
# Release optimization is covered by the compiler bootstrap jobs.
# This lane verifies packaging and installation with a faster seed.
SCRIPTC_NATIVE_OPTIMIZATION: dev
run: |
pnpm --filter @scriptc/cli-darwin-arm64 build:native
pnpm --dir packages/runtime pack --pack-destination "$RUNNER_TEMP" --silent
pnpm --dir packages/runtime-darwin-arm64 pack --pack-destination "$RUNNER_TEMP" --silent
pnpm --dir packages/compiler pack --pack-destination "$RUNNER_TEMP" --silent
pnpm --dir packages/cli-darwin-arm64 pack --pack-destination "$RUNNER_TEMP" --silent
pnpm --dir packages/cli pack --pack-destination "$RUNNER_TEMP" --silent
PREFIX="$RUNNER_TEMP/installed-scriptc"
npm install --prefix "$PREFIX" --ignore-scripts \
npm install --prefix "$PREFIX" --no-audit --no-fund \
"$RUNNER_TEMP/scriptc-runtime-$(node -p "require('./packages/runtime/package.json').version").tgz" \
"$RUNNER_TEMP/scriptc-runtime-darwin-arm64-$(node -p "require('./packages/runtime-darwin-arm64/package.json').version").tgz" \
"$RUNNER_TEMP/scriptc-llvm-darwin-arm64-$(node -p "require('./packages/llvm-darwin-arm64/package.json').version").tgz" \
"$RUNNER_TEMP/scriptc-compiler-$(node -p "require('./packages/compiler/package.json').version").tgz" \
"$RUNNER_TEMP/scriptc-cli-darwin-arm64-$(node -p "require('./packages/cli-darwin-arm64/package.json').version").tgz" \
"$RUNNER_TEMP/scriptc-$(node -p "require('./packages/cli/package.json').version").tgz"
"$PREFIX/node_modules/.bin/scriptc" build tests/corpus/001-hello.ts \
PATH="" "$PREFIX/node_modules/.bin/scriptc" build tests/corpus/001-hello.ts \
--emit=obj -o "$RUNNER_TEMP/installed.o"
file "$RUNNER_TEMP/installed.o" | grep 'Mach-O 64-bit object arm64'
"$PREFIX/node_modules/.bin/scriptc" build tests/corpus/001-hello.ts \
PATH="" "$PREFIX/node_modules/.bin/scriptc" build tests/corpus/001-hello.ts \
--print=native-link-info -o "$RUNNER_TEMP/installed-link.o" \
> "$RUNNER_TEMP/installed-link.json"
node examples/native-object/link.mjs cc \
+43 -11
View File
@@ -61,37 +61,48 @@ jobs:
runner: macos-15
helper: llvm-darwin-arm64
runtime: runtime-darwin-arm64
cli: cli-darwin-arm64
- platform: darwin-x64
runner: macos-15-intel
helper: llvm-darwin-x64
runtime: runtime-darwin-x64
cli: cli-darwin-x64
- platform: linux-x64
runner: ubuntu-24.04
helper: llvm-linux-x64-gnu
runtime: runtime-linux-x64-gnu
cli: cli-linux-x64-gnu
llvm_asset: LLVM-22.1.8-Linux-X64
use_zig: true
- platform: linux-arm64
runner: ubuntu-24.04-arm
helper: llvm-linux-arm64-gnu
runtime: runtime-linux-arm64-gnu
cli: cli-linux-arm64-gnu
llvm_asset: LLVM-22.1.8-Linux-ARM64
use_zig: true
- platform: windows-x64
runner: windows-2022
helper: llvm-win32-x64-msvc
runtime: runtime-win32-x64-msvc
cli: cli-win32-x64-msvc
use_zig: true
- platform: linux-x64-musl
runner: ubuntu-24.04
helper: llvm-linux-x64-musl
runtime: runtime-linux-x64-musl
cli: cli-linux-x64-musl
seed_helper: llvm-linux-x64-gnu
cli_target: x86_64-linux-musl
llvm_asset: LLVM-22.1.8-Linux-X64
use_zig: true
- platform: linux-arm64-musl
runner: ubuntu-24.04-arm
helper: llvm-linux-arm64-musl
runtime: runtime-linux-arm64-musl
cli: cli-linux-arm64-musl
seed_helper: llvm-linux-arm64-gnu
cli_target: aarch64-linux-musl
llvm_asset: LLVM-22.1.8-Linux-ARM64
use_zig: true
- platform: wasm32-wasi
@@ -171,12 +182,25 @@ jobs:
run: |
pnpm --filter @scriptc/${{ matrix.helper }} build:native
pnpm --filter @scriptc/${{ matrix.runtime }} build:native
- uses: actions/upload-artifact@v4
- name: Build host helper for cross-libc bootstrap
if: matrix.seed_helper != ''
run: pnpm --filter @scriptc/${{ matrix.seed_helper }} build:native
- name: Build native CLI distribution
if: matrix.cli != ''
env:
SCRIPTC_TARGET: ${{ matrix.cli_target }}
run: |
pnpm --filter @scriptc/compiler --filter scriptc build
pnpm --filter @scriptc/${{ matrix.cli }} build:native
node scripts/verify-native-cli.mjs packages/${{ matrix.cli }} --run
- name: Upload native packages
uses: actions/upload-artifact@v4
with:
name: native-${{ matrix.platform }}
path: |
packages/${{ matrix.helper }}
packages/${{ matrix.runtime }}
${{ matrix.cli != '' && format('packages/{0}/dist', matrix.cli) || '' }}
retention-days: 1
build-mobile-runtime-packs:
@@ -266,12 +290,13 @@ jobs:
# upload-artifact does not preserve executable bits. Restore the
# helper mode before pnpm runs its prepack checks.
find packages -type f -path '*/bin/scriptc-llvm-codegen' -exec chmod 755 {} +
find packages -type f \( -path '*/dist/bin/scriptc' -o -path '*/dist/lib/typescript/lib/tsc' -o -path '*/dist/lib/scriptc-comptime' \) -exec chmod 755 {} +
pnpm -r build
- name: Check version sync
run: |
VERSION="${{ needs.check-release.outputs.version }}"
for pkg in packages/runtime packages/runtime-darwin-arm64 packages/llvm-darwin-arm64 packages/runtime-linux-x64-gnu packages/llvm-linux-x64-gnu packages/runtime-linux-arm64-gnu packages/llvm-linux-arm64-gnu packages/runtime-linux-x64-musl packages/llvm-linux-x64-musl packages/runtime-linux-arm64-musl packages/llvm-linux-arm64-musl packages/runtime-wasm32-wasi packages/runtime-ios-arm64 packages/runtime-ios-simulator-arm64 packages/runtime-android-arm64 packages/runtime-win32-x64-msvc packages/llvm-win32-x64-msvc packages/compiler packages/cli; do
for pkg in packages/runtime packages/runtime-* packages/llvm-* packages/compiler packages/cli packages/cli-*; do
V=$(node -p "require('./$pkg/package.json').version")
if [ "$V" != "$VERSION" ]; then
echo "Version mismatch: $pkg is $V, expected $VERSION"
@@ -356,6 +381,8 @@ jobs:
publish_dir packages/runtime
publish_dir packages/runtime-darwin-arm64
publish_dir packages/llvm-darwin-arm64 "$HELPER_TARBALL"
publish_dir packages/runtime-darwin-x64
publish_dir packages/llvm-darwin-x64
publish_dir packages/runtime-linux-x64-gnu
publish_dir packages/llvm-linux-x64-gnu
publish_dir packages/runtime-linux-arm64-gnu
@@ -371,19 +398,13 @@ jobs:
publish_dir packages/runtime-win32-x64-msvc
publish_dir packages/llvm-win32-x64-msvc
publish_dir packages/compiler
for pkg in packages/cli-*; do publish_dir "$pkg"; done
publish_dir packages/cli
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# The GitHub release is a tag, notes, and one asset: the surface
# manifest (packages/compiler/surface-manifest.json — the machine-
# readable listing of the surface the static tier compiles at this
# version, regenerated here and verified against the committed file).
# The platform helper ships through its npm package rather than as a GitHub
# release asset, so this job runs AFTER a successful npm publish and never
# gates it. The body is the CHANGELOG.md block between the
# release:start/release:end markers, which RELEASING.md keeps on the
# latest entry only.
# Publish standalone native distributions and the surface manifest after
# npm publishing succeeds. The release body comes from the marked changelog.
github-release:
name: Create GitHub Release
needs: [check-release, publish]
@@ -427,6 +448,16 @@ jobs:
fi
echo "Extracted release notes for $VERSION ($LINES lines)"
- name: Download native distributions and runtime packs
uses: actions/download-artifact@v4
with:
pattern: native-*
path: packages
merge-multiple: true
- name: Package standalone compilers
run: node scripts/package-native-cli.mjs packages /tmp/scriptc-release-assets
- name: Create GitHub Release
run: |
VERSION="${{ needs.check-release.outputs.version }}"
@@ -445,5 +476,6 @@ jobs:
# Attach the surface manifest (idempotent: --clobber makes
# re-runs replace the asset instead of failing).
gh release upload "$TAG" packages/compiler/surface-manifest.json --clobber
gh release upload "$TAG" /tmp/scriptc-release-assets/* --clobber
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+1
View File
@@ -21,6 +21,7 @@ output/
/packages/runtime-darwin-arm64/runtime-pack.json
/packages/runtime-darwin-arm64/.runtime-pack-*
/packages/llvm-*/bin/
/packages/cli/bin/
/packages/runtime-*/artifacts/
/packages/runtime-*/runtime-pack.json
/packages/runtime-*/.runtime-pack-*
+1 -1
View File
@@ -15,7 +15,7 @@ scriptc is experimental and targets macOS, Linux, Windows, and WebAssembly via W
## Installation
The compiler requires Node.js 24 or newer. `--emit=ir|llvm` needs only Node. `--emit=asm|obj` uses the matching optional platform helper installed with scriptc on supported macOS, Linux, and Windows hosts (and for WASI), but needs no compiler, archiver, linker, or SDK. Ordinary LLVM executable builds need a platform linker driver and SDK/sysroot, but use the bundled helper plus precompiled runtime pack rather than compiling generated or runtime C. Set `SCRIPTC_LINKER` to choose that driver. Runtime development with `--sanitize` additionally needs a C compiler. The executables it produces do not require Node.
The installed compiler runs natively on supported macOS, Linux, and Windows hosts. Compilation, compile-time evaluation, and native execution do not require Node. `--emit=ir|llvm|asm|obj` uses the bundled TypeScript checker and LLVM helper without an external compiler, archiver, linker, or SDK. Executable builds additionally need a platform linker driver and SDK/sysroot; precompiled runtime packs supply the C runtime. Set `SCRIPTC_LINKER` to choose that driver. Runtime development with `--sanitize` additionally needs a C compiler. Node.js 24 or newer is needed for npm installation, development from a source checkout, the JavaScript compiler API, and `scriptc run` of WASI modules.
```console
$ npm install -g scriptc
+1 -9
View File
@@ -14,15 +14,7 @@ To prepare a release:
CI (`.github/workflows/release.yml`) compares the version in `packages/cli/package.json` to what `scriptc` has on npm. If it differs, it builds platform packages on matching macOS, Linux, and Windows runners, verifies the version spine, and publishes the runtime, helper, compiler, and CLI packages in dependency order. After the publish succeeds, a separate job creates the git tag `v<version>` and the GitHub release with the marked changelog entry as its body, and attaches `surface-manifest.json` — the machine-readable listing of the surface the static tier compiles at that version (stable per-entry ids, so two releases diff mechanically; see `packages/compiler/src/coverage/surface-manifest.ts` for the schema). The job regenerates the manifest from the tree and fails on any byte difference from the committed file before attaching, so the asset is always the manifest of the code being released. The same file ships inside the `@scriptc/compiler` package as `@scriptc/compiler/surface-manifest.json`.
The release job builds and strips each pinned LLVM helper on its matching host,
then builds the matching precompiled runtime pack before publishing the
constrained platform packages ahead of `@scriptc/compiler`. Ordinary LLVM-tier
executables use the helper for the program object and the platform pack for
runtime objects; the user's toolchain performs only the final platform link.
Explicit C builds, LLVM refusals, and `--sanitize` retain the external C
toolchain path. npm postinstall skips local runtime-cache compilation when the
platform pack is available. The GitHub release remains a tag, release notes, and the
manifest asset; the npm publish never waits on the GitHub release.
The release job builds each pinned LLVM helper, precompiled runtime pack, and native CLI on its matching host. The native CLI bundles its TypeScript checker, LLVM helper, runtime pack, declarations, and comptime evaluator. npm postinstall installs the matching native executable; Node is needed for npm installation but not native compilation or execution. The user's toolchain performs the final platform link. Sanitized builds also use it to instrument program LLVM and compile the C runtime. Platform packages publish ahead of `@scriptc/compiler` and `scriptc`. The GitHub release attaches standalone compiler archives, their SHA-256 checksums, and the surface manifest after npm publication succeeds.
Publishing uses npm trusted publishing (OIDC) — there is no npm token secret.
Each published package must have a GitHub Actions trusted publisher for
+2 -2
View File
@@ -2,7 +2,7 @@
## Owned LLVM targets
Source artifacts selected with <code>--emit=ir|llvm</code> need only Node. On supported hosts, <code>--emit=asm|obj</code> uses the version-matched platform helper installed with scriptc and does not invoke a compiler, archiver, linker, or SDK. Supported assembly/object targets are macOS arm64/x64 (macOS 14.0 artifacts; helper needs macOS 15+), Linux x64/arm64 glibc, Windows x64 MSVC, Linux x64/arm64 musl, and WASI Preview 1. Ordinary LLVM executables additionally need the platform linker and SDK/sysroot; the helper emits the program object and the packaged runtime supplies objects/archives, so that driver compiles neither program nor runtime C. Runtime development with <code>--sanitize</code> requires an external LLVM toolchain and a C compiler for instrumentation. Object output retains undefined runtime references and is not a library archive.
The compiler runs natively and bundles its TypeScript checker and LLVM helper. Outputs selected with <code>--emit=ir|llvm|asm|obj</code> need no Node installation or external compiler, archiver, linker, or SDK. Supported assembly/object targets are macOS arm64/x64 (macOS 14.0 artifacts; helper needs macOS 15+), Linux x64/arm64 glibc, Windows x64 MSVC, Linux x64/arm64 musl, and WASI Preview 1. Executables additionally need the platform linker and SDK/sysroot; the helper emits the program object and the packaged runtime supplies objects/archives. Runtime development with <code>--sanitize</code> requires an external LLVM toolchain and a C compiler for instrumentation. Object output retains undefined runtime references and is not a library archive.
## Cross-compilation via zig
@@ -57,7 +57,7 @@ The full library-mode feature set applies: profile-declared exports and ABI entr
### WebAssembly (WASI Preview 1)
Set <code>SCRIPTC_TARGET=wasm32-wasi</code> to produce a standalone <code>.wasm</code> module. Without <code>-o</code>, <code>scriptc build hello.ts</code> writes <code>.scriptc/hello.wasm</code>. <code>scriptc run</code> hosts the module with Node's WASI implementation, inherits stdio and environment, preopens the current working directory as <code>/</code>, and maps the host platform's temporary directory to the guest's <code>/tmp</code>. A built module can run in another WASI Preview 1 host instead.
Set <code>SCRIPTC_TARGET=wasm32-wasi</code> to produce a standalone <code>.wasm</code> module. Without <code>-o</code>, <code>scriptc build hello.ts</code> writes <code>.scriptc/hello.wasm</code>. Compilation does not require Node. <code>scriptc run</code> requires Node.js 24 or newer on <code>PATH</code> to host the module with Node's WASI implementation, inherits stdio and environment, preopens the current working directory as <code>/</code>, and maps the host platform's temporary directory to the guest's <code>/tmp</code>. A built module can run in another WASI Preview 1 host instead.
WASI is a production LLVM target with the same language tiers as the native targets. Its 32-bit LLVM ABI supports collections, closures, exceptions, classes, checked dynamic values, async/await, promises, synchronous and asynchronous generators, timers, stdin/readline events, process-exit listeners, filesystem callbacks and promises, and the <code>--dynamic</code> QuickJS island.
+5 -3
View File
@@ -4,9 +4,9 @@ Install the CLI from npm and compile your first binary in a couple of minutes.
## Prerequisites
- **macOS arm64** is the primary platform ([Linux and Windows](/platforms) are cross-compilation targets).
- **Node ≥ 24** — to run the compiler. The binaries it produces need no Node at all.
- **clang** — preinstalled with the Xcode Command Line Tools; required for executable builds, but not for <code>--emit=ir|c|llvm|asm|obj</code>. Assembly/object output uses the matching helper installed with scriptc and requires macOS 15+.
- **macOS 15+ arm64/x64, Linux arm64/x64, or Windows x64** — see [platform support](/platforms) for target details.
- **Node ≥ 24 and npm** — for npm installation. The installed compiler and its native output run without Node. Standalone distributions are also available from [GitHub Releases](https://github.com/vercel-labs/scriptc/releases).
- **A platform linker and SDK** — for executable builds. On macOS, install the Xcode Command Line Tools. <code>--emit=ir|llvm|asm|obj</code> uses bundled tools and needs no external linker or SDK.
## Install
@@ -14,6 +14,8 @@ Install the CLI from npm and compile your first binary in a couple of minutes.
$ npm install -g scriptc
```
Keep optional dependencies and installation scripts enabled so npm can install the native command for your platform.
To work from a clone instead (`pnpm install && pnpm build` in the repo, then `pnpm scriptc` from the repo directory), see the [repository](https://github.com/vercel-labs/scriptc).
## Your first binary
+1 -1
View File
@@ -7,7 +7,7 @@
"scripts": {
"build": "pnpm -r --filter \"./packages/*\" run build",
"build:fresh": "rm -rf packages/compiler/dist packages/cli/dist node_modules/.cache/scriptc-tsc && pnpm build",
"build:native-compiler": "node --max-old-space-size=8192 --import tsx scripts/build-native-compiler.mts",
"build:native-compiler": "node --max-old-space-size=8192 --import tsx scripts/build-native-cli.mts",
"bench:builds": "node scripts/bench-builds.mjs",
"test": "vitest run",
"test:ts7": "node scripts/test-ts7.mjs",
+29
View File
@@ -0,0 +1,29 @@
{
"name": "@scriptc/cli-darwin-arm64",
"version": "0.1.7",
"description": "Native scriptc compiler for darwin-arm64",
"license": "Apache-2.0",
"homepage": "https://scriptc.dev",
"repository": {
"type": "git",
"url": "git+https://github.com/vercel-labs/scriptc.git",
"directory": "packages/cli-darwin-arm64"
},
"os": [
"darwin"
],
"cpu": [
"arm64"
],
"files": [
"dist"
],
"scripts": {
"build": "node -e \"\"",
"build:native": "node --max-old-space-size=8192 --import tsx ../../scripts/build-native-cli.mts dist",
"prepack": "node ../../scripts/verify-native-cli.mjs ."
},
"publishConfig": {
"access": "public"
}
}
+29
View File
@@ -0,0 +1,29 @@
{
"name": "@scriptc/cli-darwin-x64",
"version": "0.1.7",
"description": "Native scriptc compiler for darwin-x64",
"license": "Apache-2.0",
"homepage": "https://scriptc.dev",
"repository": {
"type": "git",
"url": "git+https://github.com/vercel-labs/scriptc.git",
"directory": "packages/cli-darwin-x64"
},
"os": [
"darwin"
],
"cpu": [
"x64"
],
"files": [
"dist"
],
"scripts": {
"build": "node -e \"\"",
"build:native": "node --max-old-space-size=8192 --import tsx ../../scripts/build-native-cli.mts dist",
"prepack": "node ../../scripts/verify-native-cli.mjs ."
},
"publishConfig": {
"access": "public"
}
}
+32
View File
@@ -0,0 +1,32 @@
{
"name": "@scriptc/cli-linux-arm64-gnu",
"version": "0.1.7",
"description": "Native scriptc compiler for linux-arm64-gnu",
"license": "Apache-2.0",
"homepage": "https://scriptc.dev",
"repository": {
"type": "git",
"url": "git+https://github.com/vercel-labs/scriptc.git",
"directory": "packages/cli-linux-arm64-gnu"
},
"os": [
"linux"
],
"cpu": [
"arm64"
],
"files": [
"dist"
],
"scripts": {
"build": "node -e \"\"",
"build:native": "node --max-old-space-size=8192 --import tsx ../../scripts/build-native-cli.mts dist",
"prepack": "node ../../scripts/verify-native-cli.mjs ."
},
"publishConfig": {
"access": "public"
},
"libc": [
"glibc"
]
}
@@ -0,0 +1,32 @@
{
"name": "@scriptc/cli-linux-arm64-musl",
"version": "0.1.7",
"description": "Native scriptc compiler for linux-arm64-musl",
"license": "Apache-2.0",
"homepage": "https://scriptc.dev",
"repository": {
"type": "git",
"url": "git+https://github.com/vercel-labs/scriptc.git",
"directory": "packages/cli-linux-arm64-musl"
},
"os": [
"linux"
],
"cpu": [
"arm64"
],
"files": [
"dist"
],
"scripts": {
"build": "node -e \"\"",
"build:native": "node --max-old-space-size=8192 --import tsx ../../scripts/build-native-cli.mts dist",
"prepack": "node ../../scripts/verify-native-cli.mjs ."
},
"publishConfig": {
"access": "public"
},
"libc": [
"musl"
]
}
+32
View File
@@ -0,0 +1,32 @@
{
"name": "@scriptc/cli-linux-x64-gnu",
"version": "0.1.7",
"description": "Native scriptc compiler for linux-x64-gnu",
"license": "Apache-2.0",
"homepage": "https://scriptc.dev",
"repository": {
"type": "git",
"url": "git+https://github.com/vercel-labs/scriptc.git",
"directory": "packages/cli-linux-x64-gnu"
},
"os": [
"linux"
],
"cpu": [
"x64"
],
"files": [
"dist"
],
"scripts": {
"build": "node -e \"\"",
"build:native": "node --max-old-space-size=8192 --import tsx ../../scripts/build-native-cli.mts dist",
"prepack": "node ../../scripts/verify-native-cli.mjs ."
},
"publishConfig": {
"access": "public"
},
"libc": [
"glibc"
]
}
+32
View File
@@ -0,0 +1,32 @@
{
"name": "@scriptc/cli-linux-x64-musl",
"version": "0.1.7",
"description": "Native scriptc compiler for linux-x64-musl",
"license": "Apache-2.0",
"homepage": "https://scriptc.dev",
"repository": {
"type": "git",
"url": "git+https://github.com/vercel-labs/scriptc.git",
"directory": "packages/cli-linux-x64-musl"
},
"os": [
"linux"
],
"cpu": [
"x64"
],
"files": [
"dist"
],
"scripts": {
"build": "node -e \"\"",
"build:native": "node --max-old-space-size=8192 --import tsx ../../scripts/build-native-cli.mts dist",
"prepack": "node ../../scripts/verify-native-cli.mjs ."
},
"publishConfig": {
"access": "public"
},
"libc": [
"musl"
]
}
+29
View File
@@ -0,0 +1,29 @@
{
"name": "@scriptc/cli-win32-x64-msvc",
"version": "0.1.7",
"description": "Native scriptc compiler for win32-x64-msvc",
"license": "Apache-2.0",
"homepage": "https://scriptc.dev",
"repository": {
"type": "git",
"url": "git+https://github.com/vercel-labs/scriptc.git",
"directory": "packages/cli-win32-x64-msvc"
},
"os": [
"win32"
],
"cpu": [
"x64"
],
"files": [
"dist"
],
"scripts": {
"build": "node -e \"\"",
"build:native": "node --max-old-space-size=8192 --import tsx ../../scripts/build-native-cli.mts dist",
"prepack": "node ../../scripts/verify-native-cli.mjs ."
},
"publishConfig": {
"access": "public"
}
}
+1 -1
View File
@@ -22,7 +22,7 @@ $ scriptc build fib.ts -o fib && ./fib
$ npm install -g scriptc
```
Requires Node.js 24. Executable builds require a platform linker driver and SDK/sysroot. On supported macOS, Linux, and Windows hosts, LLVM executables use the matching optional helper and precompiled runtime pack, so the driver only links; select that driver with `SCRIPTC_LINKER`. Runtime development with `--sanitize` additionally needs a C compiler. `--emit=ir|llvm` requires only Node, while `--emit=asm|obj` requires neither an external compiler nor a linker.
The installed compiler runs natively on supported macOS, Linux, and Windows hosts. Compilation, compile-time evaluation, and native execution do not require Node. `--emit=ir|llvm|asm|obj` uses the bundled TypeScript checker and LLVM helper without an external compiler, archiver, linker, or SDK. Executable builds additionally need a platform linker driver and SDK/sysroot; precompiled runtime packs supply the C runtime. Set `SCRIPTC_LINKER` to choose that driver. Runtime development with `--sanitize` additionally needs a C compiler. Node.js 24 or newer is needed for npm installation and `scriptc run` of WASI modules.
Builds use a bounded persistent cache. Unchanged source can reuse the validated frontend result and LLVM program objects. Library identity getters occupy a separate LLVM module, so an identity change can reuse the large program object. Runtime objects come from the installed pack. Executable cache entries verify their native dependencies; FFI builds relink against current external inputs. Set `SCRIPTC_NO_CACHE=1` to bypass the cache or `SCRIPTC_CACHE_DIR` to select its location. An existing POSIX override must already be private.
+27 -5
View File
@@ -1,7 +1,7 @@
{
"name": "scriptc",
"version": "0.1.7",
"description": "Compile ordinary TypeScript and JavaScript to small, fast native executables — no Node, no V8, no JavaScript engine in the binary",
"description": "Compile ordinary TypeScript and JavaScript to small, fast native executables \u2014 no Node, no V8, no JavaScript engine in the binary",
"license": "Apache-2.0",
"homepage": "https://scriptc.dev",
"repository": {
@@ -11,19 +11,41 @@
},
"type": "module",
"bin": {
"scriptc": "dist/bootstrap.js"
"scriptc": "bin/scriptc.exe"
},
"files": [
"dist",
"bin",
"scripts"
],
"engines": {
"node": ">=24"
},
"scripts": {
"build": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json"
"build": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json",
"prepack": "node scripts/prepare-native.mjs",
"postinstall": "node scripts/install-native.mjs"
},
"dependencies": {
"devDependencies": {
"@scriptc/compiler": "workspace:*"
},
"optionalDependencies": {
"@scriptc/cli-darwin-arm64": "workspace:*",
"@scriptc/cli-darwin-x64": "workspace:*",
"@scriptc/cli-linux-arm64-gnu": "workspace:*",
"@scriptc/cli-linux-arm64-musl": "workspace:*",
"@scriptc/cli-linux-x64-gnu": "workspace:*",
"@scriptc/cli-linux-x64-musl": "workspace:*",
"@scriptc/cli-win32-x64-msvc": "workspace:*",
"@scriptc/runtime-darwin-arm64": "workspace:*",
"@scriptc/runtime-darwin-x64": "workspace:*",
"@scriptc/runtime-linux-x64-gnu": "workspace:*",
"@scriptc/runtime-linux-arm64-gnu": "workspace:*",
"@scriptc/runtime-linux-x64-musl": "workspace:*",
"@scriptc/runtime-linux-arm64-musl": "workspace:*",
"@scriptc/runtime-win32-x64-msvc": "workspace:*",
"@scriptc/runtime-wasm32-wasi": "workspace:*",
"@scriptc/runtime-ios-arm64": "workspace:*",
"@scriptc/runtime-ios-simulator-arm64": "workspace:*",
"@scriptc/runtime-android-arm64": "workspace:*"
}
}
+113
View File
@@ -0,0 +1,113 @@
import { constants, copyFileSync, chmodSync, existsSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
import { createRequire } from "node:module";
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
import { fileURLToPath } from "node:url";
export function nativeCliPackage(platform, architecture, libc) {
const host = `${platform}-${architecture}`;
if (host === "darwin-arm64" || host === "darwin-x64") return `@scriptc/cli-${host}`;
if (host === "win32-x64") return "@scriptc/cli-win32-x64-msvc";
if (host === "linux-x64" || host === "linux-arm64") {
if (libc !== "glibc" && libc !== "musl") throw new Error("could not identify the Linux C library");
return `@scriptc/cli-${host}-${libc === "musl" ? "musl" : "gnu"}`;
}
throw new Error(`scriptc has no native command for ${host}`);
}
function hostLibc() {
if (process.platform !== "linux") return null;
const report = process.report.getReport();
if (report.header.glibcVersionRuntime) return "glibc";
if (report.sharedObjects.some((path) => /(?:^|\/)ld-musl-|libc\.musl-/.test(path))) return "musl";
// A statically linked Node has no loaded libc in its process report.
// Only select musl when its loader is present for this architecture.
const arch = process.arch === "arm64" ? "aarch64" : "x86_64";
return existsSync(`/lib/ld-musl-${arch}.so.1`) ? "musl" : null;
}
export function relocateToolchain(manifest, sourceDirectory, destinationDirectory) {
const relocated = { ...manifest };
const pathFrom = (value) => relative(destinationDirectory, resolve(sourceDirectory, value));
for (const name of ["ts7", "llvm_package", "runtime_pack", "runtime_sources", "declarations", "comptime", "wasi_node_runner"]) {
if (typeof relocated[name] === "string") relocated[name] = pathFrom(relocated[name]);
}
for (const name of ["linker", "dsymutil", "archiver", "relocatable_linker"]) {
const value = relocated[name];
if (typeof value === "string" && !isAbsolute(value) && /[/\\]/.test(value)) relocated[name] = pathFrom(value);
}
if (relocated.runtime_packs) relocated.runtime_packs = relocated.runtime_packs.map((pack) => ({ ...pack, path: pathFrom(pack.path) }));
return relocated;
}
export function installNativeCli(directory, packageName = nativeCliPackage(process.platform, process.arch, hostLibc())) {
directory = realpathSync(directory);
const require = createRequire(join(directory, "package.json"));
const packageManifest = JSON.parse(readFileSync(join(directory, "package.json"), "utf8"));
const version = packageManifest.version;
let platformManifest;
try { platformManifest = require.resolve(`${packageName}/package.json`); }
catch { throw new Error(`scriptc requires ${packageName}@${version}; reinstall with optional dependencies enabled`); }
const identity = JSON.parse(readFileSync(platformManifest, "utf8"));
if (identity.name !== packageName || identity.version !== version) throw new Error(`scriptc requires ${packageName}@${version}, found ${identity.version}`);
const sourceDirectory = join(dirname(platformManifest), "dist", "bin");
const source = join(sourceDirectory, process.platform === "win32" ? "scriptc.exe" : "scriptc");
const original = JSON.parse(readFileSync(source + ".json", "utf8"));
if (original.schema !== "scriptc.native-toolchain.v1" || original.compiler_version !== version) throw new Error("native compiler toolchain version does not match this installation");
const bin = join(directory, "bin");
mkdirSync(bin, { recursive: true });
const manifest = relocateToolchain(original, sourceDirectory, bin);
const packs = new Map((manifest.runtime_packs ?? []).map((pack) => [pack.target, pack]));
for (const suffix of ["darwin-arm64", "darwin-x64", "linux-x64-gnu", "linux-arm64-gnu", "linux-x64-musl", "linux-arm64-musl",
"win32-x64-msvc", "wasm32-wasi", "ios-arm64", "ios-simulator-arm64", "android-arm64"]) {
const name = `@scriptc/runtime-${suffix}`;
if (packageManifest.optionalDependencies?.[name] === undefined) continue;
let path;
try { path = require.resolve(`${name}/package.json`); }
catch { continue; }
const packIdentity = JSON.parse(readFileSync(path, "utf8"));
if (packIdentity.name !== name || packIdentity.version !== version) throw new Error(`scriptc requires ${name}@${version}`);
const pack = JSON.parse(readFileSync(join(dirname(path), "runtime-pack.json"), "utf8"));
if (pack.schema !== "scriptc.runtime-pack.v1" || pack.package !== name || pack.version !== version) {
throw new Error(`invalid runtime pack installed for ${name}@${version}`);
}
if (!packs.has(pack.target.name)) packs.set(pack.target.name, { target: pack.target.name, path: relative(bin, dirname(path)) });
}
manifest.runtime_packs = [...packs.values()];
// npm normalizes modes for payloads outside package bin entries. These
// tools are launched directly by the compiler after installation.
if (process.platform !== "win32") {
for (const path of [manifest.ts7, manifest.comptime, join(manifest.llvm_package, "bin/scriptc-llvm-codegen")]) {
chmodSync(resolve(bin, path), 0o755);
}
}
// The common filename lets npm's Windows shim invoke a native executable.
// POSIX bin links also execute this file directly, without a JS launcher.
const destination = join(bin, "scriptc.exe");
const staged = destination + `.install-${process.pid}`;
try {
copyFileSync(source, staged, constants.COPYFILE_FICLONE);
chmodSync(staged, 0o755);
writeFileSync(staged + ".json", JSON.stringify(manifest, null, 2) + "\n");
renameSync(staged + ".json", destination + ".json");
renameSync(staged, destination);
} finally {
rmSync(staged, { force: true });
rmSync(staged + ".json", { force: true });
}
return destination;
}
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
try {
const directory = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const manifest = JSON.parse(readFileSync(join(directory, "package.json"), "utf8"));
// A source checkout builds the native distribution separately. pnpm pack
// replaces workspace ranges with release versions before publication.
const workspace = Object.values(manifest.optionalDependencies ?? {}).some((value) => String(value).startsWith("workspace:"));
if (!workspace) installNativeCli(directory);
}
catch (error) {
process.stderr.write(`scriptc: ${error instanceof Error ? error.message : String(error)}\n`);
process.exitCode = 1;
}
}
+18
View File
@@ -0,0 +1,18 @@
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
export function prepareNativeCommand(directory) {
const bin = join(directory, "bin");
mkdirSync(bin, { recursive: true });
// npm creates bin links before postinstall. The payload must exist in the
// tarball, and no shebang may pin the Windows shim to an interpreter.
// Installation replaces this placeholder with the platform executable.
writeFileSync(join(bin, "scriptc.exe"),
"echo 'scriptc: native installation is incomplete; enable install scripts and reinstall scriptc' >&2\nexit 1\n", { mode: 0o755 });
rmSync(join(bin, "scriptc.exe.json"), { force: true });
}
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
prepareNativeCommand(resolve(dirname(fileURLToPath(import.meta.url)), ".."));
}
+1
View File
@@ -0,0 +1 @@
export * from "@scriptc/compiler/cli/command";
+1
View File
@@ -0,0 +1 @@
export * from "@scriptc/compiler/cli/host";
+20 -337
View File
@@ -1,12 +1,9 @@
import { spawn } from "node:child_process";
import { existsSync, readFileSync, rmSync, statSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { existsSync, readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { parseArgs } from "node:util";
import { analyze, compile, compileLibrary, isExactExternalTypeSpecifier, renderDiagnostics, renderCoverage, resolveProvenanceSources, setProvenanceSources, sourceTargetPlatform, warmNativeCaches, type NativeCacheWarmProfile } from "@scriptc/compiler";
import { resolveOutputOptions } from "./output-options.js";
import { selectOutputPaths } from "./paths.js";
import { CLI_OPTIONS, USAGE } from "./usage.js";
import { analyze, compile, compileLibrary, resolveProvenanceSources, sourceTargetPlatform, warmNativeCaches } from "@scriptc/compiler";
import { runCli } from "./command.js";
/** The version of the installed package. Read from the manifest rather than
* baked in by the build, so a stamped release and a source checkout answer
@@ -19,334 +16,20 @@ function version(): string {
return manifest.version ?? "unknown";
}
/* The exit discipline: NEVER process.exit() after writing output. stdout/
* stderr to a PIPE are async streams — process.exit() drops whatever libuv
* hasn't flushed yet, which truncates large diagnostic renders at the pipe
* buffer (observed: 64KB cut mid-code-frame). Every path sets
* process.exitCode and returns instead; Node exits naturally once the
* streams drain. */
class CliExit extends Error {
constructor(readonly code: number) {
super(`exit ${code}`);
}
}
function fail(msg: string): never {
process.stderr.write(msg + "\n");
throw new CliExit(1);
}
/** parseArgs, with its throw turned into the CLI's own one-line error.
* Unparseable arguments are a USER error — an unknown flag or a missing
* value used to reach the top level as an uncaught ERR_PARSE_ARGS_* and
* print a Node stack trace over the user's terminal. */
function parseCli(): ReturnType<typeof parseArgs<{ options: typeof CLI_OPTIONS; allowPositionals: true; allowNegative: true }>> {
try {
return parseArgs({ options: CLI_OPTIONS, allowPositionals: true, allowNegative: true });
} catch (err) {
// parseArgs appends a paragraph about `--` and positionals to the
// unknown-option message; the first sentence is the part that names
// what was wrong, and USAGE below already covers what was meant.
const raw = err instanceof Error ? err.message : String(err);
const msg = raw.split("\n")[0]!.split(". ")[0]!;
fail(`scriptc: ${msg}\n\n${USAGE}`);
}
}
async function main(): Promise<number> {
const { values, positionals } = parseCli();
const externalTypeArgs = values["external-types"] ?? [];
if (values.version) {
process.stdout.write(`${version()}\n`);
return 0;
}
if (values.help || positionals.length === 0) {
process.stdout.write(USAGE);
return values.help ? 0 : 1;
}
const [command, inputArg] = positionals;
if (command === "cache") {
if (inputArg !== "warm") fail(`unknown cache command "${inputArg ?? ""}" (supported: warm)\n\n${USAGE}`);
if (values.lib || values.dynamic || values.backend !== undefined || values.emit !== undefined || values.print !== undefined || values.ffi !== undefined || values.profile !== undefined || values.strip || values["windows-subsystem"] !== undefined || (values["npm-static"] ?? []).length > 0 || values["provenance-sources"] || externalTypeArgs.length > 0 || values.out !== undefined || values["emit-ir"] || !values["keep-llvm"]) {
fail(`scriptc cache warm takes only native optimization/sanitizer options and profile names\n\n${USAGE}`);
}
const optimization = values.optimization;
if (optimization !== undefined && optimization !== "release" && optimization !== "dev") {
fail(`unknown optimization "${optimization}" (supported: release, dev)\n\n${USAGE}`);
}
const profileArgs = positionals.slice(2);
const knownProfiles = new Set<NativeCacheWarmProfile>(["runtime", "tls", "dynamic"]);
for (const profile of profileArgs) {
if (!knownProfiles.has(profile as NativeCacheWarmProfile)) {
fail(`unknown cache warm profile "${profile}" (supported: runtime, tls, dynamic)`);
}
}
let result;
try {
result = await warmNativeCaches({
...(optimization === undefined ? {} : { optimization }),
sanitize: values.sanitize,
...(profileArgs.length === 0
? {}
: { profiles: profileArgs as NativeCacheWarmProfile[] }),
});
} catch (error) {
fail(`scriptc: ${error instanceof Error ? error.message : String(error)}`);
}
process.stdout.write(`${result.cacheRoot}\n`);
for (const profile of result.profiles) {
process.stdout.write(`${profile.profile}\t${Math.round(profile.elapsedMs)}ms\n`);
}
return 0;
}
if (command !== "build" && command !== "run" && command !== "coverage") {
fail(`unknown command "${command}"\n\n${USAGE}`);
}
if (values.lib) {
// LIBRARY mode: the profile names the entry module and pins the
// emission; the executable lane's mode flags have no meaning here
// (library artifacts are static-tier only, and there is no fallback
// concept — bare npm specifiers are static-or-refuse: the npm-static
// eligibility bar runs automatically, eligible packages compile into
// the graph, ineligible ones refuse with SC4013).
if (command !== "build") fail(`--lib is a build mode (scriptc build --lib --profile <p.json>)\n\n${USAGE}`);
const profileArg = values.profile;
if (!profileArg) fail(`scriptc build --lib needs --profile <profile.json>\n\n${USAGE}`);
if (inputArg) {
fail("scriptc build --lib takes no input positional: the profile names the entry module");
}
if (values.dynamic || values.backend !== undefined || values.emit !== undefined || values.print !== undefined || values.optimization !== undefined || values.strip || values.ffi !== undefined || values["windows-subsystem"] !== undefined || (values["npm-static"] ?? []).length > 0 || externalTypeArgs.length > 0) {
fail(
"scriptc build --lib takes no --dynamic/--backend/--emit/--print/--optimization/--strip/--windows-subsystem/--npm-static/--ffi/--external-types: the profile pins the emission and optimization, npm imports are judged automatically, outbound FFI belongs to executable builds, and external type mappings belong to coverage",
);
}
const profilePath = resolve(profileArg);
const libOutDir = values.out ? dirname(resolve(values.out)) : join(dirname(profilePath), ".scriptc");
const result = await compileLibrary({
profilePath,
outDir: libOutDir,
...(values.out ? { outPath: resolve(values.out) } : {}),
emitIr: values["emit-ir"],
sanitize: values.sanitize,
process.exitCode = await runCli(process.argv.slice(2), {
version, analyze: async (entry, options) => analyze(entry, options), compile, compileLibrary, resolveProvenanceSources, sourceTargetPlatform, warmNativeCaches,
run: (binary) => new Promise<number>((resolveExit) => {
let child;
if (sourceTargetPlatform() === "wasi") {
const builtRunner = fileURLToPath(new URL("./wasi-runner.js", import.meta.url));
const runner = existsSync(builtRunner) ? builtRunner : fileURLToPath(new URL("./wasi-runner.ts", import.meta.url));
child = spawn(process.execPath, [...process.execArgv, "--no-warnings", runner, binary], { stdio: "inherit" });
} else child = spawn(binary, [], { stdio: "inherit" });
child.on("exit", (code, signal) => {
if (signal) {
process.stderr.write(`scriptc: program killed by ${signal}\n`);
resolveExit(1);
} else resolveExit(code ?? 0);
});
if (!result.ok) {
const color = process.stderr.isTTY ?? false;
process.stderr.write(renderDiagnostics(result.diagnostics, result.sourceTexts, { color }) + "\n");
const n = result.diagnostics.length;
process.stderr.write(`\n${n} error${n === 1 ? "" : "s"}.\n`);
return 1;
}
if (!values["keep-llvm"]) rmSync(result.llvmPath, { force: true });
process.stdout.write(`${result.archivePath}\n`);
// The contract sidecar rides the same invocation when the profile
// declares one — name it so the embedder's tooling knows where to look.
if (result.sidecarPath !== undefined) process.stdout.write(`${result.sidecarPath}\n`);
return 0;
}
if (values["emit-ir"] && (command === "build" || command === "run")) {
process.stderr.write("scriptc: warning: --emit-ir is deprecated; use --emit=ir for IR as the primary output\n");
}
if (!inputArg) fail(`missing input file\n\n${USAGE}`);
const input = resolve(inputArg);
if (command === "coverage" && values.emit !== undefined) {
fail(`--emit is a build/run option\n\n${USAGE}`);
}
if (command === "coverage" && values.strip) {
fail(`--strip is a build/run option\n\n${USAGE}`);
}
if (values.print !== undefined && values.print !== "native-link-info") {
fail(`unknown print kind "${values.print}" (supported: native-link-info)\n\n${USAGE}`);
}
const printNativeLinkInfo = values.print === "native-link-info";
if (printNativeLinkInfo && command !== "build") {
fail(`--print=native-link-info is a build option\n\n${USAGE}`);
}
if (printNativeLinkInfo && values.emit !== undefined && values.emit !== "obj") {
fail(`--print=native-link-info requires --emit=obj\n\n${USAGE}`);
}
if (externalTypeArgs.length > 0 && command !== "coverage") {
fail(`--external-types is a coverage-only option\n\n${USAGE}`);
}
const externalTypes: Record<string, string> = Object.create(null) as Record<string, string>;
for (const mapping of externalTypeArgs) {
const equals = mapping.indexOf("=");
if (equals <= 0 || equals === mapping.length - 1) {
fail(`invalid --external-types mapping ${JSON.stringify(mapping)} (expected <specifier=file.d.ts>)`);
}
const specifier = mapping.slice(0, equals).trim();
const declarationArg = mapping.slice(equals + 1).trim();
if (!isExactExternalTypeSpecifier(specifier)) {
fail(`invalid --external-types specifier ${JSON.stringify(specifier)} (expected an exact bare package specifier)`);
}
if (!/\.d\.(?:ts|mts|cts)$/.test(declarationArg)) {
fail(`invalid --external-types declaration ${JSON.stringify(declarationArg)} (expected a .d.ts, .d.mts, or .d.cts file)`);
}
if (externalTypes[specifier] !== undefined) {
fail(`duplicate --external-types mapping for ${JSON.stringify(specifier)}`);
}
const declarationPath = resolve(declarationArg);
try {
if (!statSync(declarationPath).isFile()) throw new Error("not a file");
} catch {
fail(`--external-types declaration does not name a readable file: ${declarationPath}`);
}
externalTypes[specifier] = declarationPath;
}
const ffiProfilePath = values.ffi !== undefined ? resolve(values.ffi) : undefined;
if (values.backend !== undefined && values.backend !== "llvm") {
fail(`unknown backend "${values.backend}" (supported: llvm)\n\n${USAGE}`);
}
const optimization = values.optimization;
if (optimization !== undefined && optimization !== "release" && optimization !== "dev") {
fail(`unknown optimization "${optimization}" (supported: release, dev)\n\n${USAGE}`);
}
const windowsSubsystem = values["windows-subsystem"];
if (windowsSubsystem !== undefined && windowsSubsystem !== "console" && windowsSubsystem !== "gui") {
fail(`unknown Windows subsystem "${windowsSubsystem}" (supported: console, gui)\n\n${USAGE}`);
}
if (windowsSubsystem !== undefined && command === "coverage") {
fail(`--windows-subsystem is only supported for executable builds\n\n${USAGE}`);
}
const output = command === "coverage"
? null
: resolveOutputOptions(command, {
...(values.emit === undefined && !printNativeLinkInfo
? {}
: { emit: values.emit ?? "obj" }),
emitIr: values["emit-ir"],
...(values.backend === undefined ? {} : { backend: values.backend }),
keepLlvm: values["keep-llvm"],
sanitize: values.sanitize,
...(values.optimization === undefined ? {} : { optimization: values.optimization }),
strip: values.strip,
...(windowsSubsystem === undefined ? {} : { windowsSubsystem }),
...(values.ffi === undefined ? {} : { ffi: values.ffi }),
});
if (output !== null && !output.ok) fail(`${output.message}\n\n${USAGE}`);
const backend = output?.ok ? output.backend : undefined;
// --npm-static: repeatable and comma-splittable; the literal "auto"
// switches to eligibility-based detection (mixing "auto" with names
// is rejected — the shapes answer different questions).
const npmStaticRaw = (values["npm-static"] ?? []).flatMap((v) => v.split(",")).map((v) => v.trim()).filter((v) => v !== "");
let npmStatic: string[] | "auto" | undefined;
if (npmStaticRaw.includes("auto")) {
if (npmStaticRaw.length > 1) fail(`--npm-static auto cannot be combined with package names\n\n${USAGE}`);
npmStatic = "auto";
} else if (npmStaticRaw.length > 0) {
npmStatic = npmStaticRaw;
}
// --provenance-sources resolves BEFORE the program loads (tsgo needs the
// source "paths" at creation): attestations and source trees fetch (or
// ride the content-addressed cache / the offline manifest), the registry
// installs, and every fallback prints as a note — never a failure.
const provenance = values["provenance-sources"] ? await resolveProvenanceSources(input) : null;
if (provenance !== null) {
setProvenanceSources(provenance);
for (const pkg of provenance.packages) {
process.stderr.write(
`provenance: ${pkg.name}@${pkg.version} ← ${pkg.repo.replace(/^git\+/, "")} @ ${pkg.commit.slice(0, 12)} (source compiles statically)\n`,
);
}
for (const note of provenance.notes) process.stderr.write(`provenance: ${note}\n`);
}
if (command === "coverage") {
const { coverage, sourceTexts } = analyze(input, {
dynamic: values.dynamic,
...(npmStatic !== undefined ? { npmStatic } : {}),
...(ffiProfilePath !== undefined ? { ffiProfilePath } : {}),
...(Object.keys(externalTypes).length > 0 ? { externalTypes } : {}),
});
const color = process.stdout.isTTY ?? false;
process.stdout.write(renderCoverage(coverage, { color, sourceTexts }) + "\n");
return coverage.preflightFailed ? 1 : 0;
}
if (output === null || !output.ok) throw new Error("internal output-option state");
if (windowsSubsystem !== undefined && sourceTargetPlatform() !== "win32") {
fail(`--windows-subsystem requires a Windows executable target\n\n${USAGE}`);
}
const { outDir, outPath } = selectOutputPaths(input, output.cliOutputKind, values.out);
let nativeLinkInfo: object | undefined;
const build = async (): Promise<string> => {
const result = await compile(input, {
outPath,
outDir,
outputKind: output.outputKind,
emitIr: output.emitIr,
sanitize: values.sanitize,
dynamic: values.dynamic,
...(backend !== undefined ? { backend } : {}),
...(optimization !== undefined ? { optimization } : {}),
...(values.strip ? { strip: true } : {}),
...(windowsSubsystem !== undefined ? { windowsSubsystem } : {}),
...(npmStatic !== undefined ? { npmStatic } : {}),
...(ffiProfilePath !== undefined ? { ffiProfilePath } : {}),
...(printNativeLinkInfo ? { nativeLinkInfo: true } : {}),
});
if (!result.ok) {
const color = process.stderr.isTTY ?? false;
process.stderr.write(renderDiagnostics(result.diagnostics, result.sourceTexts, { color }) + "\n");
const n = result.diagnostics.length;
process.stderr.write(`\n${n} error${n === 1 ? "" : "s"}.\n`);
throw new CliExit(1);
}
if (result.artifact.kind === "exe") {
if (!values["keep-llvm"]) rmSync(result.artifact.translationUnitPath, { force: true });
} else if (result.artifact.kind === "obj") {
nativeLinkInfo = result.artifact.nativeLinkInfo;
}
return result.artifact.path;
};
const binary = await build();
if (command === "run") {
return new Promise<number>((resolveExit) => {
let child;
if (sourceTargetPlatform() === "wasi") {
const builtRunner = fileURLToPath(new URL("./wasi-runner.js", import.meta.url));
const runner = existsSync(builtRunner)
? builtRunner
: fileURLToPath(new URL("./wasi-runner.ts", import.meta.url));
child = spawn(
process.execPath,
[...process.execArgv, "--no-warnings", runner, binary],
{ stdio: "inherit" },
);
} else {
child = spawn(binary, [], { stdio: "inherit" });
}
child.on("exit", (code, signal) => {
if (signal) {
process.stderr.write(`scriptc: program killed by ${signal}\n`);
resolveExit(1);
} else {
resolveExit(code ?? 0);
}
});
});
}
if (printNativeLinkInfo) {
if (nativeLinkInfo === undefined) throw new Error("internal native-link-info state");
// Keep stdout pure JSON for tooling; the ordinary artifact path is in
// program.object inside the document.
process.stdout.write(`${JSON.stringify(nativeLinkInfo, null, 2)}\n`);
} else {
process.stdout.write(`${binary}\n`);
}
return 0;
}
try {
process.exitCode = await main();
} catch (err) {
if (err instanceof CliExit) process.exitCode = err.code;
else throw err;
}
}),
});
+1 -91
View File
@@ -1,91 +1 @@
import type { CompileOutputKind } from "@scriptc/compiler";
import type { CliOutputKind } from "./paths.js";
export interface OutputOptionValues {
emit?: string;
emitIr: boolean;
backend?: string;
keepLlvm: boolean;
sanitize: boolean;
optimization?: string;
strip?: boolean;
windowsSubsystem?: string;
ffi?: string;
}
export type OutputOptionResolution =
| {
ok: true;
outputKind: CompileOutputKind;
cliOutputKind: CliOutputKind;
backend?: "llvm";
emitIr: boolean;
deprecateEmitIr: boolean;
}
| { ok: false; message: string };
const SOURCE_KINDS = new Set<CliOutputKind>(["ir", "llvm"]);
const NATIVE_ARTIFACT_KINDS = new Set<CliOutputKind>(["asm", "obj"]);
/** Pure compatibility/validation matrix for build/run output selection. */
export function resolveOutputOptions(
command: "build" | "run",
values: OutputOptionValues,
): OutputOptionResolution {
if (values.backend !== undefined && values.backend !== "llvm") {
return { ok: false, message: `unknown backend "${values.backend}" (supported: llvm)` };
}
const backend = values.backend as "llvm" | undefined;
const rawEmit = values.emit;
if (
rawEmit !== undefined && rawEmit !== "ir" && rawEmit !== "llvm" &&
rawEmit !== "asm" && rawEmit !== "obj" && rawEmit !== "exe"
) {
return {
ok: false,
message: `unknown emit kind "${rawEmit}" (supported: ir, llvm, asm, obj, exe)`,
};
}
const emit = (rawEmit ?? "exe") as CliOutputKind;
if (command === "run" && emit !== "exe") {
return { ok: false, message: `scriptc run requires --emit=exe` };
}
if (values.emitIr && rawEmit !== undefined && emit !== "ir" && emit !== "exe") {
return { ok: false, message: `--emit-ir cannot be combined with --emit=${emit}; use --emit=ir` };
}
if (values.emitIr && emit === "ir") {
return { ok: false, message: `--emit-ir and --emit=ir select the same output; use --emit=ir` };
}
if (values.windowsSubsystem !== undefined && emit !== "exe") {
return { ok: false, message: `--windows-subsystem is only supported with --emit=exe` };
}
if (values.strip && emit !== "exe") {
return { ok: false, message: `--strip is only supported with --emit=exe` };
}
if (emit === "ir" && backend !== undefined) {
return { ok: false, message: `--emit=ir cannot be combined with --backend; IR is emitted before backend selection` };
}
if (SOURCE_KINDS.has(emit)) {
if (!values.keepLlvm) {
return { ok: false, message: `--no-keep-llvm is only meaningful with --emit=exe` };
}
if (values.sanitize) {
return { ok: false, message: `--sanitize is only meaningful with --emit=exe` };
}
if (values.optimization !== undefined) {
return { ok: false, message: `--optimization is only meaningful with --emit=exe` };
}
}
if (NATIVE_ARTIFACT_KINDS.has(emit) && !values.keepLlvm) {
return { ok: false, message: `--no-keep-llvm is only meaningful with --emit=exe` };
}
const outputKind = emit as CompileOutputKind;
return {
ok: true,
outputKind,
cliOutputKind: emit,
...(emit === "ir" && backend === undefined ? {} : { backend: "llvm" as const }),
emitIr: values.emitIr && emit === "exe",
deprecateEmitIr: values.emitIr,
};
}
export * from "@scriptc/compiler/cli/output-options";
+1 -103
View File
@@ -1,103 +1 @@
import { tmpdir } from "node:os";
import { basename, dirname, join, resolve } from "node:path";
import { buildTargetPlatform, sourceTargetPlatform, wasiGuestPath } from "@scriptc/compiler";
export type CliOutputKind = "ir" | "llvm" | "asm" | "obj" | "exe";
const POSIX_SUFFIXES: Record<CliOutputKind, string> = {
ir: ".ir.json",
llvm: ".ll",
asm: ".s",
obj: ".o",
exe: "",
};
const WINDOWS_SUFFIXES: Record<CliOutputKind, string> = {
...POSIX_SUFFIXES,
asm: ".asm",
obj: ".obj",
exe: ".exe",
};
export function defaultOutputName(
stem: string,
kind: CliOutputKind,
platform?: string,
): string {
const selectedPlatform = platform ?? (kind === "exe" ? sourceTargetPlatform() : process.platform);
if (kind === "exe" && selectedPlatform === "wasi") return `${stem}.wasm`;
return `${stem}${(selectedPlatform === "win32" ? WINDOWS_SUFFIXES : POSIX_SUFFIXES)[kind]}`;
}
export interface OutputPaths {
outDir: string;
outPath: string;
}
/** One authority for explicit and default primary artifact paths. */
export function selectOutputPaths(
input: string,
kind: CliOutputKind,
explicitOut?: string,
platform?: string,
): OutputPaths {
const absoluteInput = resolve(input);
const outDir = explicitOut === undefined
? join(dirname(absoluteInput), ".scriptc")
: dirname(resolve(explicitOut));
const stem = basename(absoluteInput).replace(/\.(ts|mts|cts|js|mjs|cjs|c|ll)$/, "");
return {
outDir,
outPath: explicitOut === undefined
? join(outDir, defaultOutputName(stem, kind, platform))
: resolve(explicitOut),
};
}
/** Default executable filename for the build target. Explicit --out paths
* stay exact; only scriptc's generated default needs the Windows PE suffix. */
export function defaultExecutableName(stem: string, platform: string = buildTargetPlatform()): string {
return defaultOutputName(stem, "exe", platform);
}
/** Host paths exposed by `scriptc run` to a WASI Preview 1 module. Guest
* `/tmp` maps to the host's real platform temp directory instead of assuming
* the POSIX spelling exists (notably false on Windows). */
export function wasiPreopens(
cwd: string = process.cwd(),
hostTmp: string = tmpdir(),
): Record<string, string> {
return { "/": cwd, "/tmp": hostTmp };
}
/** Environment inherited by a WASI module. Host-absolute directory values
* must not claim paths outside the guest namespace: `/` is the module's
* capability root/home/cwd and `/tmp` is its writable temporary directory. */
export function wasiEnvironment(
env: NodeJS.ProcessEnv = process.env,
cwd: string = process.cwd(),
hostTmp: string = tmpdir(),
): Record<string, string> {
const guest = Object.fromEntries(
Object.entries(env).filter((entry): entry is [string, string] => entry[1] !== undefined),
);
guest["PWD"] = "/";
guest["HOME"] = "/";
guest["TMPDIR"] = "/tmp";
if (guest["USERPROFILE"] !== undefined) guest["USERPROFILE"] = "/";
if (guest["TMP"] !== undefined) guest["TMP"] = "/tmp";
if (guest["TEMP"] !== undefined) guest["TEMP"] = "/tmp";
// These optional shell/package-manager paths retain their meaning only
// when they fall under a capability the runner actually exposes.
for (const key of ["OLDPWD", "INIT_CWD"] as const) {
const value = guest[key];
if (value === undefined) continue;
const mapped = wasiGuestPath(value, cwd, hostTmp);
if (mapped === null) delete guest[key];
else guest[key] = mapped;
}
return guest;
}
export * from "@scriptc/compiler/cli/paths";
+1 -86
View File
@@ -1,86 +1 @@
export const USAGE = `scriptc — TypeScript/JavaScript to native and WebAssembly executables (experimental)
Usage:
scriptc build <file.ts|.js> [options] compile to an executable or source artifact
scriptc run <file.ts|.js> [options] compile and run
scriptc coverage <file.ts|.js> how much compiles statically, and why not
scriptc coverage <file.ts|.js> --dynamic what a --dynamic build compiles, and what still blocks it
scriptc coverage <file.ts|.js> --external-types <specifier=file.d.ts>
type-resolve an embedder-provided module for analysis
scriptc build --lib --profile <p.json> library mode: compile the profile's entry
module to a linkable static archive
(<name>.lib.a), or a Wasm reactor
(<name>.wasm) on wasm32-wasi,
exporting the profile symbols; a profile
with a sidecar section also gets the
contract sidecar JSON beside the archive
scriptc cache warm [runtime|tls|dynamic…] prebuild expensive native cache families
for the current compiler/SDK/target
Options:
-o, --out <path> primary output path (default: .scriptc/<name><suffix>)
--emit <kind> primary output: ir, llvm, asm, obj, or exe
(default: exe). asm/obj use the matching platform helper
--print <kind> print machine-readable metadata instead of the output path
(native-link-info implies --emit=obj and never links)
--backend <b> code generator (llvm)
--optimization <release|dev>
native optimization posture (default: release/-O2). dev
uses -O0, source breakpoints, and cached LLVM object shards;
macOS executable builds also produce an adjacent .dSYM
--strip remove symbol/debug payload from the linked executable
for smaller builds (opt in; --emit=exe only)
--windows-subsystem <console|gui>
Windows executable subsystem (default: console). gui
prevents Windows from opening a console window
--keep-llvm keep generated LLVM IR beside the executable (default)
--no-keep-llvm delete generated LLVM IR after compiling
--emit-ir also write IR beside an executable or library archive;
deprecated for executables: use --emit=ir for primary IR
--sanitize build with ASan + runtime RC audit
--dynamic embed the dynamic engine (adds ~620KB; static stays the default)
--ffi <file> bind signature-only TypeScript declarations to native
C symbols and link the manifest's archives/libraries
--npm-static <pkg[,pkg…]|auto>
compile the named npm packages' shipped JS statically as
program modules (repeatable; "auto" opts in every eligible
direct import: own .d.ts, unminified JS, no build-transform
markers). A package preflight refuses falls back to the
island (--dynamic) with a coverage-report note — opt-in,
experimental
--provenance-sources
EXPERIMENTAL: compile npm dependencies from their
provenance-attested SOURCE (fetched at the attested
commit) as static program modules; packages without a
usable attestation keep the island path (a note, never
a failure)
--external-types <specifier=file.d.ts>
coverage only: map an exact bare module specifier to a
local declaration file. The declaration supplies types
for analysis; the host module remains an explicit
external-boundary blocker (repeatable)
-h, --help show this help
-v, --version print the version
`;
export const CLI_OPTIONS = {
out: { type: "string", short: "o" },
emit: { type: "string" },
print: { type: "string" },
backend: { type: "string" },
optimization: { type: "string" },
strip: { type: "boolean", default: false },
"windows-subsystem": { type: "string" },
"keep-llvm": { type: "boolean", default: true },
"emit-ir": { type: "boolean", default: false },
sanitize: { type: "boolean", default: false },
dynamic: { type: "boolean", default: false },
ffi: { type: "string" },
"npm-static": { type: "string", multiple: true },
"provenance-sources": { type: "boolean", default: false },
"external-types": { type: "string", multiple: true },
lib: { type: "boolean", default: false },
profile: { type: "string" },
help: { type: "boolean", short: "h", default: false },
version: { type: "boolean", short: "v", default: false },
} as const;
export * from "@scriptc/compiler/cli/usage";
+1 -38
View File
@@ -1,39 +1,2 @@
#!/usr/bin/env node
/* The subprocess host for `scriptc run` on wasm32-wasi. Keeping the WASI
* instance outside the CLI process preserves native run's exit isolation:
* process.exit(), traps, and signals cannot take the compiler process down.
*/
import { readFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { WASI } from "node:wasi";
import { wasiGuestPath } from "@scriptc/compiler";
import { wasiEnvironment, wasiPreopens } from "./paths.js";
type WasiInstance = Parameters<WASI["start"]>[0];
declare const WebAssembly: {
instantiate(
bytes: Uint8Array,
imports: ReturnType<WASI["getImportObject"]>,
): Promise<{ instance: WasiInstance }>;
};
const binary = process.argv[2];
if (binary === undefined) throw new Error("scriptc WASI runner needs a module path");
const cwd = process.cwd();
const hostTmp = tmpdir();
const wasi = new WASI({
version: "preview1",
args: [wasiGuestPath(binary, cwd, hostTmp) ?? binary],
env: wasiEnvironment(process.env, cwd, hostTmp),
// A native scriptc executable inherits access to the caller's filesystem.
// WASI is capability-based, so expose the caller's working tree as `/`
// and the platform's actual temporary directory as guest `/tmp`.
preopens: wasiPreopens(cwd, hostTmp),
returnOnExit: true,
});
const instantiated = await WebAssembly.instantiate(
await readFile(binary),
wasi.getImportObject(),
);
process.exitCode = wasi.start(instantiated.instance);
import "@scriptc/compiler/cli/wasi-runner";
+131
View File
@@ -0,0 +1,131 @@
import { execFileSync } from "node:child_process";
import { copyFileSync, mkdtempSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { afterEach, expect, test } from "vitest";
import { installNativeCli, nativeCliPackage } from "../scripts/install-native.mjs";
import { prepareNativeCommand } from "../scripts/prepare-native.mjs";
const roots: string[] = [];
afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); });
function fixture() {
const root = mkdtempSync(join(process.platform === "win32" ? tmpdir() : "/tmp", "scriptc-install-"));
roots.push(root);
const directory = join(root, "installation");
const packageName = "@scriptc/cli-darwin-arm64";
const platform = join(directory, "node_modules", packageName);
const bin = join(platform, "dist/bin");
mkdirSync(bin, { recursive: true });
writeFileSync(join(directory, "package.json"), JSON.stringify({ name: "scriptc", version: "1.2.3" }));
writeFileSync(join(platform, "package.json"), JSON.stringify({ name: packageName, version: "1.2.3" }));
const binary = join(bin, process.platform === "win32" ? "scriptc.exe" : "scriptc");
writeFileSync(binary, "native compiler payload");
const toolchain = {
schema: "scriptc.native-toolchain.v1", compiler_version: "1.2.3", target: "macos-arm64",
ts7: "../lib/typescript/lib/tsc", llvm_package: "../lib/llvm", runtime_pack: "../lib/runtime",
runtime_packs: [{ target: "macos-arm64", path: "../lib/runtime" }],
linker: "clang", linker_args: [], dsymutil: "dsymutil", comptime: "../lib/comptime",
wasi_node_runner: "../lib/wasi/cli/wasi-runner.js",
};
writeFileSync(binary + ".json", JSON.stringify(toolchain));
const helpers = [toolchain.ts7, toolchain.comptime, join(toolchain.llvm_package, "bin/scriptc-llvm-codegen")]
.map((path) => resolve(bin, path));
for (const path of helpers) {
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, "native helper payload", { mode: 0o644 });
}
return { root, directory, packageName, platform, binary, toolchain, helpers };
}
test("selects only supported native host packages, including Linux libc", () => {
expect(nativeCliPackage("darwin", "arm64", null)).toBe("@scriptc/cli-darwin-arm64");
expect(nativeCliPackage("win32", "x64", null)).toBe("@scriptc/cli-win32-x64-msvc");
expect(nativeCliPackage("linux", "x64", "glibc")).toBe("@scriptc/cli-linux-x64-gnu");
expect(nativeCliPackage("linux", "arm64", "musl")).toBe("@scriptc/cli-linux-arm64-musl");
expect(() => nativeCliPackage("linux", "x64", null)).toThrow("identify");
expect(() => nativeCliPackage("win32", "arm64", null)).toThrow("no native command");
});
test("installs a direct executable and relocatable references to platform assets", () => {
const f = fixture();
const command = installNativeCli(f.directory, f.packageName);
expect(readFileSync(command, "utf8")).toBe("native compiler payload");
if (process.platform !== "win32") expect(statSync(command).mode & 0o111).toBe(0o111);
if (process.platform !== "win32") {
for (const helper of f.helpers) expect(statSync(helper).mode & 0o111).toBe(0o111);
}
const moved = join(f.root, "relocated");
renameSync(f.directory, moved);
const installed = join(moved, "bin/scriptc.exe");
const manifest = JSON.parse(readFileSync(installed + ".json", "utf8"));
const platform = join(moved, "node_modules", f.packageName, "dist/lib");
expect(resolve(dirname(installed), manifest.ts7)).toBe(join(platform, "typescript/lib/tsc"));
expect(resolve(dirname(installed), manifest.wasi_node_runner)).toBe(join(platform, "wasi/cli/wasi-runner.js"));
expect(resolve(dirname(installed), manifest.runtime_packs[0].path)).toBe(join(platform, "runtime"));
expect(manifest.linker).toBe("clang");
expect(JSON.stringify(manifest)).not.toContain(f.directory);
});
test("missing and mismatched packages fail without replacing an installed command", () => {
const f = fixture();
const command = installNativeCli(f.directory, f.packageName);
writeFileSync(join(f.platform, "package.json"), JSON.stringify({ name: f.packageName, version: "1.2.4" }));
expect(() => installNativeCli(f.directory, f.packageName)).toThrow("found 1.2.4");
expect(() => installNativeCli(f.directory, "@scriptc/cli-missing")).toThrow("optional dependencies");
expect(readFileSync(command, "utf8")).toBe("native compiler payload");
});
test("npm links the installed native executable without an interpreter", () => {
const f = fixture();
// Keep the package payload small while exercising npm's native shim.
// Production command coverage builds the real compiler in the bootstrap gate.
const windows = process.platform === "win32";
if (windows) copyFileSync(process.execPath, f.binary);
else {
const source = join(f.root, "payload.c");
writeFileSync(source, '#include <stdio.h>\nint main(void) { puts("native compiler"); return 0; }\n');
execFileSync("clang", [source, "-o", f.binary]);
}
for (const helper of f.helpers) writeFileSync(helper, "#!/bin/sh\nprintf 'native helper\\n'\n");
const packageName = "scriptc-native-install-test";
const scripts = join(f.directory, "scripts");
mkdirSync(scripts);
copyFileSync(join(import.meta.dirname, "../scripts/install-native.mjs"), join(scripts, "install-native.mjs"));
writeFileSync(join(scripts, "fixture-install.mjs"),
`import { installNativeCli } from './install-native.mjs'; installNativeCli(process.cwd(), ${JSON.stringify(f.packageName)});\n`);
const npm = process.platform === "win32" ? "npm.cmd" : "npm";
const npmEnv = { ...process.env, NODE_PATH: "", npm_config_cache: join(f.root, "npm-cache"), npm_config_update_notifier: "false" };
const pack = (directory: string): string => {
const result = execFileSync(npm, ["pack", "--json", "--ignore-scripts"], {
cwd: directory, env: npmEnv, encoding: "utf8", shell: process.platform === "win32",
});
return join(directory, (JSON.parse(result) as { filename: string }[])[0]!.filename);
};
const platformTarball = pack(f.platform);
writeFileSync(join(f.directory, "package.json"), JSON.stringify({
name: packageName, version: "1.2.3", type: "module",
bin: { [packageName]: "bin/scriptc.exe" }, files: ["bin", "scripts"],
scripts: { postinstall: "node scripts/fixture-install.mjs" },
optionalDependencies: { [f.packageName]: "file:" + platformTarball },
}));
prepareNativeCommand(f.directory);
const tarball = pack(f.directory);
const installed = join(f.root, "npm-install");
mkdirSync(installed);
execFileSync(npm, ["install", "--offline", "--no-audit", "--no-fund", tarball], {
cwd: installed, env: npmEnv, shell: process.platform === "win32", stdio: "pipe",
});
const command = join(installed, "node_modules/.bin", packageName + (process.platform === "win32" ? ".cmd" : ""));
const result = execFileSync(command, windows ? ["--version"] : ["native compiler"], {
env: { ...process.env, PATH: "" }, encoding: "utf8", shell: process.platform === "win32",
});
expect(result.trim()).toBe(windows ? process.version : "native compiler");
if (process.platform !== "win32") {
const bin = join(installed, "node_modules", packageName, "bin");
const manifest = JSON.parse(readFileSync(join(bin, "scriptc.exe.json"), "utf8"));
for (const helper of [manifest.ts7, manifest.comptime, join(manifest.llvm_package, "bin/scriptc-llvm-codegen")]) {
expect(execFileSync(resolve(bin, helper), [], { env: { ...process.env, PATH: "" }, encoding: "utf8" }).trim()).toBe("native helper");
}
}
}, 60_000);
+6 -4
View File
@@ -42,7 +42,7 @@ test.runIf(supported)("WASI helper object plus runtime pack builds and runs with
})).resolves.toMatchObject({ stdout: "hello world\n" });
});
test.runIf(supported)("switching WASI LLVM and native C builds preserves both translation units", async () => {
test.runIf(supported)("switching WASI and native builds retains both executables", async () => {
const dir = await mkdtemp(join(tmpdir(), "scriptc-wasi-native-output-"));
dirs.push(dir);
const entry = join(dir, "hello.ts");
@@ -59,10 +59,12 @@ test.runIf(supported)("switching WASI LLVM and native C builds preserves both tr
const llvm = await readFile(join(outDir, "hello.ll"));
const wasm = await readFile(join(outDir, "hello.wasm"));
await build(["--backend=llvm", "--keep-llvm"], nativeEnv);
const c = await readFile(join(outDir, "hello.c"));
expect(await readFile(join(outDir, "hello.ll"))).toEqual(llvm);
const native = await readFile(join(outDir, process.platform === "win32" ? "hello.exe" : "hello"));
const nativeLlvm = await readFile(join(outDir, "hello.ll"));
expect(nativeLlvm.equals(llvm)).toBe(false);
expect(await readFile(join(outDir, "hello.wasm"))).toEqual(wasm);
await build([], wasiEnv);
expect(await readFile(join(outDir, "hello.c"))).toEqual(c);
expect(await readFile(join(outDir, "hello.ll"))).toEqual(llvm);
expect(await readFile(join(outDir, process.platform === "win32" ? "hello.exe" : "hello"))).toEqual(native);
});
+2 -3
View File
@@ -1069,9 +1069,8 @@ declare module "node:fs" {
): void;
/* The bare-encoding spelling — the options record's encoding key alone. */
export function writeFileSync(path: string, data: string, encoding: "utf8" | "utf-8"): void;
export function writeFileSync(path: string, data: Uint8Array): void;
export function appendFileSync(path: string, data: string): void;
export function appendFileSync(path: string, data: Uint8Array): void;
export function writeFileSync(path: string, data: string | Uint8Array): void;
export function appendFileSync(path: string, data: string | Uint8Array): void;
export function existsSync(path: string): boolean;
export function mkdirSync(path: string): void;
export function mkdirSync(path: string, options: { recursive?: boolean; mode?: number }): void;
+189
View File
@@ -0,0 +1,189 @@
/* Isolated JavaScript evaluation for comptime callbacks. No filesystem,
* process, module loader, or console bindings enter the guest context. */
#ifndef _WIN32
#define _POSIX_C_SOURCE 200809L
#endif
#include "quickjs.h"
#include <errno.h>
#include <math.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#ifdef _WIN32
#include <windows.h>
#else
#include <time.h>
#endif
static uint64_t now_ms(void) {
#ifdef _WIN32
return GetTickCount64();
#else
struct timespec ts;
if (clock_gettime(CLOCK_MONOTONIC, &ts) != 0) return 0;
return (uint64_t)ts.tv_sec * 1000 + (uint64_t)ts.tv_nsec / 1000000;
#endif
}
typedef struct { uint64_t deadline; int interrupted; } Budget;
static int interrupt(JSRuntime *rt, void *opaque) {
(void)rt;
Budget *budget = opaque;
if (now_ms() >= budget->deadline) budget->interrupted = 1;
return budget->interrupted;
}
typedef struct {
char *data;
size_t length, capacity;
void *active[512];
size_t depth;
} Output;
static int append(Output *out, const char *text) {
size_t size = strlen(text);
if (size > 64 * 1024 * 1024 - out->length) return -1;
size_t required = out->length + size + 1;
if (required > out->capacity) {
size_t capacity = out->capacity ? out->capacity : 1024;
while (capacity < required) capacity *= 2;
char *data = realloc(out->data, capacity);
if (!data) return -1;
out->data = data;
out->capacity = capacity;
}
memcpy(out->data + out->length, text, size + 1);
out->length += size;
return 0;
}
static int quote(JSContext *ctx, Output *out, JSValueConst value) {
size_t length;
const uint16_t *text = JS_ToCStringLenUTF16(ctx, &length, value);
if (!text) return -1;
int status = append(out, "\"");
for (size_t i = 0; status == 0 && i < length; i++) {
char escaped[7];
unsigned ch = text[i];
if (ch >= 32 && ch < 127 && ch != '"' && ch != '\\') {
escaped[0] = (char)ch; escaped[1] = 0;
} else snprintf(escaped, sizeof(escaped), "\\u%04x", ch);
status = append(out, escaped);
}
JS_FreeCStringUTF16(ctx, text);
return status == 0 ? append(out, "\"") : status;
}
/* Serialize through engine APIs, outside the callback's mutable globals.
* The guest cannot replace JSON.stringify or install a toJSON hook that
* changes the value the compiler validates. */
static int encode(JSContext *ctx, Output *out, JSValueConst value) {
if (JS_IsException(value)) return -1;
if (JS_IsUndefined(value)) return append(out, "[\"undefined\"]");
if (JS_IsNull(value)) return append(out, "[\"null\"]");
if (JS_IsBool(value)) return append(out, JS_ToBool(ctx, value) ? "[\"boolean\",true]" : "[\"boolean\",false]");
if (JS_IsFunction(ctx, value)) return append(out, "[\"function\"]");
if (JS_IsSymbol(value)) return append(out, "[\"symbol\"]");
if (JS_IsString(value) || JS_IsNumber(value) || JS_IsBigInt(value)) {
const char *tag = JS_IsString(value) ? "[\"string\"," : JS_IsNumber(value) ? "[\"number\"," : "[\"bigint\",";
if (append(out, tag) != 0) return -1;
double number = 1;
if (JS_IsNumber(value) && JS_ToFloat64(ctx, &number, value) < 0) return -1;
int status = number == 0 && signbit(number) ? append(out, "\"-0\"") : quote(ctx, out, value);
return status == 0 ? append(out, "]") : status;
}
if (out->depth == 512) { JS_ThrowTypeError(ctx, "compile-time result is nested too deeply"); return -1; }
void *identity = JS_VALUE_GET_PTR(value);
for (size_t i = 0; i < out->depth; i++) {
if (out->active[i] == identity) { JS_ThrowTypeError(ctx, "cyclic compile-time result"); return -1; }
}
out->active[out->depth++] = identity;
int status = 0;
if (JS_IsArray(value)) {
int64_t length = 0;
if (JS_GetLength(ctx, value, &length) < 0 || length < 0 || length > 1000000) status = -1;
if (status == 0) status = append(out, "[\"array\",[");
for (int64_t i = 0; status == 0 && i < length; i++) {
if (i != 0) status = append(out, ",");
JSValue item = JS_GetPropertyUint32(ctx, value, (uint32_t)i);
if (status == 0) status = encode(ctx, out, item);
JS_FreeValue(ctx, item);
}
} else {
JSPropertyEnum *properties = NULL;
uint32_t length = 0;
status = JS_GetOwnPropertyNames(ctx, &properties, &length, value, JS_GPN_STRING_MASK | JS_GPN_ENUM_ONLY);
if (status == 0) status = append(out, "[\"object\",[");
for (uint32_t i = 0; status == 0 && i < length; i++) {
if (i != 0) status = append(out, ",");
if (status == 0) status = append(out, "[");
JSValue key = JS_AtomToString(ctx, properties[i].atom);
if (status == 0) status = quote(ctx, out, key);
JS_FreeValue(ctx, key);
if (status == 0) status = append(out, ",");
JSValue item = JS_GetProperty(ctx, value, properties[i].atom);
if (status == 0) status = encode(ctx, out, item);
JS_FreeValue(ctx, item);
if (status == 0) status = append(out, "]");
}
JS_FreePropertyEnum(ctx, properties, length);
}
out->depth--;
return status == 0 ? append(out, "]]") : status;
}
int main(int argc, char **argv) {
if (argc != 3) { fputs("usage: scriptc-comptime <javascript> <timeout-ms>\n", stderr); return 2; }
char *end = NULL;
errno = 0;
long timeout = strtol(argv[2], &end, 10);
if (errno || !end || *end || timeout < 1 || timeout > 60000) {
fputs("invalid compile-time budget\n", stderr); return 2;
}
FILE *file = fopen(argv[1], "rb");
if (!file) { perror("compile-time input"); return 2; }
if (fseek(file, 0, SEEK_END) != 0) { fclose(file); return 2; }
long size = ftell(file);
if (size < 0 || size > 16 * 1024 * 1024 || fseek(file, 0, SEEK_SET) != 0) { fclose(file); return 2; }
char *source = malloc((size_t)size + 1);
if (!source) { fclose(file); return 2; }
if (fread(source, 1, (size_t)size, file) != (size_t)size) { free(source); fclose(file); return 2; }
fclose(file);
source[size] = 0;
JSRuntime *rt = JS_NewRuntime();
if (!rt) { free(source); return 2; }
JS_SetMemoryLimit(rt, 256 * 1024 * 1024);
JS_SetMaxStackSize(rt, 2 * 1024 * 1024);
Budget budget = { now_ms() + (uint64_t)timeout, 0 };
JS_SetInterruptHandler(rt, interrupt, &budget);
JSContext *ctx = JS_NewContext(rt);
if (!ctx) { JS_FreeRuntime(rt); free(source); return 2; }
JSValue result = JS_Eval(ctx, source, (size_t)size, "comptime", JS_EVAL_TYPE_GLOBAL);
free(source);
int status = 0;
Output out = {0};
if (JS_IsException(result) || encode(ctx, &out, result) != 0) {
free(out.data);
memset(&out, 0, sizeof(out));
JSValue exception = JS_GetException(ctx);
if (budget.interrupted) status = append(&out, "[\"error\",\"ERR_SCRIPT_EXECUTION_TIMEOUT\",\"compile-time evaluation timed out\"]");
else {
JSValue message = JS_GetPropertyStr(ctx, exception, "message");
JSValue detail = JS_IsUndefined(message) ? JS_IsNull(exception)
? JS_NewString(ctx, "compile-time result exceeds the output limit") : JS_DupValue(ctx, exception) : JS_DupValue(ctx, message);
status = append(&out, "[\"error\",\"\",");
if (status == 0) status = quote(ctx, &out, detail);
if (status == 0) status = append(&out, "]");
JS_FreeValue(ctx, detail);
JS_FreeValue(ctx, message);
}
JS_FreeValue(ctx, exception);
}
if (status == 0 && (fwrite(out.data, 1, out.length, stdout) != out.length || fputc('\n', stdout) == EOF)) status = 2;
free(out.data);
JS_FreeValue(ctx, result);
JS_FreeContext(ctx);
JS_FreeRuntime(rt);
return status == 0 ? 0 : 2;
}
+46
View File
@@ -0,0 +1,46 @@
#ifndef _WIN32
#define _POSIX_C_SOURCE 200809L
#endif
#include <stdint.h>
#include <limits.h>
#include <stddef.h>
#include <stdlib.h>
#include <string.h>
#ifdef _WIN32
#define WIN32_LEAN_AND_MEAN
#include <windows.h>
#else
#include <sys/stat.h>
#include <unistd.h>
#endif
/* Accept only directories owned by this user with private POSIX access.
* Windows inherits the user's cache ACL; reject reparse points so an
* installed cache directory cannot redirect compiler payloads elsewhere. */
uint8_t scriptc_native_private_directory(const uint8_t *bytes, size_t size, uint8_t harden) {
if (size == 0 || memchr(bytes, 0, size) != NULL) return 0;
#ifdef _WIN32
(void)harden;
if (size > INT_MAX) return 0;
int length = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, (const char *)bytes, (int)size, NULL, 0);
if (length <= 0) return 0;
wchar_t *path = malloc(((size_t)length + 1) * sizeof(wchar_t));
if (!path) return 0;
if (MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, (const char *)bytes, (int)size, path, length) != length) { free(path); return 0; }
path[length] = 0;
DWORD attrs = GetFileAttributesW(path);
free(path);
return attrs != INVALID_FILE_ATTRIBUTES && (attrs & FILE_ATTRIBUTE_DIRECTORY) != 0 && (attrs & FILE_ATTRIBUTE_REPARSE_POINT) == 0;
#else
char *path = malloc(size + 1);
if (!path) return 0;
memcpy(path, bytes, size);
path[size] = 0;
struct stat info;
int ok = lstat(path, &info) == 0 && S_ISDIR(info.st_mode) && info.st_uid == getuid();
if (ok && harden && (info.st_mode & 077) != 0) ok = chmod(path, 0700) == 0;
if (ok) ok = lstat(path, &info) == 0 && S_ISDIR(info.st_mode) && info.st_uid == getuid() && (info.st_mode & 077) == 0;
free(path);
return ok;
#endif
}
+6
View File
@@ -0,0 +1,6 @@
{
"ffi_format": 6,
"functions": [
{ "name": "compilerNativePrivateDirectory", "symbol": "scriptc_native_private_directory", "params": ["string", "bool"], "returns": "bool" }
]
}
+7 -1
View File
@@ -18,7 +18,13 @@
"./scriptc.d.ts": "./ambient/scriptc.d.ts",
"./scriptc-overrides.d.ts": "./ambient/scriptc-overrides.d.ts",
"./scriptc-node-fallback.d.ts": "./ambient/scriptc-node-fallback.d.ts",
"./surface-manifest.json": "./surface-manifest.json"
"./surface-manifest.json": "./surface-manifest.json",
"./cli/command": "./dist/cli/command.js",
"./cli/host": "./dist/cli/host.js",
"./cli/paths": "./dist/cli/paths.js",
"./cli/output-options": "./dist/cli/output-options.js",
"./cli/usage": "./dist/cli/usage.js",
"./cli/wasi-runner": "./dist/cli/wasi-runner.js"
},
"files": [
"dist",
+3 -24
View File
@@ -1,7 +1,8 @@
import { resolveBuildCacheRoot } from "./cache-root.js";
export { resolveBuildCacheRoot } from "./cache-root.js";
import { createHash } from "node:crypto";
import { chmod, copyFile, mkdir, readdir, readFile, rename, rm, stat, unlink, utimes, writeFile } from "node:fs/promises";
import { homedir } from "node:os";
import { basename, dirname, join, resolve } from "node:path";
import { basename, dirname, join } from "node:path";
export async function fileExists(path: string): Promise<boolean> {
return stat(path).then(
@@ -44,28 +45,6 @@ export async function installArtifact(
}
}
/** Resolve the build cache without touching the filesystem. Exported from this
* internal module so its platform and override behavior can be pinned directly. */
export function resolveBuildCacheRoot(
env: NodeJS.ProcessEnv = process.env,
platform: NodeJS.Platform = process.platform,
userHome: string = homedir(),
): string | null {
if (env["SCRIPTC_NO_CACHE"] === "1") return null;
const configured = env["SCRIPTC_CACHE_DIR"];
if (configured !== undefined) return configured === "" ? null : resolve(configured);
const xdg = env["XDG_CACHE_HOME"];
if (xdg !== undefined && xdg !== "") return resolve(xdg, "scriptc", "build");
if (platform === "win32") {
const local = env["LOCALAPPDATA"];
if (local !== undefined && local !== "") return resolve(local, "scriptc", "cache", "build");
}
return platform === "darwin"
? resolve(userHome, "Library", "Caches", "scriptc", "build")
: resolve(userHome, ".cache", "scriptc", "build");
}
/** Shared persistent-cache root for compiler-level tiers. The early library
* cache deliberately follows the native cache's activation and hard-disable
* contract, while native compilation retains ownership of toolchain safety. */
@@ -0,0 +1,24 @@
import { homedir } from "node:os";
import { resolve } from "node:path";
/** Resolve the build cache without touching the filesystem. Exported from this
* internal module so its platform and override behavior can be pinned directly. */
export function resolveBuildCacheRoot(
env: NodeJS.ProcessEnv = process.env,
platform: NodeJS.Platform = process.platform,
userHome: string = homedir(),
): string | null {
if (env["SCRIPTC_NO_CACHE"] === "1") return null;
const configured = env["SCRIPTC_CACHE_DIR"];
if (configured !== undefined) return configured === "" ? null : resolve(configured);
const xdg = env["XDG_CACHE_HOME"];
if (xdg !== undefined && xdg !== "") return resolve(xdg, "scriptc", "build");
if (platform === "win32") {
const local = env["LOCALAPPDATA"];
if (local !== undefined && local !== "") return resolve(local, "scriptc", "cache", "build");
}
return platform === "darwin"
? resolve(userHome, "Library", "Caches", "scriptc", "build")
: resolve(userHome, ".cache", "scriptc", "build");
}
@@ -0,0 +1,22 @@
export function linkTraceCandidate(line: string): string[] {
const trimmed = line.trim().replace(/^(?:LOAD|load)\s+/, "");
if (trimmed === "") return [];
const unquoted =
(trimmed.startsWith('"') && trimmed.endsWith('"')) ||
(trimmed.startsWith("'") && trimmed.endsWith("'"))
? trimmed.slice(1, -1)
: trimmed;
const candidates = [unquoted];
const member = unquoted.lastIndexOf("(");
if (member > 0 && unquoted.endsWith(")")) candidates.push(unquoted.slice(0, member));
return candidates;
}
export function driverTraceCandidates(line: string): string[] {
const candidates: string[] = [];
for (const match of line.matchAll(/"((?:\\.|[^"\\])*)"|'([^']*)'|(\S+)/g)) {
const token = (match[1] ?? match[2] ?? match[3] ?? "").replace(/\\(["\\])/g, "$1");
if (token !== "") candidates.push(token);
}
return candidates;
}
+26 -26
View File
@@ -576,7 +576,7 @@ export class LlEmitter {
`}`,
``,
);
defs.push(...dispatchBody);
for (const line of dispatchBody) defs.push(line);
this.declare(`declare ptr @scr_ffi_call_new(ptr, ptr, ptr, ${this.sizeType})`);
this.declare(`declare void @scr_ffi_post(ptr)`);
@@ -1027,7 +1027,7 @@ export class LlEmitter {
// finish_top_level initially notes 13. Replace that hint before exit
// listeners run when a higher-priority verdict was already selected.
lines.push(` call void @scr_exit_code_note(i32 ${exitStatus})`);
lines.push(...exitListenerLines("xp"));
for (const line of exitListenerLines("xp")) lines.push(line);
if (tracksIslandExit) {
lines.push(
` %tla_exit_version_after = call ${this.sizeType} @scr_island_exit_code_version()`,
@@ -1043,7 +1043,7 @@ export class LlEmitter {
` %tla_final_exit = phi i32 [ %tla_listener_exit, %tla_exit_updated ], [ ${exitStatus}, %tla_exit_unchanged ]`,
);
}
lines.push(...topPendingReleases);
for (const line of topPendingReleases) lines.push(line);
lines.push(` ret i32 ${tracksIslandExit ? "%tla_final_exit" : exitStatus}`);
return lines;
};
@@ -1147,8 +1147,8 @@ export class LlEmitter {
`%ScrIslandModule = type { ptr, ptr, ${this.sizeType}, ${this.sizeType}, i32, ptr, ${this.sizeType}, ${this.sizeType} }`,
`%ScrIslandEdge = type { ptr, ptr, ptr, i32 }`,
];
out.push(...shapes.typeDefs);
out.push(...classShapes.typeDefs);
for (const line of shapes.typeDefs) out.push(line);
for (const line of classShapes.typeDefs) out.push(line);
// Thread-instanced library state (abi.instance_per_thread): the
// program TU's mutable globals — module globals, run-once guards, the
// lazily-compiled regex literal caches — and the runtime globals its
@@ -1293,7 +1293,7 @@ export class LlEmitter {
}
out.push(``);
}
out.push(...ffiCallbacks.globals);
for (const line of ffiCallbacks.globals) out.push(line);
if (ffiCallbacks.globals.length > 0) out.push(``);
for (const g of globals) {
const ty = this.llType(g.type);
@@ -1302,16 +1302,16 @@ export class LlEmitter {
out.push(`@${mangleGlobal(g.id)} = internal ${tl}global ${ty} ${zero}${debug ? `, !dbg ${debug}` : ""} ; ${g.name}`);
}
if (globals.length > 0) out.push(``);
out.push(...helpers);
out.push(...ffiCallbacks.defs);
out.push(...shapes.defs);
out.push(...classShapes.defs);
out.push(...classObjDefs);
out.push(...this.walkers.defs);
out.push(...this.dyn.defs);
out.push(...wrappers);
out.push(...asyncDefs);
out.push(...this.resolveThunkDefs);
for (const line of helpers) out.push(line);
for (const line of ffiCallbacks.defs) out.push(line);
for (const line of shapes.defs) out.push(line);
for (const line of classShapes.defs) out.push(line);
for (const line of classObjDefs) out.push(line);
for (const line of this.walkers.defs) out.push(line);
for (const line of this.dyn.defs) out.push(line);
for (const line of wrappers) out.push(line);
for (const line of asyncDefs) out.push(line);
for (const line of this.resolveThunkDefs) out.push(line);
out.push(fnDefs.join("\n\n"), ``);
// main(): scr_init, the program-dependent error-vt interval stamps,
@@ -1367,7 +1367,7 @@ export class LlEmitter {
if (this.mod.lib !== undefined) {
// LIBRARY mode: no @main — the profile-declared external
// symbols specified by the library IR instead.
out.push(...this.emitLibDefs(globals, globalReleaseLines, stamps));
for (const line of this.emitLibDefs(globals, globalReleaseLines, stamps)) out.push(line);
out.push(`attributes #0 = { sanitize_address }`);
if (this.wasi) out.push(`attributes #1 = { sanitize_address presplitcoroutine }`);
if (hasNoInlineRecordClone) out.push(`attributes #2 = { noinline sanitize_address }`);
@@ -1661,7 +1661,7 @@ export class LlEmitter {
// from the poisoned guard and every runtime touch (ratified), so a
// host can read them before init and after a trap. The u64 rides
// i64 two's-complement (LLVM integer constants are signed).
out.push(...emitLibraryIdentityLines(lib.identity, FN_ATTRS));
for (const line of emitLibraryIdentityLines(lib.identity, FN_ATTRS)) out.push(line);
}
if (lib.resultResetSymbol !== null) {
out.push(
@@ -2006,7 +2006,7 @@ export class LlEmitter {
if (fn.async !== true || fn.generator !== undefined) continue;
const { definitions, ret, tr, spawnParams, argPackLines } =
this.emitArgPackAndTrampolinePrologue(fn);
out.push(...definitions);
for (const line of definitions) out.push(line);
this.declare(`declare ptr @scr_fiber_promise(ptr)`);
this.declare(`declare ptr @scr_async_spawn(ptr, ptr)`);
this.needOom();
@@ -2058,7 +2058,7 @@ export class LlEmitter {
}
tr.push(` ret void`, `}`, ``);
}
out.push(...tr);
for (const line of tr) out.push(line);
// Spawn wrapper: pack the args (+1 moves in), spawn the fiber.
const cache = fn.asyncCacheGlobal !== undefined ? mangleGlobal(fn.asyncCacheGlobal) : null;
@@ -2128,9 +2128,9 @@ export class LlEmitter {
`}`,
``,
);
out.push(...sp);
for (const line of sp) out.push(line);
}
out.push(...this.emitGenScaffolding());
for (const line of this.emitGenScaffolding()) out.push(line);
return out;
}
@@ -2154,7 +2154,7 @@ export class LlEmitter {
spawnParams,
argPackLines,
} = this.emitArgPackAndTrampolinePrologue(fn);
out.push(...definitions);
for (const line of definitions) out.push(line);
this.declare(`declare zeroext i1 @scr_exc_genret_pending()`);
this.declare(`declare void @scr_exc_clear()`);
this.declare(`declare ptr @scr_gen_of_fiber(ptr)`);
@@ -2211,7 +2211,7 @@ export class LlEmitter {
}
tr.push(` br label %done`, `done:`, ` ret void`, `}`, ``);
}
out.push(...tr);
for (const line of tr) out.push(line);
let settleAsync: string | null = null;
if (fn.async) {
@@ -2264,7 +2264,7 @@ export class LlEmitter {
}
});
dr.push(` call void @free(ptr %ap)`, ` ret void`, `}`, ``);
out.push(...dr);
for (const line of dr) out.push(line);
// Spawn wrapper: pack the args (+1 moves in), allocate the
// SUSPENDED fiber — nothing runs until the first .next().
@@ -2284,7 +2284,7 @@ export class LlEmitter {
`}`,
``,
);
out.push(...sp);
for (const line of sp) out.push(line);
}
return out;
}
@@ -235,7 +235,7 @@ export function emitSerializationExpr(host: LlvmEmitterContext, e: ExprOf<"jsonS
// clamp/truncate rules); the interned re-indenter rewrites the
// compact text with Node's gap algorithm. Compact temp stays
// frame-owned; the pretty string is a fresh +1.
const indent = (e as { indent?: string }).indent;
const indent = e.indent;
if (indent === undefined || indent === "") return compact;
const rewriter = host.walkers.jsonIndentHelper();
const t2 = B.tmp();
@@ -0,0 +1,155 @@
import { join } from "node:path";
import type { FfiProfile } from "../ffi/ffi-manifest.js";
import { EXTERNAL_OBJECT_ABI_STABILITY, RUNTIME_ABI_MARKER, RUNTIME_ABI_VERSION } from "./runtime-abi.js";
import { executableLinkInputs } from "./link-plan-core.js";
import type { RuntimePackArtifact, RuntimePackArtifacts, RuntimePackManifest } from "./runtime-pack-core.js";
import type { NativeTargetSpec } from "./targets.js";
export interface NativeLinkFeatures {
dynamic: boolean;
regex: boolean;
copying: boolean;
textDecoderLegacy: boolean;
fileHandle: boolean;
fetch: boolean;
netIsland: boolean;
zlib: boolean;
assert: boolean;
inspect: boolean;
dynInvoke: boolean;
dc: boolean;
dynAsync: boolean;
events: boolean;
emitter: boolean;
symbol: boolean;
bigint: boolean;
searchParams: boolean;
qs: boolean;
parseArgs: boolean;
stream: boolean;
net: boolean;
http: boolean;
http2: boolean;
dgram: boolean;
watch: boolean;
foreignFfi: boolean;
nodeTest: boolean;
tls: boolean;
tlsCa: boolean;
}
export interface NativeLinkInfo {
schema: "scriptc.native-link-info.v1";
format: 1;
compiler_version: string;
object_abi: {
stability: "experimental";
compatibility: "exact-runtime-version";
};
target: {
name: NativeTargetSpec["name"];
llvm_triple: NativeTargetSpec["llvmTriple"];
architecture: NativeTargetSpec["architecture"];
object_format: NativeTargetSpec["objectFormat"];
minimum_os: NativeTargetSpec["minimumOs"];
relocation_model: NativeTargetSpec["relocationModel"];
};
program: {
object: string;
entry_symbol: "main";
undefined_runtime_symbol_prefix: "scr_";
};
runtime_abi: {
version: typeof RUNTIME_ABI_VERSION;
marker: typeof RUNTIME_ABI_MARKER;
};
runtime_pack: {
kind: "precompiled";
package: string;
version: string;
root: string;
path_base: "runtime_pack.root";
flavor: "release" | "dev";
objects: RuntimePackArtifact[];
archives: RuntimePackArtifact[];
};
ffi: {
format: number | null;
symbols: string[];
libraries: string[];
};
link: {
input_order: string[];
driver_flags: string[];
system_libraries: string[];
frameworks: string[];
};
}
/** Format the same external-link contract after either host verifies a pack. */
export function formatNativeLinkInfo(options: {
programObject: string;
target: NativeTargetSpec;
ffi: FfiProfile | null;
}, compilerVersion: string, pack: {
root: string;
manifest: RuntimePackManifest;
selected: RuntimePackArtifacts;
flavor: "release" | "dev";
}): NativeLinkInfo {
const ffiLibraries = options.ffi?.libraries ?? [];
const plan = executableLinkInputs({
target: options.target, programObject: options.programObject,
ffiLibraries, ffiSystemLibraries: options.ffi?.systemLibraries ?? [],
ffiFrameworks: options.ffi?.frameworks ?? [], runtimeObjects: pack.selected.runtime.map((artifact) => join(pack.root, artifact.path)),
runtimeArchives: pack.selected.archives.map((artifact) => join(pack.root, artifact.path)), runtimeSystemLibraries: pack.selected.systemLibraries,
optimization: pack.flavor,
});
return {
schema: "scriptc.native-link-info.v1",
format: 1,
compiler_version: compilerVersion,
object_abi: {
stability: EXTERNAL_OBJECT_ABI_STABILITY,
compatibility: "exact-runtime-version",
},
target: {
name: options.target.name,
llvm_triple: options.target.llvmTriple,
architecture: options.target.architecture,
object_format: options.target.objectFormat,
minimum_os: options.target.minimumOs,
relocation_model: options.target.relocationModel,
},
program: {
object: options.programObject,
entry_symbol: "main",
undefined_runtime_symbol_prefix: "scr_",
},
runtime_abi: {
version: RUNTIME_ABI_VERSION,
marker: RUNTIME_ABI_MARKER,
},
runtime_pack: {
kind: "precompiled",
package: pack.manifest.package,
version: pack.manifest.version,
root: pack.root,
path_base: "runtime_pack.root",
flavor: pack.flavor,
objects: pack.selected.runtime,
archives: pack.selected.archives,
},
ffi: {
format: options.ffi?.ffiFormat ?? null,
symbols: options.ffi?.functions.filter((fn) => !fn.callbackOperation).map((fn) => fn.symbol) ?? [],
libraries: [...ffiLibraries],
},
link: {
input_order: plan.inputs,
driver_flags: plan.driverFlags,
system_libraries: plan.systemLibraries,
frameworks: [...(options.ffi?.frameworks ?? [])],
},
};
}
@@ -1,92 +1,10 @@
import { formatNativeLinkInfo, type NativeLinkInfo, type NativeLinkFeatures } from "./native-link-info-core.js";
export type { NativeLinkInfo, NativeLinkFeatures } from "./native-link-info-core.js";
import type { FfiProfile } from "../ffi/ffi-manifest.js";
import { compilerReleaseVersion } from "../library/sidecar.js";
import { EXTERNAL_OBJECT_ABI_STABILITY, RUNTIME_ABI_MARKER, RUNTIME_ABI_VERSION } from "./runtime-abi.js";
import { executableLinkInputs } from "./link-plan-core.js";
import { loadRuntimePack } from "./runtime-pack.js";
import type { RuntimePackArtifact } from "./runtime-pack-core.js";
import type { NativeTargetSpec } from "./targets.js";
export interface NativeLinkFeatures {
dynamic: boolean;
regex: boolean;
copying: boolean;
textDecoderLegacy: boolean;
fileHandle: boolean;
fetch: boolean;
netIsland: boolean;
zlib: boolean;
assert: boolean;
inspect: boolean;
dynInvoke: boolean;
dc: boolean;
dynAsync: boolean;
events: boolean;
emitter: boolean;
symbol: boolean;
bigint: boolean;
searchParams: boolean;
qs: boolean;
parseArgs: boolean;
stream: boolean;
net: boolean;
http: boolean;
http2: boolean;
dgram: boolean;
watch: boolean;
foreignFfi: boolean;
nodeTest: boolean;
tls: boolean;
tlsCa: boolean;
}
export interface NativeLinkInfo {
schema: "scriptc.native-link-info.v1";
format: 1;
compiler_version: string;
object_abi: {
stability: "experimental";
compatibility: "exact-runtime-version";
};
target: {
name: NativeTargetSpec["name"];
llvm_triple: NativeTargetSpec["llvmTriple"];
architecture: NativeTargetSpec["architecture"];
object_format: NativeTargetSpec["objectFormat"];
minimum_os: NativeTargetSpec["minimumOs"];
relocation_model: NativeTargetSpec["relocationModel"];
};
program: {
object: string;
entry_symbol: "main";
undefined_runtime_symbol_prefix: "scr_";
};
runtime_abi: {
version: typeof RUNTIME_ABI_VERSION;
marker: typeof RUNTIME_ABI_MARKER;
};
runtime_pack: {
kind: "precompiled";
package: string;
version: string;
root: string;
path_base: "runtime_pack.root";
flavor: "release" | "dev";
objects: RuntimePackArtifact[];
archives: RuntimePackArtifact[];
};
ffi: {
format: number | null;
symbols: string[];
libraries: string[];
};
link: {
input_order: string[];
driver_flags: string[];
system_libraries: string[];
frameworks: string[];
};
}
export async function createNativeLinkInfo(options: {
programObject: string;
target: NativeTargetSpec;
@@ -100,59 +18,8 @@ export async function createNativeLinkInfo(options: {
target: options.target, features: options.features,
optimization: options.optimization ?? "release", ...(options.env === undefined ? {} : { env: options.env }),
});
const ffiLibraries = options.ffi?.libraries ?? [];
const plan = executableLinkInputs({
target: options.target, programObject: options.programObject,
ffiLibraries, ffiSystemLibraries: options.ffi?.systemLibraries ?? [],
ffiFrameworks: options.ffi?.frameworks ?? [], runtimeObjects: pack.runtimeObjects,
runtimeArchives: pack.archives, runtimeSystemLibraries: pack.systemLibraries,
optimization: pack.flavor,
return formatNativeLinkInfo(options, compilerReleaseVersion(), {
root: pack.root, manifest: pack.manifest, flavor: pack.flavor,
selected: { features: pack.features, runtime: pack.selectedRuntimeArtifacts, archives: pack.selectedArchiveArtifacts, systemLibraries: pack.systemLibraries },
});
return {
schema: "scriptc.native-link-info.v1",
format: 1,
compiler_version: compilerReleaseVersion(),
object_abi: {
stability: EXTERNAL_OBJECT_ABI_STABILITY,
compatibility: "exact-runtime-version",
},
target: {
name: options.target.name,
llvm_triple: options.target.llvmTriple,
architecture: options.target.architecture,
object_format: options.target.objectFormat,
minimum_os: options.target.minimumOs,
relocation_model: options.target.relocationModel,
},
program: {
object: options.programObject,
entry_symbol: "main",
undefined_runtime_symbol_prefix: "scr_",
},
runtime_abi: {
version: RUNTIME_ABI_VERSION,
marker: RUNTIME_ABI_MARKER,
},
runtime_pack: {
kind: "precompiled",
package: pack.manifest.package,
version: pack.manifest.version,
root: pack.root,
path_base: "runtime_pack.root",
flavor: pack.flavor,
objects: pack.selectedRuntimeArtifacts,
archives: pack.selectedArchiveArtifacts,
},
ffi: {
format: options.ffi?.ffiFormat ?? null,
symbols: options.ffi?.functions.filter((fn) => !fn.callbackOperation).map((fn) => fn.symbol) ?? [],
libraries: [...ffiLibraries],
},
link: {
input_order: plan.inputs,
driver_flags: plan.driverFlags,
system_libraries: plan.systemLibraries,
frameworks: [...(options.ffi?.frameworks ?? [])],
},
};
}
@@ -1,3 +1,8 @@
import { driverTraceCandidates, linkTraceCandidate } from "./link-trace.js";
import { toolchainEnvironmentCachePolicy, toolchainEnvironmentFingerprint } from "./toolchain-environment.js";
export { toolchainEnvironmentCachePolicy, toolchainEnvironmentFingerprint, type ToolchainEnvironmentCachePolicy } from "./toolchain-environment.js";
import { IPHONEOS_MIN_VERSION, ANDROID_MIN_API, isIosTarget, isAndroidTarget, isMobileTarget, mobileLibraryTarget, mobileTargetRefusal, configuredTargetPlatform } from "./target-platform.js";
export { IPHONEOS_MIN_VERSION, ANDROID_MIN_API, isIosTarget, isAndroidTarget, isMobileTarget, mobileLibraryTarget, mobileTargetRefusal, configuredTargetPlatform } from "./target-platform.js";
import { InternalCompilerError } from "../errors.js";
import { execFile, spawnSync } from "node:child_process";
import { createHash, randomUUID } from "node:crypto";
@@ -123,114 +128,6 @@ export function executableSectionEliminationFlags(platform: string): {
}
}
/** Environment variables consumed by clang, its linker/subtools, or the
* platform SDK selection. They are implicit command-line inputs: changing one
* must never reuse an artifact produced under the old toolchain posture. */
const TOOLCHAIN_ENV_KEYS = [
"COMPILER_PATH",
"GCC_EXEC_PREFIX",
"CPATH",
"C_INCLUDE_PATH",
"CPLUS_INCLUDE_PATH",
"OBJC_INCLUDE_PATH",
"OBJCPLUS_INCLUDE_PATH",
"LIBRARY_PATH",
"LD_LIBRARY_PATH",
"LD_RUN_PATH",
"DYLD_LIBRARY_PATH",
"DYLD_FRAMEWORK_PATH",
"DYLD_FALLBACK_LIBRARY_PATH",
"DYLD_FALLBACK_FRAMEWORK_PATH",
"SDKROOT",
"DEVELOPER_DIR",
"MACOSX_DEPLOYMENT_TARGET",
"IPHONEOS_DEPLOYMENT_TARGET",
"TVOS_DEPLOYMENT_TARGET",
"WATCHOS_DEPLOYMENT_TARGET",
"DRIVERKIT_DEPLOYMENT_TARGET",
"XROS_DEPLOYMENT_TARGET",
"CCC_OVERRIDE_OPTIONS",
"CCC_ADD_ARGS",
"CLANG_CONFIG_FILE_SYSTEM_DIR",
"CLANG_CONFIG_FILE_USER_DIR",
"CC",
"CFLAGS",
"CPPFLAGS",
"LDFLAGS",
"AR",
"RANLIB",
"CMAKE_GENERATOR",
"CMAKE_TOOLCHAIN_FILE",
"ZIG_LIB_DIR",
"ZIG_LIBC",
"SOURCE_DATE_EPOCH",
"ZERO_AR_DATE",
"LANG",
"LC_ALL",
"LC_CTYPE",
] as const;
/** Toolchain variables whose values name mutable files/directories consumed
* while compiling a TU (or can inject arbitrary compiler options). Hashing the
* value is insufficient: a header, SDK, config, compiler helper, or loaded
* dylib can change in place while the spelling remains stable. In that posture
* neither complete artifacts nor per-TU runtime objects are safe to reuse. */
const MUTABLE_COMPILE_ENV_KEYS = [
"COMPILER_PATH",
"GCC_EXEC_PREFIX",
"CPATH",
"C_INCLUDE_PATH",
"CPLUS_INCLUDE_PATH",
"OBJC_INCLUDE_PATH",
"OBJCPLUS_INCLUDE_PATH",
"LD_LIBRARY_PATH",
"DYLD_LIBRARY_PATH",
"DYLD_FRAMEWORK_PATH",
"DYLD_FALLBACK_LIBRARY_PATH",
"DYLD_FALLBACK_FRAMEWORK_PATH",
"SDKROOT",
"DEVELOPER_DIR",
"CCC_OVERRIDE_OPTIONS",
"CCC_ADD_ARGS",
"CLANG_CONFIG_FILE_SYSTEM_DIR",
"CLANG_CONFIG_FILE_USER_DIR",
// `zig cc` resolves its bundled headers/runtime through ZIG_LIB_DIR and a
// caller-selected native libc description through ZIG_LIBC. Both values name
// mutable compiler inputs whose contents can change behind a stable path.
"ZIG_LIB_DIR",
"ZIG_LIBC",
] as const;
/** These variables only redirect link-time inputs. Runtime objects remain
* reusable, but a complete executable could otherwise retain a library that
* was rebuilt in place behind the same search-path spelling. */
const MUTABLE_LINK_ENV_KEYS = ["LIBRARY_PATH", "LD_RUN_PATH"] as const;
export interface ToolchainEnvironmentCachePolicy {
completeArtifacts: boolean;
runtimeObjects: boolean;
}
export function toolchainEnvironmentCachePolicy(
env: NodeJS.ProcessEnv = process.env,
): ToolchainEnvironmentCachePolicy {
const mutableCompileInput = MUTABLE_COMPILE_ENV_KEYS.some((name) => env[name] !== undefined);
const mutableLinkInput = MUTABLE_LINK_ENV_KEYS.some((name) => env[name] !== undefined);
return {
completeArtifacts: !mutableCompileInput && !mutableLinkInput,
runtimeObjects: !mutableCompileInput,
};
}
export function toolchainEnvironmentFingerprint(env: NodeJS.ProcessEnv = process.env): string {
const hash = createHash("sha256").update("toolchain-env-v1\0");
for (const name of TOOLCHAIN_ENV_KEYS) {
const value = env[name];
hash.update(name).update(value === undefined ? "\0unset\0" : "\0set\0").update(value ?? "").update("\0");
}
return hash.digest("hex");
}
/** Inputs that can change which native tool/runtime implementation an
* executable build selects before compileC has a chance to rediscover it.
* The early whole-program cache keys this exact posture before restoring a
@@ -645,59 +542,6 @@ export function isZigDriver(driver: Pick<CcDriver, "argv">): boolean {
* embedder's side of the contract: Xcode links iOS archives against the
* selected SDK, and Gradle/NDK builds link Android archives against the
* API-26+ bionic stubs. */
export const IPHONEOS_MIN_VERSION = "15.0";
export const ANDROID_MIN_API = 26;
const MOBILE_LIBRARY_TARGETS = [
"aarch64-apple-ios",
"aarch64-apple-ios-simulator",
"aarch64-linux-android",
] as const;
export function isIosTarget(target: string | null): boolean {
return target === "aarch64-apple-ios" || target === "aarch64-apple-ios-simulator";
}
export function isAndroidTarget(target: string | null): boolean {
return target === "aarch64-linux-android";
}
export function isMobileTarget(target: string | null): boolean {
return isIosTarget(target) || isAndroidTarget(target);
}
/** The canonical mobile triple SCRIPTC_TARGET selects, or null when the
* environment names none. Pure string inspection — safe to consult before
* any toolchain discovery runs. */
export function mobileLibraryTarget(env: NodeJS.ProcessEnv = process.env): string | null {
const target = env["SCRIPTC_TARGET"] ?? "";
return isMobileTarget(target) ? target : null;
}
/** The admission verdict for a mobile-family triple: null when the spelling
* and host pairing are supported, otherwise the refusal text (the same text
* resolveCc throws and compileLibrary reports as SC3002). Pure string/host
* inspection — no discovery, no subprocess. */
export function mobileTargetRefusal(
target: string,
hostPlatform: NodeJS.Platform = process.platform,
): string | null {
if (isIosTarget(target)) {
return hostPlatform === "darwin"
? null
: `${target} library archives build on macOS hosts only (the Apple iOS SDK sysroot and Mach-O symbol localization live there); this host is ${hostPlatform}`;
}
if (isAndroidTarget(target)) return null;
// A near-miss mobile spelling must refuse with the supported set named,
// never reach zig with no sysroot wired (the compile would fail on the
// first libc header) or produce an artifact for an unverified device
// class.
if (/(?:^|-)(?:ios|tvos|watchos|visionos|android)/.test(target)) {
return `unsupported mobile target '${target}' (supported: ${MOBILE_LIBRARY_TARGETS.join(", ")})`;
}
return null;
}
/** The Apple SDK root for one mobile platform, discovered through xcrun the
* way Xcode's own build system selects it. Memoized per SDK name and
* selection environment: production rediscovers per process, and the two
@@ -906,34 +750,6 @@ function isMuslTarget(driver: Pick<CcDriver, "target">): boolean {
* analyze(): the FRONTEND consults it too (path.sep / os.EOL literals and
* the path-module binding follow the target — a win32 triple compiles
* Node-on-Windows semantics, path.win32 backing the bare module). */
export function configuredTargetPlatform(
env: NodeJS.ProcessEnv = process.env,
hostPlatform: NodeJS.Platform = process.platform,
): string {
const target = env["SCRIPTC_TARGET"] ?? "";
if (target === "") return hostPlatform;
if (target === "wasm32-wasi") return "wasi";
if (target.includes("wasi")) {
throw new Error(`unsupported WASI target '${target}' (supported: wasm32-wasi)`);
}
// iOS is a darwin-family target: Mach-O objects, ld64 localization,
// POSIX path/EOL semantics. Android falls to the linux arm below —
// bionic is a linux libc and its archives are ordinary ELF.
if (isIosTarget(target)) return "darwin";
if (isAndroidTarget(target)) return "linux";
if (/(?:^|-)(?:ios|tvos|watchos|visionos|android)/.test(target)) {
throw new Error(
`unsupported mobile target '${target}' (supported: ${MOBILE_LIBRARY_TARGETS.join(", ")})`,
);
}
if (target.includes("linux")) return "linux";
if (target.includes("windows")) return "win32";
if (target.includes("macos") || target.includes("darwin")) return "darwin";
throw new Error(
`unsupported target '${target}' (supported OS families: linux, windows, macos/darwin, wasm32-wasi)`,
);
}
export function targetPlatform(driver: CcDriver): string {
if (driver.target === null) return process.platform;
return configuredTargetPlatform({ SCRIPTC_TARGET: driver.target });
@@ -2980,29 +2796,6 @@ function implicitToolchainFingerprint(
);
}
function linkTraceCandidate(line: string): string[] {
const trimmed = line.trim().replace(/^(?:LOAD|load)\s+/, "");
if (trimmed === "") return [];
const unquoted =
(trimmed.startsWith('"') && trimmed.endsWith('"')) ||
(trimmed.startsWith("'") && trimmed.endsWith("'"))
? trimmed.slice(1, -1)
: trimmed;
const candidates = [unquoted];
const member = unquoted.lastIndexOf("(");
if (member > 0 && unquoted.endsWith(")")) candidates.push(unquoted.slice(0, member));
return candidates;
}
function driverTraceCandidates(line: string): string[] {
const candidates: string[] = [];
for (const match of line.matchAll(/"((?:\\.|[^"\\])*)"|'([^']*)'|(\S+)/g)) {
const token = (match[1] ?? match[2] ?? match[3] ?? "").replace(/\\(["\\])/g, "$1");
if (token !== "") candidates.push(token);
}
return candidates;
}
async function existingDriverTracePaths(
output: string,
cwd: string,
+21 -16
View File
@@ -1,16 +1,12 @@
/** Native tool invocation without a JavaScript host or shell command construction. */
import { spawnSync } from "node:child_process";
import { execFileSync } from "node:child_process";
import { readFileSync, statSync } from "node:fs";
import type { NativeHelperSpec, NativeTargetSpec } from "./targets.js";
import { validateNativeCodegenVersion } from "./native-codegen-core.js";
import { validateNativeCodegenVersion, type NativeCodegenOutputKind } from "./native-codegen-core.js";
export function runNativeTool(executable: string, args: string[]): string {
const result = spawnSync(executable, args, { encoding: "utf8" });
if (result.error) throw new Error(`${executable}: ${result.error.message}`);
if (result.status !== 0) {
throw new Error(`${executable} failed (${result.signal ?? String(result.status)}): ${result.stderr.trim()}`);
}
return result.stdout;
export function runNativeTool(executable: string, args: string[], cwd?: string, env?: NodeJS.ProcessEnv): string {
return execFileSync(executable, args, { encoding: "utf8", stdio: "pipe", maxBuffer: 64 * 1024 * 1024,
cwd: cwd ?? process.cwd(), env: env ?? process.env });
}
export function requireNativeArtifact(path: string): void {
@@ -28,6 +24,22 @@ export function emitNativeObject(options: {
outputPath: string;
sourcePath: string;
optimization: "release" | "dev";
outputKind?: NativeCodegenOutputKind;
}): void {
verifyNativeHelper(options);
runNativeTool(options.executable, [
"emit", "--input", options.inputPath, "--output", options.outputPath, "--filetype", options.outputKind ?? "obj",
"--target", options.target.llvmTriple, "--opt-level", options.optimization === "dev" ? "0" : "2",
"--relocation-model", options.target.relocationModel, "--diagnostic-format", "json",
"--source-path", options.sourcePath,
]);
requireNativeArtifact(options.outputPath);
}
/** Verify installed identity even when a caller can reuse a cached object. */
export function verifyNativeHelper(options: {
executable: string; packageRoot: string; compilerVersion: string;
target: NativeTargetSpec; helper: NativeHelperSpec;
}): void {
const identity = JSON.parse(readFileSync(options.packageRoot + "/package.json", "utf8")) as { name: string; version: string };
if (identity.name !== options.helper.packageName || identity.version !== options.compilerVersion) {
@@ -35,11 +47,4 @@ export function emitNativeObject(options: {
}
const version = JSON.parse(runNativeTool(options.executable, ["version", "--format=json"])) as Record<string, unknown>;
validateNativeCodegenVersion(version, options.target, options.helper, options.compilerVersion);
runNativeTool(options.executable, [
"emit", "--input", options.inputPath, "--output", options.outputPath, "--filetype", "obj",
"--target", options.target.llvmTriple, "--opt-level", options.optimization === "dev" ? "0" : "2",
"--relocation-model", options.target.relocationModel, "--diagnostic-format", "json",
"--source-path", options.sourcePath,
]);
requireNativeArtifact(options.outputPath);
}
@@ -199,7 +199,10 @@ export function localizeElfObject(object: Uint8Array, keep: ReadonlySet<string>)
for (let i = 0; i < sections.length; i++) sectionMap.push(sectionKept[i] === true ? next++ : -1);
}
for (let i = 0; i < sections.length; i++) {
if (sectionKept[i] === true) sections[i]!.flags &= ~SHF_GROUP;
if (sectionKept[i] === true) {
const section = sections[i]!;
section.flags = section.flags & ~SHF_GROUP;
}
}
// Demote and drop decisions per symbol. A symbol anchored to a dropped
@@ -228,7 +231,9 @@ export function localizeElfObject(object: Uint8Array, keep: ReadonlySet<string>)
}
const symbolOrder = [...localOrder, ...globalOrder];
const symbolMap: number[] = new Array<number>(symCount).fill(-1);
symbolOrder.forEach((oldIndex, newIndex) => (symbolMap[oldIndex] = newIndex));
symbolOrder.forEach((oldIndex, newIndex) => {
symbolMap[oldIndex] = newIndex;
});
const newSymtabData = new Uint8Array(symbolOrder.length * 24);
const newSymtabView = new DataView(newSymtabData.buffer);
@@ -412,7 +417,8 @@ function parseCoff(object: Uint8Array, label: string): CoffObject {
const sectionName = (raw: Uint8Array): string => {
if (raw[0] === 0x2f /* '/' */) {
const spelled = textDecoder.decode(raw.subarray(1)).replace(/\0+$/, "").trim();
const offset = Number.parseInt(spelled, 10);
const decimal = /^\d+/.exec(spelled);
const offset = decimal === null ? NaN : Number(decimal[0]);
if (!Number.isFinite(offset)) fail(`${label}: malformed long section name`);
return readCString(strtab, offset);
}
@@ -563,7 +569,7 @@ export function mergeAndLocalizeCoffObjects(
];
for (const object of objects) {
if (object.machine !== IMAGE_FILE_MACHINE_AMD64) {
fail(`${object.label}: unsupported COFF machine 0x${object.machine.toString(16)}`);
fail(`${object.label}: unsupported COFF machine ${object.machine}`);
}
}
@@ -601,7 +607,7 @@ export function mergeAndLocalizeCoffObjects(
for (const sym of object.symbols) {
if (!coffIsUndefined(sym)) continue;
if (selectedDefinitions.has(sym.name)) continue;
for (const candidate of definers.get(sym.name) ?? []) {
for (const candidate of definers.get(sym.name) ?? new Array<number>()) {
if (included[candidate] !== true) {
included[candidate] = true;
addDefinitions(objects[candidate]!);
@@ -8,7 +8,7 @@ import type { NativeLinkFeatures } from "./native-link-info.js";
import type { NativeTargetSpec } from "./targets.js";
import {
RuntimePackError, parseRuntimePackManifest, selectRuntimePackArtifacts, validateRuntimePackIdentity,
type RuntimePackArtifact,
type RuntimePackArtifact, type RuntimePackArtifacts, type RuntimePackManifest, type RuntimePackMode,
} from "./runtime-pack-core.js";
export interface NativeRuntimePack {
@@ -17,6 +17,28 @@ export interface NativeRuntimePack {
systemLibraries: string[];
}
export interface NativeRuntimeSelection {
root: string;
manifest: RuntimePackManifest;
packageText: string;
manifestText: string;
selected: RuntimePackArtifacts;
flavor: "release" | "dev";
}
export function selectNativeRuntimePack(
root: string, target: NativeTargetSpec, compilerVersion: string,
features: NativeLinkFeatures, flavor: "release" | "dev", mode: RuntimePackMode = "executable",
): NativeRuntimeSelection {
const packageText = readFileSync(join(root, "package.json"), "utf8");
const manifestText = readFileSync(join(root, "runtime-pack.json"), "utf8");
const identity = JSON.parse(packageText) as { name?: string; version?: string };
const manifest = parseRuntimePackManifest(JSON.parse(manifestText));
validateRuntimePackIdentity(manifest, identity.name, identity.version, target, compilerVersion);
const selected = selectRuntimePackArtifacts(manifest, features, flavor, process.env, mode);
return { root, manifest, packageText, manifestText, selected, flavor };
}
function stageArtifact(root: string, stage: string, artifact: RuntimePackArtifact): string {
const source = join(root, artifact.path);
const destination = join(stage, artifact.path);
@@ -48,15 +70,15 @@ export function stageNativeRuntimePack(
compilerVersion: string,
features: NativeLinkFeatures,
flavor: "release" | "dev",
mode: RuntimePackMode = "executable",
): NativeRuntimePack {
return stageNativeRuntimeSelection(selectNativeRuntimePack(root, target, compilerVersion, features, flavor, mode), stageRoot);
}
export function stageNativeRuntimeSelection(selection: NativeRuntimeSelection, stageRoot: string): NativeRuntimePack {
const { root, manifest, selected, packageText, manifestText } = selection;
const packagePath = join(root, "package.json");
const manifestPath = join(root, "runtime-pack.json");
const packageText = readFileSync(packagePath, "utf8");
const manifestText = readFileSync(manifestPath, "utf8");
const identity = JSON.parse(packageText) as { name?: string; version?: string };
const manifest = parseRuntimePackManifest(JSON.parse(manifestText));
validateRuntimePackIdentity(manifest, identity.name, identity.version, target, compilerVersion);
const selected = selectRuntimePackArtifacts(manifest, features, flavor);
const runtimeObjects = selected.runtime.map((artifact) => stageArtifact(root, stageRoot, artifact));
const archives = selected.archives.map((artifact) => stageArtifact(root, stageRoot, artifact));
for (const license of manifest.licenses) {
@@ -0,0 +1,117 @@
import type { LlvmUnsupportedError } from "./llvm/emitter.js";
import type { ScrDiagnostic } from "../diagnostics/diagnostic.js";
import { moduleUsesFetch, moduleEmbedsBuiltin, type IrModule, type SrcLoc } from "../ir/ir.js";
/** The LLVM backend's tier refusal as a diagnostic. SC3xxx = backend
* coverage (the program is fine — this backend doesn't compile it yet);
* the parenthesized kind tag is machine-readable for the differential
* harness's histogram. */
export function llvmRefusalDiag(err: LlvmUnsupportedError, entryPath: string): ScrDiagnostic {
return {
code: "SC3001",
message: err.message,
loc: err.loc ?? { file: entryPath, start: 0, end: 0 },
};
}
/** A valid program surface that the selected execution target cannot host.
* SC3xxx stays the backend/target-coverage family: source semantics are
* valid, but this target deliberately refuses them instead of emitting a
* binary that traps later. */
export function targetRefusalDiag(target: string, surface: string, loc: SrcLoc): ScrDiagnostic {
return {
code: "SC3002",
message: `${target} target does not support ${surface}`,
loc,
};
}
/** APIs that require host capabilities absent from portable WASI Preview 1.
* These are target diagnostics, not backend-tier gaps: the same language IR
* (including async, generators, and the dynamic island) is otherwise valid.
* Keep the fine-grained walk first so diagnostics point at the API use; the
* embedded-module checks are the entry-anchored safety net for island code. */
export function moduleWasiUnavailableSurface(mod: IrModule): { surface: string; loc: SrcLoc } | null {
const entryLoc: SrcLoc = { file: mod.sourceFile, start: 0, end: 0 };
const prefixes: readonly (readonly [string, string])[] = [
["cp.", "child processes (WASI Preview 1 has no process-spawning API)"],
["child.", "child processes (WASI Preview 1 has no process-spawning API)"],
["spawnRes.", "child processes (WASI Preview 1 has no process-spawning API)"],
["net.", "network sockets (WASI Preview 1 has no socket API)"],
["http.", "network sockets (WASI Preview 1 has no socket API)"],
["https.", "network sockets (WASI Preview 1 has no socket API)"],
["http2.", "network sockets (WASI Preview 1 has no socket API)"],
["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)"],
["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)"],
["watcher.", "filesystem watching (WASI Preview 1 has no notification API)"],
];
const kinds: ReadonlyMap<string, string> = new Map([
["child", "child processes (WASI Preview 1 has no process-spawning API)"],
["spawnRes", "child processes (WASI Preview 1 has no process-spawning API)"],
["childStream", "child processes (WASI Preview 1 has no process-spawning API)"],
["childWriter", "child processes (WASI Preview 1 has no process-spawning API)"],
["netServer", "network sockets (WASI Preview 1 has no socket API)"],
["netSocket", "network sockets (WASI Preview 1 has no socket API)"],
["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)"],
["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)"],
["httpClientReq", "network sockets (WASI Preview 1 has no socket API)"],
["secureCtx", "network sockets (WASI Preview 1 has no socket API)"],
]);
let found: { surface: string; loc: SrcLoc } | null = null;
const visit = (value: unknown, inheritedLoc: SrcLoc): void => {
if (found !== null || value === null || typeof value !== "object") return;
if (Array.isArray(value)) {
for (const item of value) visit(item, inheritedLoc);
return;
}
const node = value as { kind?: unknown; fn?: unknown; loc?: SrcLoc };
const loc = node.loc ?? inheritedLoc;
if (typeof node.kind === "string") {
const kindSurface = kinds.get(node.kind);
if (kindSurface !== undefined) {
found = { surface: kindSurface, loc };
return;
}
if (node.kind === "libCall" && typeof node.fn === "string") {
if (node.fn === "process.kill" || node.fn === "process.killNum" ||
node.fn === "process.onSignal" || node.fn === "process.offSignal") {
found = { surface: "OS signals (WASI Preview 1 has no signal API)", loc };
return;
}
if (node.fn === "os.networkInterfaces") {
found = { surface: "network-interface enumeration (WASI Preview 1 has no interface API)", loc };
return;
}
for (const [prefix, surface] of prefixes) {
if (node.fn.startsWith(prefix)) {
found = { surface, loc };
return;
}
}
}
}
for (const key of Object.keys(value)) {
visit((value as Record<string, unknown>)[key], loc);
}
};
visit(mod, entryLoc);
if (found !== null) return found;
if (moduleUsesFetch(mod)) {
return { surface: "network-backed fetch (WASI Preview 1 has no socket API)", loc: entryLoc };
}
for (const builtin of ["node:http", "node:https", "node:net", "node:tls"]) {
if (moduleEmbedsBuiltin(mod, builtin)) {
return { surface: `${builtin} networking (WASI Preview 1 has no socket API)`, loc: entryLoc };
}
}
return null;
}
@@ -0,0 +1,80 @@
export const IPHONEOS_MIN_VERSION = "15.0";
export const ANDROID_MIN_API = 26;
const MOBILE_LIBRARY_TARGETS = [
"aarch64-apple-ios",
"aarch64-apple-ios-simulator",
"aarch64-linux-android",
] as const;
export function isIosTarget(target: string | null): boolean {
return target === "aarch64-apple-ios" || target === "aarch64-apple-ios-simulator";
}
export function isAndroidTarget(target: string | null): boolean {
return target === "aarch64-linux-android";
}
export function isMobileTarget(target: string | null): boolean {
return isIosTarget(target) || isAndroidTarget(target);
}
/** The canonical mobile triple SCRIPTC_TARGET selects, or null when the
* environment names none. Pure string inspection — safe to consult before
* any toolchain discovery runs. */
export function mobileLibraryTarget(env: NodeJS.ProcessEnv = process.env): string | null {
const target = env["SCRIPTC_TARGET"] ?? "";
return isMobileTarget(target) ? target : null;
}
/** The admission verdict for a mobile-family triple: null when the spelling
* and host pairing are supported, otherwise the refusal text (the same text
* resolveCc throws and compileLibrary reports as SC3002). Pure string/host
* inspection — no discovery, no subprocess. */
export function mobileTargetRefusal(
target: string,
hostPlatform: NodeJS.Platform = process.platform,
): string | null {
if (isIosTarget(target)) {
return hostPlatform === "darwin"
? null
: `${target} library archives build on macOS hosts only (the Apple iOS SDK sysroot and Mach-O symbol localization live there); this host is ${hostPlatform}`;
}
if (isAndroidTarget(target)) return null;
// A near-miss mobile spelling must refuse with the supported set named,
// never reach zig with no sysroot wired (the compile would fail on the
// first libc header) or produce an artifact for an unverified device
// class.
if (/(?:^|-)(?:ios|tvos|watchos|visionos|android)/.test(target)) {
return `unsupported mobile target '${target}' (supported: ${MOBILE_LIBRARY_TARGETS.join(", ")})`;
}
return null;
}
export function configuredTargetPlatform(
env: NodeJS.ProcessEnv = process.env,
hostPlatform: NodeJS.Platform = process.platform,
): string {
const target = env["SCRIPTC_TARGET"] ?? "";
if (target === "") return hostPlatform;
if (target === "wasm32-wasi") return "wasi";
if (target.includes("wasi")) {
throw new Error(`unsupported WASI target '${target}' (supported: wasm32-wasi)`);
}
// iOS is a darwin-family target: Mach-O objects, ld64 localization,
// POSIX path/EOL semantics. Android falls to the linux arm below —
// bionic is a linux libc and its archives are ordinary ELF.
if (isIosTarget(target)) return "darwin";
if (isAndroidTarget(target)) return "linux";
if (/(?:^|-)(?:ios|tvos|watchos|visionos|android)/.test(target)) {
throw new Error(
`unsupported mobile target '${target}' (supported: ${MOBILE_LIBRARY_TARGETS.join(", ")})`,
);
}
if (target.includes("linux")) return "linux";
if (target.includes("windows")) return "win32";
if (target.includes("macos") || target.includes("darwin")) return "darwin";
throw new Error(
`unsupported target '${target}' (supported OS families: linux, windows, macos/darwin, wasm32-wasi)`,
);
}
+19 -12
View File
@@ -439,6 +439,24 @@ function requestedTarget(
}
}
/** Native distributions already know their host ABI, including Linux libc. */
export function selectNativeTarget(
raw: string, host: NativeTargetSpec, hostPlatform: string = process.platform,
): NativeTargetSpec | null {
const target = requestedTarget(raw, host, hostPlatform as NodeJS.Platform);
if (target === null) return null;
if (target.platform === "linux" && target.name !== host.name && target.name !== "android-arm64") {
return {
...target, defaultLinker: "zig", defaultLinkerArgs: ["cc"],
linkerTargetTriple: `${target.architecture === "x64" ? "x86_64" : "aarch64"}-linux-${target.name.endsWith("musl") ? "musl" : "gnu.2.36"}`,
};
}
if (target.platform === "darwin" && hostPlatform !== "darwin") {
return { ...target, defaultLinker: "zig", defaultLinkerArgs: ["cc"], linkerTargetTriple: `${target.architecture === "x64" ? "x86_64" : "aarch64"}-macos.14.0` };
}
return target;
}
/** Select only a fully described scriptc target. LLVM accepting an arbitrary
* triple is never evidence of its object ABI, runtime pack, link, or run. */
export function nativeCodegenTarget(
@@ -449,19 +467,8 @@ export function nativeCodegenTarget(
linuxLibc?: LinuxLibc,
): NativeTargetSpec | null {
const host = nativeHostTarget(hostPlatform, hostArch, hostRelease, linuxLibc);
const target = requestedTarget(env["SCRIPTC_TARGET"] ?? "", host, hostPlatform);
const target = host === null ? null : selectNativeTarget(env["SCRIPTC_TARGET"] ?? "", host, hostPlatform);
if (host === null || target === null || nativeHelperForTarget(target, hostPlatform, hostArch, linuxLibc) === null) return null;
// Cross ELF links use Zig's target libc. A native clang driver cannot
// infer or provide another architecture's CRT and sysroot.
if (target.platform === "linux" && target.name !== host.name && target.name !== "android-arm64") {
return {
...target, defaultLinker: "zig", defaultLinkerArgs: ["cc"],
linkerTargetTriple: `${target.architecture === "x64" ? "x86_64" : "aarch64"}-linux-${target.name.endsWith("musl") ? "musl" : "gnu.2.36"}`,
};
}
if (target.platform === "darwin" && hostPlatform !== "darwin") {
return { ...target, defaultLinker: "zig", defaultLinkerArgs: ["cc"], linkerTargetTriple: `${target.architecture === "x64" ? "x86_64" : "aarch64"}-macos.14.0` };
}
return target;
}
@@ -0,0 +1,110 @@
import { createHash } from "node:crypto";
/** Environment variables consumed by clang, its linker/subtools, or the
* platform SDK selection. They are implicit command-line inputs: changing one
* must never reuse an artifact produced under the old toolchain posture. */
const TOOLCHAIN_ENV_KEYS: readonly string[] = [
"COMPILER_PATH",
"GCC_EXEC_PREFIX",
"CPATH",
"C_INCLUDE_PATH",
"CPLUS_INCLUDE_PATH",
"OBJC_INCLUDE_PATH",
"OBJCPLUS_INCLUDE_PATH",
"LIBRARY_PATH",
"LD_LIBRARY_PATH",
"LD_RUN_PATH",
"DYLD_LIBRARY_PATH",
"DYLD_FRAMEWORK_PATH",
"DYLD_FALLBACK_LIBRARY_PATH",
"DYLD_FALLBACK_FRAMEWORK_PATH",
"SDKROOT",
"DEVELOPER_DIR",
"MACOSX_DEPLOYMENT_TARGET",
"IPHONEOS_DEPLOYMENT_TARGET",
"TVOS_DEPLOYMENT_TARGET",
"WATCHOS_DEPLOYMENT_TARGET",
"DRIVERKIT_DEPLOYMENT_TARGET",
"XROS_DEPLOYMENT_TARGET",
"CCC_OVERRIDE_OPTIONS",
"CCC_ADD_ARGS",
"CLANG_CONFIG_FILE_SYSTEM_DIR",
"CLANG_CONFIG_FILE_USER_DIR",
"CC",
"CFLAGS",
"CPPFLAGS",
"LDFLAGS",
"AR",
"RANLIB",
"CMAKE_GENERATOR",
"CMAKE_TOOLCHAIN_FILE",
"ZIG_LIB_DIR",
"ZIG_LIBC",
"SOURCE_DATE_EPOCH",
"ZERO_AR_DATE",
"LANG",
"LC_ALL",
"LC_CTYPE",
];
/** Toolchain variables whose values name mutable files/directories consumed
* while compiling a TU (or can inject arbitrary compiler options). Hashing the
* value is insufficient: a header, SDK, config, compiler helper, or loaded
* dylib can change in place while the spelling remains stable. In that posture
* neither complete artifacts nor per-TU runtime objects are safe to reuse. */
const MUTABLE_COMPILE_ENV_KEYS: readonly string[] = [
"COMPILER_PATH",
"GCC_EXEC_PREFIX",
"CPATH",
"C_INCLUDE_PATH",
"CPLUS_INCLUDE_PATH",
"OBJC_INCLUDE_PATH",
"OBJCPLUS_INCLUDE_PATH",
"LD_LIBRARY_PATH",
"DYLD_LIBRARY_PATH",
"DYLD_FRAMEWORK_PATH",
"DYLD_FALLBACK_LIBRARY_PATH",
"DYLD_FALLBACK_FRAMEWORK_PATH",
"SDKROOT",
"DEVELOPER_DIR",
"CCC_OVERRIDE_OPTIONS",
"CCC_ADD_ARGS",
"CLANG_CONFIG_FILE_SYSTEM_DIR",
"CLANG_CONFIG_FILE_USER_DIR",
// `zig cc` resolves its bundled headers/runtime through ZIG_LIB_DIR and a
// caller-selected native libc description through ZIG_LIBC. Both values name
// mutable compiler inputs whose contents can change behind a stable path.
"ZIG_LIB_DIR",
"ZIG_LIBC",
];
/** These variables only redirect link-time inputs. Runtime objects remain
* reusable, but a complete executable could otherwise retain a library that
* was rebuilt in place behind the same search-path spelling. */
const MUTABLE_LINK_ENV_KEYS: readonly string[] = ["LIBRARY_PATH", "LD_RUN_PATH"];
export interface ToolchainEnvironmentCachePolicy {
completeArtifacts: boolean;
runtimeObjects: boolean;
}
export function toolchainEnvironmentCachePolicy(
env: NodeJS.ProcessEnv = process.env,
): ToolchainEnvironmentCachePolicy {
const mutableCompileInput = MUTABLE_COMPILE_ENV_KEYS.some((name) => env[name] !== undefined);
const mutableLinkInput = MUTABLE_LINK_ENV_KEYS.some((name) => env[name] !== undefined);
return {
completeArtifacts: !mutableCompileInput && !mutableLinkInput,
runtimeObjects: !mutableCompileInput,
};
}
export function toolchainEnvironmentFingerprint(env: NodeJS.ProcessEnv = process.env): string {
const hash = createHash("sha256").update("toolchain-env-v1\0");
for (const name of TOOLCHAIN_ENV_KEYS) {
const value = env[name];
const text: string = value ?? "";
hash.update(name).update(value === undefined ? "\0unset\0" : "\0set\0").update(text).update("\0");
}
return hash.digest("hex");
}
@@ -9,15 +9,8 @@ import type { CcDriver } from "./native-toolchain.js";
const execFileAsync = promisify(execFile);
/** The pinned QuickJS, mbedTLS, and zlib inputs used by native recipes. */
export const QJS_COMMIT = "3c8f3d68953955950074c41c6e4d999562ae82a7";
export const MBEDTLS_VERSION = "3.6.7";
export const ZLIB_VERSION = "1.3.1";
/** Exact translation-unit membership shared by cache builds and external
* source-pack recipes. */
export const QJS_ENGINE_SOURCES = ["dtoa.c", "libregexp.c", "libunicode.c", "quickjs.c"] as const;
export const LRE_SOURCES = ["libregexp.c", "libunicode.c"] as const;
export const ZLIB_SOURCES = ["adler32.c", "compress.c", "crc32.c", "deflate.c", "infback.c", "inffast.c", "inflate.c", "inftrees.c", "trees.c", "uncompr.c", "zutil.c"] as const;
import { QJS_COMMIT, MBEDTLS_VERSION, ZLIB_VERSION, QJS_ENGINE_SOURCES, LRE_SOURCES, ZLIB_SOURCES } from "./vendor-inputs.js";
export { QJS_COMMIT, MBEDTLS_VERSION, ZLIB_VERSION, QJS_ENGINE_SOURCES, LRE_SOURCES, ZLIB_SOURCES } from "./vendor-inputs.js";
export interface VendorArchiveContext {
runtimeSrcDir(): string;
@@ -0,0 +1,9 @@
/** The pinned QuickJS, mbedTLS, and zlib inputs used by native recipes. */
export const QJS_COMMIT = "3c8f3d68953955950074c41c6e4d999562ae82a7";
export const MBEDTLS_VERSION = "3.6.7";
export const ZLIB_VERSION = "1.3.1";
/** Exact translation-unit membership shared by cache builds and external
* source-pack recipes. */
export const QJS_ENGINE_SOURCES = ["dtoa.c", "libregexp.c", "libunicode.c", "quickjs.c"] as const;
export const LRE_SOURCES = ["libregexp.c", "libunicode.c"] as const;
export const ZLIB_SOURCES = ["adler32.c", "compress.c", "crc32.c", "deflate.c", "infback.c", "inffast.c", "inflate.c", "inftrees.c", "trees.c", "uncompr.c", "zutil.c"] as const;
+325
View File
@@ -0,0 +1,325 @@
import { rmSync, statSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { parseArgs } from "node:util";
import { isExactExternalTypeSpecifier } from "../frontend/program.js";
import { renderDiagnostics } from "../diagnostics/render.js";
import { renderCoverage } from "../coverage/report.js";
import { setProvenanceSources } from "../frontend/provenance-registry.js";
import type { CliHost, NativeCacheWarmProfile } from "./host.js";
import { resolveOutputOptions } from "./output-options.js";
import { selectOutputPaths } from "./paths.js";
import { CLI_OPTIONS, USAGE } from "./usage.js";
/* The exit discipline: NEVER process.exit() after writing output. stdout/
* stderr to a PIPE are async streams — process.exit() drops whatever libuv
* hasn't flushed yet, which truncates large diagnostic renders at the pipe
* buffer (observed: 64KB cut mid-code-frame). Every path sets
* process.exitCode and returns instead; Node exits naturally once the
* streams drain. */
class CliExit extends Error {
constructor(readonly code: number) {
super(`exit ${code}`);
this.name = "CliExit";
}
}
function fail(msg: string): never {
process.stderr.write(msg + "\n");
throw new CliExit(1);
}
/** parseArgs, with its throw turned into the CLI's own one-line error.
* Unparseable arguments are a USER error — an unknown flag or a missing
* value used to reach the top level as an uncaught ERR_PARSE_ARGS_* and
* print a Node stack trace over the user's terminal. */
function parseCli(args: string[]): ReturnType<typeof parseArgs<{ options: typeof CLI_OPTIONS; allowPositionals: true; allowNegative: true }>> {
try {
return parseArgs({ args, options: CLI_OPTIONS, allowPositionals: true, allowNegative: true });
} catch (err) {
// parseArgs appends a paragraph about `--` and positionals to the
// unknown-option message; the first sentence is the part that names
// what was wrong, and USAGE below already covers what was meant.
const raw = err instanceof Error ? err.message : String(err);
const msg = raw.split("\n")[0]!.split(". ")[0]!;
fail(`scriptc: ${msg}\n\n${USAGE}`);
}
}
async function main(args: string[], host: CliHost): Promise<number> {
const { values, positionals } = parseCli(args);
const externalTypeArgs = values["external-types"] ?? [];
if (values.version) {
process.stdout.write(`${host.version()}\n`);
return 0;
}
if (values.help || positionals.length === 0) {
process.stdout.write(USAGE);
return values.help ? 0 : 1;
}
const [command, inputArg] = positionals;
if (command === "cache") {
if (inputArg !== "warm") fail(`unknown cache command "${inputArg ?? ""}" (supported: warm)\n\n${USAGE}`);
if (values.lib || values.dynamic || values.backend !== undefined || values.emit !== undefined || values.print !== undefined || values.ffi !== undefined || values.profile !== undefined || values.strip || values["windows-subsystem"] !== undefined || (values["npm-static"] ?? []).length > 0 || values["provenance-sources"] || externalTypeArgs.length > 0 || values.out !== undefined || values["emit-ir"] || !values["keep-llvm"]) {
fail(`scriptc cache warm takes only native optimization/sanitizer options and profile names\n\n${USAGE}`);
}
const optimization = values.optimization;
if (optimization !== undefined && optimization !== "release" && optimization !== "dev") {
fail(`unknown optimization "${optimization}" (supported: release, dev)\n\n${USAGE}`);
}
const profileArgs = positionals.slice(2);
const knownProfiles = new Set<NativeCacheWarmProfile>(["runtime", "tls", "dynamic"]);
for (const profile of profileArgs) {
if (!knownProfiles.has(profile as NativeCacheWarmProfile)) {
fail(`unknown cache warm profile "${profile}" (supported: runtime, tls, dynamic)`);
}
}
let result;
try {
result = await host.warmNativeCaches({
...(optimization === undefined ? {} : { optimization }),
sanitize: values.sanitize,
...(profileArgs.length === 0
? {}
: { profiles: profileArgs as NativeCacheWarmProfile[] }),
});
} catch (error) {
fail(`scriptc: ${error instanceof Error ? error.message : String(error)}`);
}
process.stdout.write(`${result.cacheRoot}\n`);
for (const profile of result.profiles) {
process.stdout.write(`${profile.profile}\t${Math.round(profile.elapsedMs)}ms\n`);
}
return 0;
}
if (command !== "build" && command !== "run" && command !== "coverage") {
fail(`unknown command "${command}"\n\n${USAGE}`);
}
if (values.lib) {
// LIBRARY mode: the profile names the entry module and pins the
// emission; the executable lane's mode flags have no meaning here
// (library artifacts are static-tier only, and there is no fallback
// concept — bare npm specifiers are static-or-refuse: the npm-static
// eligibility bar runs automatically, eligible packages compile into
// the graph, ineligible ones refuse with SC4013).
if (command !== "build") fail(`--lib is a build mode (scriptc build --lib --profile <p.json>)\n\n${USAGE}`);
const profileArg = values.profile;
if (!profileArg) fail(`scriptc build --lib needs --profile <profile.json>\n\n${USAGE}`);
if (inputArg) {
fail("scriptc build --lib takes no input positional: the profile names the entry module");
}
if (values.dynamic || values.backend !== undefined || values.emit !== undefined || values.print !== undefined || values.optimization !== undefined || values.strip || values.ffi !== undefined || values["windows-subsystem"] !== undefined || (values["npm-static"] ?? []).length > 0 || externalTypeArgs.length > 0) {
fail(
"scriptc build --lib takes no --dynamic/--backend/--emit/--print/--optimization/--strip/--windows-subsystem/--npm-static/--ffi/--external-types: the profile pins the emission and optimization, npm imports are judged automatically, outbound FFI belongs to executable builds, and external type mappings belong to coverage",
);
}
const profilePath = resolve(profileArg);
const libOutDir = values.out ? dirname(resolve(values.out)) : join(dirname(profilePath), ".scriptc");
const result = await host.compileLibrary({
profilePath,
outDir: libOutDir,
...(values.out ? { outPath: resolve(values.out) } : {}),
emitIr: values["emit-ir"],
sanitize: values.sanitize,
});
if (!result.ok) {
const color = process.stderr.isTTY ?? false;
process.stderr.write(renderDiagnostics(result.diagnostics, result.sourceTexts, { color }) + "\n");
const n = result.diagnostics.length;
process.stderr.write(`\n${n} error${n === 1 ? "" : "s"}.\n`);
return 1;
}
if (!values["keep-llvm"]) rmSync(result.llvmPath, { force: true });
process.stdout.write(`${result.archivePath}\n`);
// The contract sidecar rides the same invocation when the profile
// declares one — name it so the embedder's tooling knows where to look.
if (result.sidecarPath !== undefined) process.stdout.write(`${result.sidecarPath}\n`);
return 0;
}
if (values["emit-ir"] && (command === "build" || command === "run")) {
process.stderr.write("scriptc: warning: --emit-ir is deprecated; use --emit=ir for IR as the primary output\n");
}
if (!inputArg) fail(`missing input file\n\n${USAGE}`);
const input = resolve(inputArg);
if (command === "coverage" && values.emit !== undefined) {
fail(`--emit is a build/run option\n\n${USAGE}`);
}
if (command === "coverage" && values.strip) {
fail(`--strip is a build/run option\n\n${USAGE}`);
}
if (values.print !== undefined && values.print !== "native-link-info") {
fail(`unknown print kind "${values.print}" (supported: native-link-info)\n\n${USAGE}`);
}
const printNativeLinkInfo = values.print === "native-link-info";
if (printNativeLinkInfo && command !== "build") {
fail(`--print=native-link-info is a build option\n\n${USAGE}`);
}
if (printNativeLinkInfo && values.emit !== undefined && values.emit !== "obj") {
fail(`--print=native-link-info requires --emit=obj\n\n${USAGE}`);
}
if (externalTypeArgs.length > 0 && command !== "coverage") {
fail(`--external-types is a coverage-only option\n\n${USAGE}`);
}
const externalTypes: Record<string, string> = Object.create(null) as Record<string, string>;
for (const mapping of externalTypeArgs) {
const equals = mapping.indexOf("=");
if (equals <= 0 || equals === mapping.length - 1) {
fail(`invalid --external-types mapping ${JSON.stringify(mapping)} (expected <specifier=file.d.ts>)`);
}
const specifier = mapping.slice(0, equals).trim();
const declarationArg = mapping.slice(equals + 1).trim();
if (!isExactExternalTypeSpecifier(specifier)) {
fail(`invalid --external-types specifier ${JSON.stringify(specifier)} (expected an exact bare package specifier)`);
}
if (!/\.d\.(?:ts|mts|cts)$/.test(declarationArg)) {
fail(`invalid --external-types declaration ${JSON.stringify(declarationArg)} (expected a .d.ts, .d.mts, or .d.cts file)`);
}
if (externalTypes[specifier] !== undefined) {
fail(`duplicate --external-types mapping for ${JSON.stringify(specifier)}`);
}
const declarationPath = resolve(declarationArg);
try {
if (!statSync(declarationPath).isFile()) throw new Error("not a file");
} catch {
fail(`--external-types declaration does not name a readable file: ${declarationPath}`);
}
externalTypes[specifier] = declarationPath;
}
const ffiProfilePath = values.ffi !== undefined ? resolve(values.ffi) : undefined;
if (values.backend !== undefined && values.backend !== "llvm") {
fail(`unknown backend "${values.backend}" (supported: llvm)\n\n${USAGE}`);
}
const optimization = values.optimization;
if (optimization !== undefined && optimization !== "release" && optimization !== "dev") {
fail(`unknown optimization "${optimization}" (supported: release, dev)\n\n${USAGE}`);
}
const windowsSubsystem = values["windows-subsystem"];
if (windowsSubsystem !== undefined && windowsSubsystem !== "console" && windowsSubsystem !== "gui") {
fail(`unknown Windows subsystem "${windowsSubsystem}" (supported: console, gui)\n\n${USAGE}`);
}
if (windowsSubsystem !== undefined && command === "coverage") {
fail(`--windows-subsystem is only supported for executable builds\n\n${USAGE}`);
}
const output = command === "coverage"
? null
: resolveOutputOptions(command, {
...(values.emit === undefined && !printNativeLinkInfo
? {}
: { emit: values.emit ?? "obj" }),
emitIr: values["emit-ir"],
...(values.backend === undefined ? {} : { backend: values.backend }),
keepLlvm: values["keep-llvm"],
sanitize: values.sanitize,
...(values.optimization === undefined ? {} : { optimization: values.optimization }),
strip: values.strip,
...(windowsSubsystem === undefined ? {} : { windowsSubsystem }),
...(values.ffi === undefined ? {} : { ffi: values.ffi }),
});
if (output !== null && !output.ok) fail(`${output.message}\n\n${USAGE}`);
const backend = output === null ? undefined : output.backend;
// --npm-static: repeatable and comma-splittable; the literal "auto"
// switches to eligibility-based detection (mixing "auto" with names
// is rejected — the shapes answer different questions).
const npmStaticRaw = (values["npm-static"] ?? []).flatMap((v) => v.split(",")).map((v) => v.trim()).filter((v) => v !== "");
let npmStatic: string[] | "auto" | undefined;
if (npmStaticRaw.includes("auto")) {
if (npmStaticRaw.length > 1) fail(`--npm-static auto cannot be combined with package names\n\n${USAGE}`);
npmStatic = "auto";
} else if (npmStaticRaw.length > 0) {
npmStatic = npmStaticRaw;
}
// --provenance-sources resolves BEFORE the program loads (tsgo needs the
// source "paths" at creation): attestations and source trees fetch (or
// ride the content-addressed cache / the offline manifest), the registry
// installs, and every fallback prints as a note — never a failure.
const provenance = values["provenance-sources"] ? await host.resolveProvenanceSources(input) : null;
if (provenance !== null) {
setProvenanceSources(provenance);
for (const pkg of provenance.packages) {
process.stderr.write(
`provenance: ${pkg.name}@${pkg.version} ← ${pkg.repo.replace(/^git\+/, "")} @ ${pkg.commit.slice(0, 12)} (source compiles statically)\n`,
);
}
for (const note of provenance.notes) process.stderr.write(`provenance: ${note}\n`);
}
if (command === "coverage") {
const { coverage, sourceTexts } = await host.analyze(input, {
dynamic: values.dynamic,
...(npmStatic !== undefined ? { npmStatic } : {}),
...(ffiProfilePath !== undefined ? { ffiProfilePath } : {}),
...(Object.keys(externalTypes).length > 0 ? { externalTypes } : {}),
});
const color = process.stdout.isTTY ?? false;
process.stdout.write(renderCoverage(coverage, { color, sourceTexts }) + "\n");
return coverage.preflightFailed ? 1 : 0;
}
if (output === null || !output.ok) throw new Error("internal output-option state");
if (windowsSubsystem !== undefined && host.sourceTargetPlatform() !== "win32") {
fail(`--windows-subsystem requires a Windows executable target\n\n${USAGE}`);
}
const { outDir, outPath } = selectOutputPaths(input, output.cliOutputKind, values.out,
output.cliOutputKind === "exe" ? host.sourceTargetPlatform() : undefined);
let nativeLinkInfo: object | undefined;
const build = async (): Promise<string> => {
const result = await host.compile(input, {
outPath,
outDir,
outputKind: output.outputKind,
emitIr: output.emitIr,
sanitize: values.sanitize,
dynamic: values.dynamic,
...(backend !== undefined ? { backend } : {}),
...(optimization !== undefined ? { optimization } : {}),
...(values.strip ? { strip: true } : {}),
...(windowsSubsystem !== undefined ? { windowsSubsystem } : {}),
...(npmStatic !== undefined ? { npmStatic } : {}),
...(ffiProfilePath !== undefined ? { ffiProfilePath } : {}),
...(printNativeLinkInfo ? { nativeLinkInfo: true } : {}),
});
if (!result.ok) {
const color = process.stderr.isTTY ?? false;
process.stderr.write(renderDiagnostics(result.diagnostics, result.sourceTexts, { color }) + "\n");
const n = result.diagnostics.length;
process.stderr.write(`\n${n} error${n === 1 ? "" : "s"}.\n`);
throw new CliExit(1);
}
if (result.artifact.kind === "exe") {
if (!values["keep-llvm"]) rmSync(result.artifact.translationUnitPath, { force: true });
} else if (result.artifact.kind === "obj") {
nativeLinkInfo = result.artifact.nativeLinkInfo;
}
return result.artifact.path;
};
const binary = await build();
if (command === "run") {
return host.run(binary);
}
if (printNativeLinkInfo) {
if (nativeLinkInfo === undefined) throw new Error("internal native-link-info state");
// Keep stdout pure JSON for tooling; the ordinary artifact path is in
// program.object inside the document.
process.stdout.write(`${JSON.stringify(nativeLinkInfo, null, 2)}\n`);
} else {
process.stdout.write(`${binary}\n`);
}
return 0;
}
/** Both installed and seed commands use this argument and diagnostic contract. */
export async function runCli(args: string[], host: CliHost): Promise<number> {
try { return await main(args, host); }
catch (err) {
if (err instanceof Error && err.name === "CliExit") return Number(err.message.slice(5));
throw err;
}
}
+20
View File
@@ -0,0 +1,20 @@
import type { AnalyzeOptions, AnalyzeResult, CompileLibraryOptions, CompileLibraryResult, CompileRequestOptions, CompileRequestResult } from "../compile-types.js";
import type { ProvenanceSources } from "../frontend/provenance-registry.js";
export type NativeCacheWarmProfile = "runtime" | "tls" | "dynamic";
/** Only host operations vary between the seed and installed compiler. */
export interface CliHost {
version: () => string;
sourceTargetPlatform: () => string;
analyze: (entry: string, options: AnalyzeOptions) => Promise<AnalyzeResult>;
compile: (entry: string, options: CompileRequestOptions) => Promise<CompileRequestResult>;
compileLibrary: (options: CompileLibraryOptions) => Promise<CompileLibraryResult>;
resolveProvenanceSources: (entry: string) => Promise<ProvenanceSources>;
warmNativeCaches: (options: {
optimization?: "release" | "dev";
sanitize: boolean;
profiles?: NativeCacheWarmProfile[];
}) => Promise<{ cacheRoot: string; profiles: { profile: NativeCacheWarmProfile; elapsedMs: number }[] }>;
run: (binary: string) => Promise<number>;
}
@@ -0,0 +1,91 @@
import type { CompileOutputKind } from "../compile-types.js";
import type { CliOutputKind } from "./paths.js";
export interface OutputOptionValues {
emit?: string;
emitIr: boolean;
backend?: string;
keepLlvm: boolean;
sanitize: boolean;
optimization?: string;
strip?: boolean;
windowsSubsystem?: string;
ffi?: string;
}
export type OutputOptionResolution =
| {
ok: true;
outputKind: CompileOutputKind;
cliOutputKind: CliOutputKind;
backend?: "llvm";
emitIr: boolean;
deprecateEmitIr: boolean;
}
| { ok: false; message: string };
const SOURCE_KINDS = new Set<CliOutputKind>(["ir", "llvm"]);
const NATIVE_ARTIFACT_KINDS = new Set<CliOutputKind>(["asm", "obj"]);
/** Pure compatibility/validation matrix for build/run output selection. */
export function resolveOutputOptions(
command: "build" | "run",
values: OutputOptionValues,
): OutputOptionResolution {
if (values.backend !== undefined && values.backend !== "llvm") {
return { ok: false, message: `unknown backend "${values.backend}" (supported: llvm)` };
}
const backend = values.backend as "llvm" | undefined;
const rawEmit = values.emit;
if (
rawEmit !== undefined && rawEmit !== "ir" && rawEmit !== "llvm" &&
rawEmit !== "asm" && rawEmit !== "obj" && rawEmit !== "exe"
) {
return {
ok: false,
message: `unknown emit kind "${rawEmit}" (supported: ir, llvm, asm, obj, exe)`,
};
}
const emit = (rawEmit ?? "exe") as CliOutputKind;
if (command === "run" && emit !== "exe") {
return { ok: false, message: `scriptc run requires --emit=exe` };
}
if (values.emitIr && rawEmit !== undefined && emit !== "ir" && emit !== "exe") {
return { ok: false, message: `--emit-ir cannot be combined with --emit=${emit}; use --emit=ir` };
}
if (values.emitIr && emit === "ir") {
return { ok: false, message: `--emit-ir and --emit=ir select the same output; use --emit=ir` };
}
if (values.windowsSubsystem !== undefined && emit !== "exe") {
return { ok: false, message: `--windows-subsystem is only supported with --emit=exe` };
}
if (values.strip && emit !== "exe") {
return { ok: false, message: `--strip is only supported with --emit=exe` };
}
if (emit === "ir" && backend !== undefined) {
return { ok: false, message: `--emit=ir cannot be combined with --backend; IR is emitted before backend selection` };
}
if (SOURCE_KINDS.has(emit)) {
if (!values.keepLlvm) {
return { ok: false, message: `--no-keep-llvm is only meaningful with --emit=exe` };
}
if (values.sanitize) {
return { ok: false, message: `--sanitize is only meaningful with --emit=exe` };
}
if (values.optimization !== undefined) {
return { ok: false, message: `--optimization is only meaningful with --emit=exe` };
}
}
if (NATIVE_ARTIFACT_KINDS.has(emit) && !values.keepLlvm) {
return { ok: false, message: `--no-keep-llvm is only meaningful with --emit=exe` };
}
const outputKind = emit as CompileOutputKind;
return {
ok: true,
outputKind,
cliOutputKind: emit,
...(emit === "ir" && backend === undefined ? {} : { backend: "llvm" as const }),
emitIr: values.emitIr && emit === "exe",
deprecateEmitIr: values.emitIr,
};
}
+61
View File
@@ -0,0 +1,61 @@
import { basename, dirname, join, resolve } from "node:path";
import { configuredTargetPlatform as sourceTargetPlatform } from "../backend/target-platform.js";
export { wasiEnvironment, wasiPreopens } from "./wasi-paths.js";
export type CliOutputKind = "ir" | "llvm" | "asm" | "obj" | "exe";
const POSIX_SUFFIXES: Record<CliOutputKind, string> = {
ir: ".ir.json",
llvm: ".ll",
asm: ".s",
obj: ".o",
exe: "",
};
const WINDOWS_SUFFIXES: Record<CliOutputKind, string> = {
...POSIX_SUFFIXES,
asm: ".asm",
obj: ".obj",
exe: ".exe",
};
export function defaultOutputName(
stem: string,
kind: CliOutputKind,
platform?: string,
): string {
const selectedPlatform = platform ?? (kind === "exe" ? sourceTargetPlatform() : process.platform);
if (kind === "exe" && selectedPlatform === "wasi") return `${stem}.wasm`;
return `${stem}${(selectedPlatform === "win32" ? WINDOWS_SUFFIXES : POSIX_SUFFIXES)[kind]}`;
}
export interface OutputPaths {
outDir: string;
outPath: string;
}
/** One authority for explicit and default primary artifact paths. */
export function selectOutputPaths(
input: string,
kind: CliOutputKind,
explicitOut?: string,
platform?: string,
): OutputPaths {
const absoluteInput = resolve(input);
const outDir = explicitOut === undefined
? join(dirname(absoluteInput), ".scriptc")
: dirname(resolve(explicitOut));
const stem = basename(absoluteInput).replace(/\.(ts|mts|cts|js|mjs|cjs|c|ll)$/, "");
return {
outDir,
outPath: explicitOut === undefined
? join(outDir, defaultOutputName(stem, kind, platform))
: resolve(explicitOut),
};
}
/** Default executable filename for the build target. Explicit --out paths
* stay exact; only scriptc's generated default needs the Windows PE suffix. */
export function defaultExecutableName(stem: string, platform: string = sourceTargetPlatform()): string {
return defaultOutputName(stem, "exe", platform);
}
+86
View File
@@ -0,0 +1,86 @@
export const USAGE = `scriptc — TypeScript/JavaScript to native and WebAssembly executables (experimental)
Usage:
scriptc build <file.ts|.js> [options] compile to an executable or source artifact
scriptc run <file.ts|.js> [options] compile and run
scriptc coverage <file.ts|.js> how much compiles statically, and why not
scriptc coverage <file.ts|.js> --dynamic what a --dynamic build compiles, and what still blocks it
scriptc coverage <file.ts|.js> --external-types <specifier=file.d.ts>
type-resolve an embedder-provided module for analysis
scriptc build --lib --profile <p.json> library mode: compile the profile's entry
module to a linkable static archive
(<name>.lib.a), or a Wasm reactor
(<name>.wasm) on wasm32-wasi,
exporting the profile symbols; a profile
with a sidecar section also gets the
contract sidecar JSON beside the archive
scriptc cache warm [runtime|tls|dynamic…] prebuild expensive native cache families
for the current compiler/SDK/target
Options:
-o, --out <path> primary output path (default: .scriptc/<name><suffix>)
--emit <kind> primary output: ir, llvm, asm, obj, or exe
(default: exe). asm/obj use the matching platform helper
--print <kind> print machine-readable metadata instead of the output path
(native-link-info implies --emit=obj and never links)
--backend <b> code generator (llvm)
--optimization <release|dev>
native optimization posture (default: release/-O2). dev
uses -O0, source breakpoints, and cached LLVM object shards;
macOS executable builds also produce an adjacent .dSYM
--strip remove symbol/debug payload from the linked executable
for smaller builds (opt in; --emit=exe only)
--windows-subsystem <console|gui>
Windows executable subsystem (default: console). gui
prevents Windows from opening a console window
--keep-llvm keep generated LLVM IR beside the executable (default)
--no-keep-llvm delete generated LLVM IR after compiling
--emit-ir also write IR beside an executable or library archive;
deprecated for executables: use --emit=ir for primary IR
--sanitize build with ASan + runtime RC audit
--dynamic embed the dynamic engine (adds ~620KB; static stays the default)
--ffi <file> bind signature-only TypeScript declarations to native
C symbols and link the manifest's archives/libraries
--npm-static <pkg[,pkg…]|auto>
compile the named npm packages' shipped JS statically as
program modules (repeatable; "auto" opts in every eligible
direct import: own .d.ts, unminified JS, no build-transform
markers). A package preflight refuses falls back to the
island (--dynamic) with a coverage-report note — opt-in,
experimental
--provenance-sources
EXPERIMENTAL: compile npm dependencies from their
provenance-attested SOURCE (fetched at the attested
commit) as static program modules; packages without a
usable attestation keep the island path (a note, never
a failure)
--external-types <specifier=file.d.ts>
coverage only: map an exact bare module specifier to a
local declaration file. The declaration supplies types
for analysis; the host module remains an explicit
external-boundary blocker (repeatable)
-h, --help show this help
-v, --version print the version
`;
export const CLI_OPTIONS = {
out: { type: "string", short: "o" },
emit: { type: "string" },
print: { type: "string" },
backend: { type: "string" },
optimization: { type: "string" },
strip: { type: "boolean", default: false },
"windows-subsystem": { type: "string" },
"keep-llvm": { type: "boolean", default: true },
"emit-ir": { type: "boolean", default: false },
sanitize: { type: "boolean", default: false },
dynamic: { type: "boolean", default: false },
ffi: { type: "string" },
"npm-static": { type: "string", multiple: true },
"provenance-sources": { type: "boolean", default: false },
"external-types": { type: "string", multiple: true },
lib: { type: "boolean", default: false },
profile: { type: "string" },
help: { type: "boolean", short: "h", default: false },
version: { type: "boolean", short: "v", default: false },
} as const;
+45
View File
@@ -0,0 +1,45 @@
import { tmpdir } from "node:os";
import { wasiGuestPath } from "../wasi-paths.js";
/** Host paths exposed by `scriptc run` to a WASI Preview 1 module. Guest
* `/tmp` maps to the host's real platform temp directory instead of assuming
* the POSIX spelling exists (notably false on Windows). */
export function wasiPreopens(
cwd: string = process.cwd(),
hostTmp: string = tmpdir(),
): Record<string, string> {
return { "/": cwd, "/tmp": hostTmp };
}
/** Environment inherited by a WASI module. Host-absolute directory values
* must not claim paths outside the guest namespace: `/` is the module's
* capability root/home/cwd and `/tmp` is its writable temporary directory. */
export function wasiEnvironment(
env: NodeJS.ProcessEnv = process.env,
cwd: string = process.cwd(),
hostTmp: string = tmpdir(),
): Record<string, string> {
const guest: Record<string, string> = {};
for (const [name, value] of Object.entries(env)) {
if (value !== undefined) guest[name] = value;
}
guest["PWD"] = "/";
guest["HOME"] = "/";
guest["TMPDIR"] = "/tmp";
if (guest["USERPROFILE"] !== undefined) guest["USERPROFILE"] = "/";
if (guest["TMP"] !== undefined) guest["TMP"] = "/tmp";
if (guest["TEMP"] !== undefined) guest["TEMP"] = "/tmp";
// These optional shell/package-manager paths retain their meaning only
// when they fall under a capability the runner actually exposes.
for (const key of ["OLDPWD", "INIT_CWD"]) {
const value = guest[key];
if (value === undefined) continue;
const mapped = wasiGuestPath(value, cwd, hostTmp);
if (mapped === null) delete guest[key];
else guest[key] = mapped;
}
return guest;
}
+39
View File
@@ -0,0 +1,39 @@
#!/usr/bin/env node
/* The subprocess host for `scriptc run` on wasm32-wasi. Keeping the WASI
* instance outside the CLI process preserves native run's exit isolation:
* process.exit(), traps, and signals cannot take the compiler process down.
*/
import { readFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { WASI } from "node:wasi";
import { wasiGuestPath } from "../wasi-paths.js";
import { wasiEnvironment, wasiPreopens } from "./wasi-paths.js";
type WasiInstance = Parameters<WASI["start"]>[0];
declare const WebAssembly: {
instantiate(
bytes: Uint8Array,
imports: ReturnType<WASI["getImportObject"]>,
): Promise<{ instance: WasiInstance }>;
};
const binary = process.argv[2];
if (binary === undefined) throw new Error("scriptc WASI runner needs a module path");
const cwd = process.cwd();
const hostTmp = tmpdir();
const wasi = new WASI({
version: "preview1",
args: [wasiGuestPath(binary, cwd, hostTmp) ?? binary],
env: wasiEnvironment(process.env, cwd, hostTmp),
// A native scriptc executable inherits access to the caller's filesystem.
// WASI is capability-based, so expose the caller's working tree as `/`
// and the platform's actual temporary directory as guest `/tmp`.
preopens: wasiPreopens(cwd, hostTmp),
returnOnExit: true,
});
const instantiated = await WebAssembly.instantiate(
await readFile(binary),
wasi.getImportObject(),
);
process.exitCode = wasi.start(instantiated.instance);
+157
View File
@@ -0,0 +1,157 @@
import type { WindowsSubsystem } from "./backend/targets.js";
import type { NativeLinkInfo } from "./backend/native-link-info.js";
import type { ScrDiagnostic } from "./diagnostics/diagnostic.js";
import type { CoverageInput } from "./coverage/report.js";
export type CompileOutputKind = "ir" | "llvm" | "asm" | "obj" | "exe";
export interface CompileBaseOptions {
/** Primary artifact path. The CLI supplies the output-kind default. */
outPath: string;
/** Where generated intermediates and compatibility side artifacts land. */
outDir: string;
/** @deprecated This option no longer controls output cleanup; sibling artifacts are retained. */
defaultOutputPath?: boolean;
/** Compatibility-only additive IR side artifact for executable builds.
* The CLI's deprecated --emit-ir flag supplies this option. */
emitIr?: boolean;
sanitize?: boolean;
/** Embed the dynamic-island engine (--dynamic). Off = the static default:
* island constructs are diagnostics and nothing about codegen or linking
* changes. */
dynamic?: boolean;
/** LLVM is the production code generator. */
backend?: "llvm";
/** Native optimization posture. Release is the shipped -O2 default; dev
* uses -O0, source line tables, and stable multi-TU object caching for
* large LLVM programs. Darwin executables include an adjacent .dSYM. */
optimization?: "release" | "dev";
/** Remove symbol/debug payload from an executable at link time. */
strip?: boolean;
/** Windows PE executable subsystem. Console is the default; GUI suppresses
* automatic console-window creation. Only valid for Windows executables. */
windowsSubsystem?: WindowsSubsystem;
/** --npm-static: package names whose shipped, unminified JS compiles
* STATICALLY as program modules (inference types the bodies; statements
* the lowering cannot prove become runtime fences). "auto" opts in every
* directly-imported package passing the eligibility heuristics (own
* .d.ts, unminified JS, no build-transform markers). A package whose
* preflight refuses marks itself an offender and falls back to the
* island (--dynamic) or the requires-dynamic diagnostic (static builds)
* — never a silent misbuild. Off by default: nothing changes without
* the flag. */
npmStatic?: readonly string[] | "auto";
/** Outbound native FFI manifest. Its signature-only TypeScript bindings
* lower to direct C ABI calls. Source outputs retain those declarations;
* archive/system-library inputs join only an executable link. */
ffiProfilePath?: string;
/** Attach the machine-readable external link recipe to an object result.
* Valid only with outputKind "obj"; it never invokes a linker. */
nativeLinkInfo?: boolean;
}
/** Executable compile options. This remains the compatibility type for the
* historical compile() API, whose omitted output kind means executable. */
export interface CompileOptions extends CompileBaseOptions {
outputKind?: "exe";
/** Internal validation lane retained for helper-object artifact tests.
* Supported ordinary LLVM executable builds select this path automatically. */
nativeProgramObject?: boolean;
}
/** Source-artifact compile options, discriminated by the required kind. */
export interface CompileSourceOptions extends CompileBaseOptions {
outputKind: Exclude<CompileOutputKind, "exe">;
}
/** Internal/dynamic request shape for callers that select the kind at runtime.
* Statically executable/source callers should prefer the narrower interfaces. */
export interface CompileRequestOptions extends CompileBaseOptions {
outputKind?: CompileOutputKind;
/** Internal validation lane for executable requests. */
nativeProgramObject?: boolean;
}
export type CompileArtifact =
| { kind: "ir"; path: string }
| { kind: "llvm"; path: string }
| { kind: "asm"; path: string }
| { kind: "obj"; path: string; nativeLinkInfo?: NativeLinkInfo }
| {
kind: "exe";
path: string;
translationUnitPath: string;
backend: "llvm";
};
export type CompileFailure = {
ok: false;
diagnostics: ScrDiagnostic[];
sourceTexts: Map<string, string>;
};
export type CompileSourceResult =
| { ok: true; artifact: Extract<CompileArtifact, { kind: "ir" | "llvm" | "asm" | "obj" }> }
| CompileFailure;
/** Historical executable result shape retained for source compatibility. */
export type CompileResult =
| {
ok: true;
binaryPath: string;
llvmPath: string;
irPath?: string;
backend: "llvm";
}
| CompileFailure;
export type CompileExecutableResult =
/** The generated LLVM source is retained beside the executable. */
| (Extract<CompileResult, { ok: true }> & {
artifact: Extract<CompileArtifact, { kind: "exe" }>;
})
| CompileFailure;
/** Result union for callers that choose outputKind dynamically. */
export type CompileRequestResult = CompileSourceResult | CompileExecutableResult;
export interface CompileLibraryOptions {
profilePath: string;
/** Where the archive and the kept program TU land. */
outDir: string;
/** Artifact path. Default: <stem>.lib.a, or <stem>.wasm for wasm32-wasi. */
outPath?: string;
emitIr?: boolean;
sanitize?: boolean;
}
export type CompileLibraryResult =
/** `sidecarPath` is present exactly when the profile declares a
* `sidecar` section: the contract JSON written beside the archive by
* the same invocation (ask 2). */
| { ok: true; archivePath: string; llvmPath: string; backend: "llvm"; irPath?: string; sidecarPath?: string }
| { ok: false; diagnostics: ScrDiagnostic[]; sourceTexts: Map<string, string> };
export interface AnalyzeOptions {
/** Analyze as a --dynamic build (island constructs lower instead of
* producing requires-dynamic diagnostics). */
dynamic?: boolean;
/** --npm-static (see CompileOptions.npmStatic): the analysis compiles
* opted-in packages' JS as program modules and the coverage report
* carries each package's static/fallback status. */
npmStatic?: readonly string[] | "auto";
/** Analyze with the outbound native bindings from this FFI manifest. */
ffiProfilePath?: string;
/** Coverage-only external host type surfaces: exact bare module
* specifier → local declaration file. The checker uses the declarations
* to analyze project code, but imported runtime values remain explicit
* SC1010 blockers rather than being counted as executable. */
externalTypes?: Readonly<Record<string, string>>;
}
export interface AnalyzeResult {
coverage: CoverageInput;
sourceTexts: Map<string, string>;
}
+7 -7
View File
@@ -190,21 +190,21 @@ export function renderCoverage(input: CoverageInput, opts: { color?: boolean; so
// surface as SC2030 blockers below; "lazy trap" rows DON'T fail the
// build — the binary embeds Node's call-time error and the call throws
// at runtime, exactly where Node would have failed.
const builtins = (input.dynamic && input.npmBuiltins) || [];
const traps = (input.dynamic && input.npmLazyTraps) || [];
const builtins = input.dynamic ? input.npmBuiltins ?? [] : [];
const traps = input.dynamic ? input.npmLazyTraps ?? [] : [];
if (builtins.length > 0 || traps.length > 0) {
out.push(
` ${c(DIM, traps.length > 0 ? "embedded npm code imports Node builtins and unresolved specifiers:" : "embedded npm code imports Node builtins:")}`,
);
const widestB = Math.max(
const widestB = Math.max(...[
...builtins.map((b) => b.builtin.length),
...traps.map((t) => t.specifier.length),
);
]);
// Pad the plain words before coloring — escape codes have no width.
const widestS = Math.max(
const widestS = Math.max(...[
...builtins.map((b) => (b.shimmed ? 7 : b.lazy ? 23 : 11)),
...traps.map(() => 24),
);
]);
for (const b of builtins) {
const status = b.shimmed
? c(GREEN, "shimmed".padEnd(widestS))
@@ -247,7 +247,7 @@ export function renderCoverage(input: CoverageInput, opts: { color?: boolean; so
let pFailed = 0;
let pIsland = 0;
const dir = pkg.dir.endsWith("/") ? pkg.dir : `${pkg.dir}/`;
for (const [file, s] of input.statsByFile ?? []) {
for (const [file, s] of input.statsByFile ?? new Map<string, { total: number; failed: number; island: number }>()) {
if (!file.startsWith(dir)) continue;
pTotal += s.total;
pFailed += s.failed;
@@ -187,7 +187,7 @@ export function generateSurfaceManifest(compilerVersion: string): SurfaceManifes
["for-using-of", "for (using ... of ...) over arrays", "each array element is disposed at the end of its iteration, including break and continue paths"],
["finally-abrupt-completions", "abrupt completions through finally", "return, throw, break, continue, and labeled jumps run crossed finally blocks; a finally completion replaces the pending one"],
["spread-arguments", "spread arguments", "non-empty fixed tuples flatten into fixed signatures with evaluate-once ordering; arrays, Sets, and statically represented class iterables spread into typed rest parameters"],
] as const) {
] as [string, string, string][]) {
add({ id: `syntax.${id}`, kind: "syntax", name, status: "static", note });
}
@@ -0,0 +1,79 @@
import type { CompileFailure, CompileRequestOptions } from "../compile-types.js";
import type { FfiProfile } from "../ffi/ffi-manifest.js";
import { checkerPanicDiag, iceDiag, isCheckerPanic, type ScrDiagnostic } from "../diagnostics/diagnostic.js";
import type { IrModule, SrcLoc } from "../ir/ir.js";
import { validateModule } from "../ir/validate.js";
import { moduleWasiUnavailableSurface, targetRefusalDiag } from "../backend/target-diagnostics.js";
import type { LowerResult, LowerStats } from "../frontend/lowering/lowerer.js";
import type { FrontendFactory } from "../frontend/pipeline.js";
export interface PreparedExecutableModule {
ok: true;
mod: IrModule;
sourceTexts: Map<string, string>;
stats: LowerStats;
}
/** Release the parser/checker before native object generation begins. */
export function prepareExecutableModule(
entryPath: string,
opts: CompileRequestOptions,
ffi: FfiProfile | null,
buildPlatform: string,
createFrontend: FrontendFactory,
): PreparedExecutableModule | CompileFailure {
const fe = createFrontend(entryPath, opts.npmStatic);
let lowered: LowerResult;
let sourceTexts: Map<string, string>;
// The frontend (and its tsgo server) is released as soon as lowering
// ends — clang and the link never hold it open.
try {
const fail = (diagnostics: ScrDiagnostic[]): CompileFailure => ({
ok: false,
diagnostics,
sourceTexts: fe.sourceTexts(),
});
if (fe.preflight.length > 0) return fail(fe.preflight);
try {
lowered = fe.lower({
dynamic: opts.dynamic ?? false,
targetPlatform: buildPlatform,
...(ffi !== null ? { ffiImports: ffi.functions } : {}),
});
} catch (e) {
// The last-resort panic fence: an upstream tsgo panic that crossed a
// checker call no statement/collection fence wrapped still becomes a
// clean failed compile (anchored at the entry), never a crashed CLI.
if (!isCheckerPanic(e)) throw e;
return fail([
checkerPanicDiag(e.message.split("\n", 1)[0]!, { file: entryPath, start: 0, end: 0 }),
]);
}
if (lowered.module === null) return fail(lowered.diagnostics);
const validation = validateModule(lowered.module);
if (validation.length > 0) {
return fail(validation.map((v) => iceDiag(v.message, v.loc)));
}
if (buildPlatform === "wasi") {
const entryLoc: SrcLoc = { file: entryPath, start: 0, end: 0 };
if (opts.sanitize) {
return fail([targetRefusalDiag("wasm32-wasi", "--sanitize", entryLoc)]);
}
if (ffi !== null) {
return fail([targetRefusalDiag("wasm32-wasi", "native FFI manifests", entryLoc)]);
}
const unavailable = moduleWasiUnavailableSurface(lowered.module);
if (unavailable !== null) {
return fail([targetRefusalDiag("wasm32-wasi", unavailable.surface, unavailable.loc)]);
}
}
sourceTexts = fe.sourceTexts();
} finally {
fe.dispose();
}
return { ok: true, mod: lowered.module!, sourceTexts, stats: lowered.stats };
}
@@ -0,0 +1,90 @@
import type { AnalyzeOptions, AnalyzeResult } from "../compile-types.js";
import { loadFfiProfile, type FfiProfile } from "../ffi/ffi-manifest.js";
import { provenanceSources } from "./provenance-registry.js";
import type { FrontendFactory } from "./pipeline.js";
/** Analysis without codegen: how much of the program compiles statically.
* Unlike compile(), lowering diagnostics are data here, not failure. */
export function analyzeWithFrontend(entryPath: string, opts: AnalyzeOptions, buildPlatform: string, createFrontend: FrontendFactory): AnalyzeResult {
let ffi: FfiProfile | null = null;
if (opts.ffiProfilePath !== undefined) {
const loaded = loadFfiProfile(opts.ffiProfilePath);
if (!loaded.ok) {
return {
coverage: {
file: entryPath,
dynamic: opts.dynamic ?? false,
stats: { statementsTotal: 0, statementsFailed: 0, statementsIsland: 0, functionsSkipped: 0 },
diagnostics: loaded.diagnostics,
preflightFailed: true,
},
sourceTexts: new Map(),
};
}
ffi = loaded.profile;
}
const fe = createFrontend(entryPath, opts.npmStatic, opts.externalTypes);
try {
const emptyStats = { statementsTotal: 0, statementsFailed: 0, statementsIsland: 0, functionsSkipped: 0 };
const preflight = fe.preflight;
// Import-FORM fences don't stop the analysis: the module graph is still
// computable (a fenced import contributes no edges), the imported
// bindings poison at their use sites, and the fences join the blockers
// list beside statement-level ones — the report shows a statement
// percentage instead of stopping at the import lines. Everything else —
// tsc errors, config incompatibilities, circular imports — still stops
// at preflight (no trustworthy program to lower). Builds are unchanged:
// compile() fails on every preflight diagnostic exactly as before.
const IMPORT_FENCES = new Set(["SC1010", "SC1012", "SC1013", "SC1014", "SC1015"]);
if (preflight.some((d) => !IMPORT_FENCES.has(d.code))) {
return {
coverage: {
file: entryPath,
dynamic: opts.dynamic ?? false,
stats: emptyStats,
diagnostics: preflight,
...(fe.npmStatic.length > 0 ? { npmStatic: fe.npmStatic } : {}),
preflightFailed: true,
},
sourceTexts: fe.sourceTexts(),
};
}
// Coverage is whole-program by design: builds stop at what the entry
// reaches, but the analysis additionally lowers the unreached remainder
// (throwaway) so the report covers everything the source declares — with
// the unreached share in its own group.
const lowered = fe.lower({
dynamic: opts.dynamic ?? false,
coverage: true,
targetPlatform: buildPlatform,
...(ffi !== null ? { ffiImports: ffi.functions } : {}),
});
const provenance = provenanceSources();
return {
coverage: {
file: entryPath,
dynamic: opts.dynamic ?? false,
stats: lowered.stats,
// The import fences report as blockers alongside the statement-level
// ones (use sites of the fenced bindings emit matching diagnostics,
// which the report groups with these).
diagnostics: [...preflight, ...lowered.diagnostics],
...(lowered.runtimeFences.length > 0 ? { runtimeFences: lowered.runtimeFences } : {}),
...(lowered.unreached ? { unreached: lowered.unreached } : {}),
...(lowered.npmBuiltins ? { npmBuiltins: lowered.npmBuiltins } : {}),
...(lowered.npmLazyTraps ? { npmLazyTraps: lowered.npmLazyTraps } : {}),
...(fe.npmStatic.length > 0 ? { npmStatic: fe.npmStatic } : {}),
// --provenance-sources: the per-package attribution inputs (the
// report aggregates statsByFile under each package's source dir).
...(provenance !== null ? { provenance } : {}),
...(lowered.statsByFile ? { statsByFile: lowered.statsByFile } : {}),
...(lowered.provenanceElided ? { provenanceElided: lowered.provenanceElided } : {}),
preflightFailed: false,
},
sourceTexts: fe.sourceTexts(),
};
} finally {
fe.dispose();
}
}
+16 -3
View File
@@ -1,16 +1,29 @@
import { createRequire } from "node:module";
import { join } from "node:path";
import { npmPackageNameOf } from "./workspace-registry.js";
import { tsgoPath } from "./ts7/session-path.js";
export { tsgoPath } from "./ts7/session-path.js";
const require = createRequire(import.meta.url);
let installedDeclarationRoot: string | null = null;
/** A native distribution supplies its relocatable declaration directory. */
export function setDeclarationRoot(root: string): void { installedDeclarationRoot = root; }
function declarationPath(name: string): string {
return tsgoPath(installedDeclarationRoot === null
? name === "scriptc.d.ts" ? require.resolve("@scriptc/compiler/scriptc.d.ts")
: name === "scriptc-overrides.d.ts" ? require.resolve("@scriptc/compiler/scriptc-overrides.d.ts")
: require.resolve("@scriptc/compiler/scriptc-node-fallback.d.ts")
: join(installedDeclarationRoot, name));
}
/** Path of the shipped ambient declarations — the always-shipped CORE
* (comptime/__island_eval, setTimeout). Part of EVERY program scriptc
* builds, the project-world preflight program included. */
export function ambientDtsPath(): string {
return tsgoPath(require.resolve("@scriptc/compiler/scriptc.d.ts"));
return declarationPath("scriptc.d.ts");
}
/** Path of the shipped divergence/precision OVERRIDES (JSON.parse():
@@ -19,7 +32,7 @@ export function ambientDtsPath(): string {
* so a project that typechecks under its own tsc never fails preflight over
* an override-manufactured error (checkPreflight). */
export function overridesDtsPath(): string {
return tsgoPath(require.resolve("@scriptc/compiler/scriptc-overrides.d.ts"));
return declarationPath("scriptc-overrides.d.ts");
}
/** Path of the shipped FALLBACK declarations (console, process, node:fs) —
@@ -27,7 +40,7 @@ export function overridesDtsPath(): string {
* With @types/node, the project's real Node types stand in and this file
* stands down (its declaration forms would collide). */
export function fallbackDtsPath(): string {
return tsgoPath(require.resolve("@scriptc/compiler/scriptc-node-fallback.d.ts"));
return declarationPath("scriptc-node-fallback.d.ts");
}
/** True for files belonging to the adopted Node type surface: the
@@ -176,3 +176,27 @@ test("failed directory enumeration invalidates when access is restored", async (
await chmod(packages, 0o700);
}
});
test("synchronous tracking restores parents after throws and propagates unstable inputs", async () => {
const dir = await mkdtemp(join(tmpdir(), "scriptc-inputs-"));
scratch.push(dir);
const first = join(dir, "first.ts");
const second = join(dir, "second.ts");
await writeFile(first, "export const first = 1;");
await writeFile(second, "export const second = 2;");
const parent = new FrontendInputTracker();
const child = new FrontendInputTracker();
parent.runSynchronous(() => {
expect(() => child.runSynchronous(() => {
trackedReadFile(first);
markFrontendInputsUnstable();
throw new Error("interrupted");
})).toThrow("interrupted");
trackedReadFile(second);
});
expect(parent.snapshot().probes.filter((probe) => probe.op === "file").map((probe) => probe.path)).toEqual([first, second]);
expect(parent.snapshot().stable).toBe(false);
const before = parent.snapshot();
trackedFileExists(join(dir, "outside.ts"));
expect(parent.snapshot()).toEqual(before);
});
@@ -41,8 +41,8 @@ export interface FrontendSemanticMatch {
* named artifacts and a generated directory's formerly-missing observation
* are excluded. */
export interface FrontendInputExclusions {
outputPaths?: Iterable<string>;
outputDirectories?: Iterable<string>;
outputPaths?: readonly string[];
outputDirectories?: readonly string[];
}
function frontendSourceDigest(text: string): string {
@@ -73,6 +73,7 @@ function systemRealpath(path: string): string {
}
const activeTracker = new AsyncLocalStorage<FrontendInputTracker>();
let synchronousTracker: FrontendInputTracker | undefined;
export class FrontendInputTracker {
private readonly probes = new Map<string, FrontendInputProbe>();
@@ -88,6 +89,23 @@ export class FrontendInputTracker {
});
}
/** The native frontend is synchronous; its typed result need not cross an
* async-context callback boundary. Nested trackers still propagate probes. */
runSynchronous<T>(fn: () => T): T {
const parent = synchronousTracker;
// Install the active tracker so filesystem callbacks can record probes.
// eslint-disable-next-line @typescript-eslint/no-this-alias
synchronousTracker = this;
try { return fn(); }
finally {
synchronousTracker = parent;
if (parent !== undefined && parent !== this) {
for (const probe of this.probes.values()) parent.record(probe);
if (!this.stable) parent.markUnstable();
}
}
}
record(probe: FrontendInputProbe): void {
const key = `${probe.op}\0${probe.path}`;
const previous = this.probes.get(key);
@@ -140,13 +158,13 @@ export class FrontendInputTracker {
}
function record(probe: FrontendInputProbe): void {
activeTracker.getStore()?.record(probe);
(synchronousTracker ?? activeTracker.getStore())?.record(probe);
}
/** Mark the active frontend as unsafe to persist because an exact host query
* bypassed the tracked filesystem wrappers. No-op outside a frontend run. */
export function markFrontendInputsUnstable(): void {
activeTracker.getStore()?.markUnstable();
(synchronousTracker ?? activeTracker.getStore())?.markUnstable();
}
export function trackedReadFile(path: string): string | null {
@@ -411,11 +429,11 @@ export function frontendInputsSemanticallyMatch(
/** Pure validator used by the persistent-cache reader before any path probes. */
export function validFrontendInputSnapshot(snapshot: unknown): snapshot is FrontendInputSnapshot {
if (snapshot === null || typeof snapshot !== "object") return false;
const candidate = snapshot as Partial<FrontendInputSnapshot>;
const candidate = snapshot as { version?: unknown; stable?: unknown; probes?: unknown };
if (candidate.version !== 1 || candidate.stable !== true || !Array.isArray(candidate.probes)) return false;
return candidate.probes.every((probe) => {
if (probe === null || typeof probe !== "object") return false;
const value = probe as Partial<FrontendInputProbe>;
const value = probe as Record<string, unknown>;
if (typeof value.path !== "string" || typeof value.op !== "string") return false;
switch (value.op) {
case "file":
@@ -1,4 +1,4 @@
import { BOOL, F64, type IrExpr, type IrStmt, type IrType, type SrcLoc } from "../../ir/ir.js";
import { BOOL, DYN, F64, NULL_T, type IrExpr, type IrStmt, type IrType, type SrcLoc } from "../../ir/ir.js";
import { numLit, varRef } from "../../ir/build.js";
import { typeKey } from "../type-mapper.js";
import type { Lowerer } from "./lowerer.js";
@@ -40,8 +40,12 @@ export function lowerArrayFill(
arrType: IrType & { kind: "array" },
loc: SrcLoc,
): IrExpr {
const valueType = writeUndefined ? F64 : value!.type;
const key = "indexed:fill:" + typeKey(arrType.elem) + ":" + typeKey(valueType) + ":" + writeUndefined;
const writeNull = value?.type.kind === "nullT";
// Unit literals have no parameter ABI. Pass a numeric placeholder and
// synthesize the unit inside the helper's correctly tagged store.
const discardValue = writeUndefined || writeNull;
const valueType = discardValue ? F64 : value!.type;
const key = "indexed:fill:" + typeKey(arrType.elem) + ":" + typeKey(valueType) + ":" + writeUndefined + ":" + writeNull;
let name = lowerer.arrHofHelpers.get(key);
if (!name) {
name = "%arr.fill." + lowerer.arrHofHelpers.size;
@@ -51,7 +55,7 @@ export function lowerArrayFill(
const i = varRef("i.0", F64, loc);
const body: IrStmt[] = writeUndefined
? [{ kind: "arraySetUndefined", arr: a, index: i, loc }]
: [arrayValueStore(lowerer, a, i, varRef("v.0", valueType, loc), arrType.elem, loc)];
: [arrayValueStore(lowerer, a, i, writeNull ? { kind: "unitLit", unit: "null", type: NULL_T, loc } : varRef("v.0", valueType, loc), arrType.elem, loc)];
lowerer.liftedFns.push({
name,
params: [
@@ -88,8 +92,9 @@ export function lowerArrayFill(
loc,
});
}
const valueArg: IrExpr = value && writeUndefined
? { kind: "seqExpr", stmts: [{ kind: "exprStmt", expr: value, loc }], result: numLit(0, loc), type: F64, loc }
const valueArg: IrExpr = discardValue
? value === null || value.kind === "unitLit" ? numLit(0, loc)
: { kind: "seqExpr", stmts: [{ kind: "exprStmt", expr: lowerer.coerceToExpected(value, DYN), loc }], result: numLit(0, loc), type: F64, loc }
: value ?? numLit(0, loc);
return { kind: "call", callee: name, args: [receiver, valueArg, start, end], type: arrType, loc };
}
@@ -2295,6 +2295,12 @@ function lowerFsSyncBufferWindow(
}
if (bi.module === "fs" && (bi.member === "writeFileSync" || bi.member === "appendFileSync") && expr.arguments.length === 2) {
const dataIr = lowerer.mapTypeOf(lowerer.typeOf(expr.arguments[1]!));
if (dataIr?.kind === "union") {
const arms = lowerer.unions.get(dataIr.unionId)?.arms;
if (arms && arms.every((arm) => arm.kind === "string" || (arm.kind === "bytes" && arm.elem === "u8"))) {
return lowerStringOrBytesWrite(lowerer, expr, dataIr, arms, bi.member === "appendFileSync");
}
}
if (dataIr?.kind === "bytes") {
if (dataIr.elem !== "u8") {
lowerer.noLowering(
@@ -4987,17 +4993,49 @@ export function lowerForkCall(lowerer: Lowerer, expr: ts.CallExpression, loc: Sr
);
}
const node: IrExpr = { kind: "jsonStringify", value, type: STRING, loc };
if (indent !== "") {
// The compile-time-resolved indent rides as an extra property (the
// node shape in ir/ir.ts is unchanged); the backend re-indents
// the compact serializer output with Node's gap algorithm.
(node as { indent?: string }).indent = indent;
}
if (indent !== "") node.indent = indent;
return node;
}
return null; // unknown members are tsc errors before lowering
}
/** A valid string/byte overload must select its runtime entry from the live
* union tag. Coercing the whole argument to the string overload loses bytes. */
function lowerStringOrBytesWrite(lowerer: Lowerer, call: ts.CallExpression,
dataType: Extract<IrType, { kind: "union" }>, arms: readonly IrType[], append: boolean): IrExpr {
const loc = locOf(call);
const key = `fs.${append ? "append" : "write"}:${dataType.unionId}`;
let helper = lowerer.widthHelpers.get(key);
if (!helper) {
helper = `%fs.writeData.${lowerer.widthHelpers.size}`;
lowerer.widthHelpers.set(key, helper);
const body: IrStmt[] = [];
for (let tag = 0; tag < arms.length; tag++) {
const arm = arms[tag]!;
const fn: IrLibFn = arm.kind === "bytes"
? append ? "fs.appendFileSyncBytes" : "fs.writeFileSyncBytes"
: append ? "fs.appendFileSync" : "fs.writeFileSync";
body.push({ kind: "if", loc,
cond: { kind: "unionIsTag", unionId: dataType.unionId, tag, negated: false,
value: varRef("data.0", dataType, loc), type: BOOL, loc },
then: [
{ kind: "exprStmt", expr: { kind: "libCall", fn, args: [varRef("path.0", STRING, loc),
{ kind: "unionNarrow", unionId: dataType.unionId, tag, value: varRef("data.0", dataType, loc), type: arm, loc }], type: VOID, loc }, loc },
{ kind: "return", value: null, loc },
], else_: null,
});
}
body.push({ kind: "return", value: null, loc });
lowerer.liftedFns.push({ name: helper, returnType: VOID, loc,
params: [{ localId: "path.0", name: "path", type: STRING }, { localId: "data.0", name: "data", type: dataType }],
locals: [{ id: "path.0", name: "path", type: STRING, mutable: false }, { id: "data.0", name: "data", type: dataType, mutable: false }],
body,
});
}
return { kind: "call", callee: helper, args: [lowerer.lowerExprExpecting(call.arguments[0]!, STRING),
lowerer.lowerExprExpecting(call.arguments[1]!, dataType)], type: VOID, loc };
}
function lowerOptionalStringifyRoot(lowerer: Lowerer, value: IrExpr, indent: string, loc: SrcLoc): IrExpr | null {
const tags = optionalStringTags(lowerer, value.type);
if (!tags || value.type.kind !== "union") return null;
@@ -5014,7 +5052,7 @@ function lowerOptionalStringifyRoot(lowerer: Lowerer, value: IrExpr, indent: str
type: STRING,
loc,
};
if (indent !== "") (serialized as { indent?: string }).indent = indent;
if (indent !== "") serialized.indent = indent;
const missing = lowerer.wrappedUndefined(resultT, loc);
if (!missing) throw new InternalCompilerError("optional JSON.stringify result needs an undefined arm");
lowerer.liftedFns.push({
@@ -6129,7 +6167,11 @@ function lowerOptionalStringSearchParams(lowerer: Lowerer, init: IrExpr, loc: Sr
lowerer.noLowering(`${receiverType.kind === "cryptoHash" ? "Hash" : "Hmac"}.update with ${call.arguments.length} arguments`, call, "update(stringOrBuffer[, inputEncoding]) is supported");
}
const dataNode = call.arguments[0]!;
const data = lowerer.lowerExpr(dataNode);
const dataType = lowerer.mapTypeOf(lowerer.typeOf(dataNode));
// Indexed reads can retain optional storage after a nullish fallback
// or narrowing. Use the proven argument type through a checked coercion.
const data = dataType?.kind === "string" || dataType?.kind === "bytes"
? lowerer.lowerExprExpecting(dataNode, dataType) : lowerer.lowerExpr(dataNode);
const prefix = receiverType.kind === "cryptoHash" ? "crypto.hashUpdate" : "crypto.hmacUpdate";
if (data.type.kind === "bytes" && data.type.elem === "u8") {
if (call.arguments.length !== 1) {
@@ -1,3 +1,4 @@
import { objectEnumerationReceiver } from "./object-enumeration-receiver.js";
import { InternalCompilerError } from "../../errors.js";
/* Call lowering: the lowerCall dispatch chain, parameter-shape analysis and
* argument completion (optional/default/rest, explicit-undefined ≡ omission),
@@ -9576,7 +9577,7 @@ export function lowerPromiseMethodCall(lowerer: Lowerer, call: ts.CallExpression
const loc = locOf(call);
const resultT = lowerer.irTypeOf(call);
if (resultT.kind !== "array") lowerer.badType(call, lowerer.typeOf(call)); // defensive
const receiver = lowerer.lowerExpr(argNode);
const receiver = objectEnumerationReceiver(lowerer, lowerer.lowerExpr(argNode), argIr, loc);
if (member === "keys") {
// The keys walk is shared with for-in (which iterates exactly the
// keys Object.keys answers — one construction, one intern key).
@@ -1,3 +1,4 @@
import { objectEnumerationReceiver } from "./object-enumeration-receiver.js";
import { InternalCompilerError } from "../../errors.js";
/* Container-surface call lowering: array methods (including the HOF family
* map/filter/forEach with their synthesized helper functions), Map/Set
@@ -5958,7 +5959,8 @@ function mapFromSeedValue(lowerer: Lowerer, seed: IrExpr, mapT: IrType & { kind:
shape: IrRecordShape,): IrExpr {
const resultT = lowerer.irTypeOf(call);
if (resultT.kind !== "array") lowerer.badType(call, lowerer.typeOf(call)); // defensive
return objectIterOverIndexShape(lowerer, call, member, argIr, shape, lowerer.lowerExpr(call.arguments[0]!), resultT, locOf(call));
return objectIterOverIndexShape(lowerer, call, member, argIr, shape,
objectEnumerationReceiver(lowerer, lowerer.lowerExpr(call.arguments[0]!), argIr, locOf(call)), resultT, locOf(call));
}
/** The construction core, receiver/result pre-resolved — `node` anchors
@@ -5791,7 +5791,7 @@ export function lowerElementCompound(lowerer: Lowerer, expr: ts.BinaryExpression
if (arr.type.kind !== "array") {
lowerer.unsupported("SC1090", target.expression, "assignment through a possibly missing nested array receiver");
}
const index = lowerer.lowerExpr(target.argumentExpression);
const index = lowerOptionalNumber(lowerer, lowerer.lowerExpr(target.argumentExpression), locOf(target.argumentExpression), target.argumentExpression);
if (index.type.kind !== "f64") {
lowerer.unsupported("SC1090", target.argumentExpression, "indexing with non-number keys");
}
@@ -1,4 +1,5 @@
import { everyExprChild, everyStmtChild } from "../../ir/traverse.js";
import { sanitizeUnregisteredClassTypes } from "./sanitize-class-types.js";
import { buildUnionNarrow } from "./union-narrow.js";
import { planUnionRetag, buildUnionRetag, planRecordUnionWrap, buildRecordUnionWrap } from "./union-retag.js";
import type { WidthLift } from "./width-lift.js";
@@ -3817,9 +3818,6 @@ export class Lowerer {
// slots), uniformly across params/locals/globals/fields/body types so
// every producer and consumer agrees. Programs with no unregistered
// reference are untouched — byte-stability holds.
if (this.diags.length === 0) {
this.sanitizeUnregisteredClassTypes([functions, this.globalsList, artifacts.classes, artifacts.records, artifacts.unions]);
}
const module: IrModule | null =
this.diags.length > 0
? null
@@ -3835,6 +3833,7 @@ export class Lowerer {
entry: ENTRY_NAME,
...(this.ffiImports.length > 0 ? { ffiImports: [...this.ffiImports] } : {}),
};
if (module) sanitizeUnregisteredClassTypes(module, (name) => this.classes.has(name));
return {
module,
diagnostics: this.diags,
@@ -3847,40 +3846,6 @@ export class Lowerer {
};
}
/** The unregistered-class type sweep (run()'s last step before the
* module assembles): every `{kind:"object"}` TYPE naming a class with
* no registered ClassInfo is rewritten IN PLACE to the f64 dummy.
* classval types are exempt (they emit the class-independent
* `ScrClassObj *` — inert-but-valid storage, the validator's own
* stance), and only type objects rewrite — node-level classNames
* (`new`, upcasts) cannot reach here (their lowerings fence without a
* registered class), so the validator still backstops those. */
sanitizeUnregisteredClassTypes(roots: unknown[]): void {
const isUnregisteredObjectType = (v: unknown): boolean =>
typeof v === "object" && v !== null &&
(v as { kind?: unknown }).kind === "object" &&
typeof (v as { className?: unknown }).className === "string" &&
!this.classes.has((v as { className: string }).className);
const sweep = (node: unknown): void => {
if (node === null || typeof node !== "object") return;
if (Array.isArray(node)) {
node.forEach((item, i) => {
if (isUnregisteredObjectType(item)) node[i] = F64;
else sweep(item);
});
return;
}
const rec = node as Record<string, unknown>;
for (const key of Object.keys(rec)) {
if (key === "loc") continue;
const v = rec[key];
if (isUnregisteredObjectType(v)) rec[key] = F64;
else sweep(v);
}
};
for (const root of roots) sweep(root);
}
/** True when `t` (recursively) names a class instance type with no
* registered ClassInfo — the shape of a JS class whose collection fenced.
* Used by run()'s global pruning; shapes/unions recurse with a seen-set
@@ -7812,6 +7777,17 @@ export class Lowerer {
* lowering goes through here (via lowerExprExpecting) or calls this
* directly when the expression was already lowered. */
coerceInto(node: ts.Node, expr: IrExpr, expected: IrType): IrExpr {
// A union destination can accept the binding's tagged storage directly.
// The checker's single-arm flow type can be stale after a callback writes
// the binding. Extracting that arm and wrapping it again would discard the
// actual tag and could read a different record layout. Retag the stored
// value instead; ordinary coercion checks any excluded destination arms.
// Explicit assertions keep their own conversion and validation.
if (expected.kind === "union" && expr.kind === "unionNarrow") {
let source = node;
while (ts.isParenthesizedExpression(source) || ts.isSatisfiesExpression(source)) source = source.expression;
if (ts.isIdentifier(source) || ts.isShorthandPropertyAssignment(source)) expr = expr.value;
}
// A fresh literal can retain a wider runtime-optional field after its
// initial contextual layout was chosen. Its known discriminator still
// selects the destination arm; validate that payload before wrapping.
@@ -0,0 +1,34 @@
import { BOOL, STRING, type IrExpr, type IrType, type SrcLoc, isUnitType, typeEquals } from "../../ir/ir.js";
import { varRef } from "../../ir/build.js";
import type { Lowerer } from "./lowerer.js";
/** Array/property reads retain a missing-value arm even when the checker
* promises a record. Object enumeration must check it before reading fields. */
export function objectEnumerationReceiver(
lowerer: Lowerer, receiver: IrExpr, expected: IrType & { kind: "record" }, loc: SrcLoc,
): IrExpr {
if (receiver.type.kind !== "union") return receiver;
const source = receiver.type;
const arms = lowerer.unions.get(source.unionId)?.arms;
const tag = lowerer.armTag(source.unionId, expected);
if (tag < 0 || !arms?.every((arm) => isUnitType(arm) || typeEquals(arm, expected))) return receiver;
const key = `obj.receiver:${source.unionId}:${expected.shapeId}`;
let name = lowerer.arrHofHelpers.get(key);
if (name === undefined) {
name = `%obj.receiver.${lowerer.arrHofHelpers.size}`;
lowerer.arrHofHelpers.set(key, name);
const value = varRef("value.0", source, loc);
lowerer.liftedFns.push({
name, params: [{ localId: "value.0", name: "value", type: source }],
returnType: expected, locals: [{ id: "value.0", name: "value", type: source, mutable: false }],
body: [{
kind: "if", cond: { kind: "unionIsTag", unionId: source.unionId, tag, value, negated: false, type: BOOL, loc },
then: [{ kind: "return", value: { kind: "unionNarrow", unionId: source.unionId, tag, value, type: expected, loc }, loc }],
else_: [{ kind: "throw", value: { kind: "libCall", fn: "error.new",
args: [{ kind: "strLit", value: "Cannot convert undefined or null to object", type: STRING, loc }],
type: { kind: "object", className: "%TypeError" }, loc }, loc }], loc,
}], loc,
});
}
return { kind: "call", callee: name, args: [receiver], type: expected, loc };
}
@@ -0,0 +1,61 @@
import { expect, test } from "vitest";
import { F64, type IrExpr, type IrModule, type IrType } from "../../ir/ir.js";
import { sanitizeUnregisteredClassTypes } from "./sanitize-class-types.js";
test("fenced instance slots are erased throughout signatures, storage and nested expressions", () => {
const loc = { file: "classes.js", start: 0, end: 1 };
const missing: IrType = { kind: "object", className: "Fenced" };
const kept: IrType = { kind: "object", className: "Registered" };
const constructor: IrType = { kind: "classval", className: "Fenced" };
const composite: IrType = { kind: "func", rest: true, restAbi: "typed", argumentsAll: true,
params: [{ kind: "array", elem: missing }, { kind: "set", elem: missing },
{ kind: "map", key: missing, value: { kind: "promise", inner: missing } }],
ret: { kind: "generator", async: true, yieldT: missing, retT: missing, nextT: missing } };
const ref = (type: IrType): IrExpr => ({ kind: "varRef", localId: "x", type, loc });
const module: IrModule = {
irVersion: 13, sourceFile: loc.file, entry: "main",
globals: [{ id: "g", name: "g", type: composite, mutable: false }],
classes: [{ name: "Registered", fields: [{ name: "value", type: missing }],
localCaptures: [{ localId: "x", name: "x", type: missing }], loc }],
records: [{ id: "r", fields: [{ name: "value", type: missing },
{ name: "next", type: { kind: "union", unionId: "u" } }], indexValue: missing }],
unions: [{ id: "u", arms: [missing, { kind: "record", shapeId: "r" }, kept, constructor] }],
functions: [{ name: "main", returnType: missing, loc,
params: [{ localId: "x", name: "x", type: missing }],
locals: [{ id: "x", name: "x", type: missing, mutable: false }],
captures: [{ localId: "y", name: "y", type: missing }],
classCaptures: [{ localId: "z", name: "z", type: missing, slot: 3 }],
generator: { yieldT: missing, nextT: missing, resultType: { kind: "record", shapeId: "r" } },
body: [{ kind: "if", cond: ref(kept), else_: null, loc, then: [{ kind: "exprStmt", loc,
expr: { kind: "seqExpr", type: missing, loc,
stmts: [{ kind: "exprStmt", expr: ref(composite), loc }],
result: { kind: "new", className: "Fenced", args: [ref(missing)], type: missing, loc } } }] }],
}],
};
// An independent structural oracle checks every nested slot and leaves
// class-value types and node-level names intact, even on invalid nodes.
const expected: unknown = JSON.parse(JSON.stringify(module, (_key, value: unknown) => {
const type = value as { kind?: string; className?: string } | null;
return type?.kind === "object" && type.className === "Fenced" ? F64 : value;
}));
sanitizeUnregisteredClassTypes(module, (name) => name === "Registered");
expect(module).toEqual(expected);
expect(module.functions[0]!.params[0]!.type).toBe(F64);
expect(module.unions![0]!.arms[2]).toBe(kept);
expect(module.unions![0]!.arms[3]).toBe(constructor);
expect(missing).toEqual({ kind: "object", className: "Fenced" });
sanitizeUnregisteredClassTypes(module, (name) => name === "Registered");
expect(module).toEqual(expected);
});
test("modules without optional tables and registered class types retain their structure", () => {
const loc = { file: "registered.ts", start: 0, end: 1 };
const type: IrType = { kind: "object", className: "Registered" };
const module: IrModule = { irVersion: 13, sourceFile: loc.file, entry: "main", functions: [{
name: "main", returnType: type, params: [], locals: [], body: [], loc,
}] };
const before = structuredClone(module);
sanitizeUnregisteredClassTypes(module, () => true);
expect(module).toEqual(before);
expect(module.functions[0]!.returnType).toBe(type);
});
@@ -0,0 +1,68 @@
import { F64, type IrModule, type IrType } from "../../ir/ir.js";
import { everyStmtList } from "../../ir/traverse.js";
/** Replace inert instance slots left by runtime-fenced class declarations.
* Construction of an unregistered class already traps, but its remaining
* storage types must agree across declarations, signatures and expressions.
* Keep class values and node-level class names for the validator to check.
* Traverse typed IR directly: boxing a whole module into unknown recursively
* copies its records and makes this pass costly in the native compiler. */
export function sanitizeUnregisteredClassTypes(module: IrModule, hasClass: (name: string) => boolean): void {
const rewrite = (type: IrType): IrType => {
switch (type.kind) {
case "object":
return hasClass(type.className) ? type : F64;
case "array":
case "set":
type.elem = rewrite(type.elem);
break;
case "map":
type.key = rewrite(type.key);
type.value = rewrite(type.value);
break;
case "func":
for (let i = 0; i < type.params.length; i++) type.params[i] = rewrite(type.params[i]!);
type.ret = rewrite(type.ret);
break;
case "promise":
type.inner = rewrite(type.inner);
break;
case "generator":
type.yieldT = rewrite(type.yieldT);
type.retT = rewrite(type.retT);
type.nextT = rewrite(type.nextT);
break;
}
// Record and union references use IDs. Visit each definition below,
// without following those edges through recursive type graphs.
return type;
};
for (const global of module.globals ?? []) global.type = rewrite(global.type);
for (const cls of module.classes ?? []) {
for (const field of cls.fields) field.type = rewrite(field.type);
for (const capture of cls.localCaptures ?? []) capture.type = rewrite(capture.type);
}
for (const record of module.records ?? []) {
for (const field of record.fields) field.type = rewrite(field.type);
if (record.indexValue) record.indexValue = rewrite(record.indexValue);
}
for (const union of module.unions ?? []) {
for (let i = 0; i < union.arms.length; i++) union.arms[i] = rewrite(union.arms[i]!);
}
for (const fn of module.functions) {
fn.returnType = rewrite(fn.returnType);
for (const param of fn.params) param.type = rewrite(param.type);
for (const local of fn.locals) local.type = rewrite(local.type);
for (const capture of fn.captures ?? []) capture.type = rewrite(capture.type);
for (const capture of fn.classCaptures ?? []) capture.type = rewrite(capture.type);
if (fn.generator) {
fn.generator.yieldT = rewrite(fn.generator.yieldT);
fn.generator.nextT = rewrite(fn.generator.nextT);
// resultType is a record reference; its fields are visited above.
}
everyStmtList(fn.body, {
expr: (expr) => { expr.type = rewrite(expr.type); return true; },
stmt: () => true,
});
}
}
@@ -1,6 +1,6 @@
import { runFrontend, type Frontend } from "./pipeline.js";
import { loadProgram } from "./program.js";
import { FrontendServices } from "./services.js";
import { FrontendServices, type ComptimeEvaluator } from "./services.js";
import { createNativeTs7Api } from "./ts7/native-api.js";
/** Native callers supply the installed TS7 executable explicitly. The
@@ -11,13 +11,15 @@ export function runNativeFrontend(
executable: string,
npmStatic?: readonly string[] | "auto" | "lib",
externalTypes?: Readonly<Record<string, string>>,
evaluateComptime?: ComptimeEvaluator,
libraryNpmStatic: readonly string[] = [],
): Frontend {
const services = new FrontendServices((options) => createNativeTs7Api({ ...options, executable }));
const services = new FrontendServices((options) => createNativeTs7Api({ ...options, executable }), process.cwd(), evaluateComptime);
try {
const frontend = runFrontend(entryPath, (path, options) => loadProgram(path, services, {
npmStatic: options.npmStatic ?? [],
externalTypes: Object.entries(options.externalTypes ?? {}),
}), npmStatic, externalTypes);
}), npmStatic, externalTypes, libraryNpmStatic);
return {
...frontend,
dispose: () => {
@@ -59,6 +59,14 @@ export interface Frontend {
dispose: () => void;
}
/** Hosts own transport lifetimes; shared compiler stages own the frontend. */
export type FrontendFactory = (
entryPath: string,
npmStatic?: readonly string[] | "auto" | "lib",
externalTypes?: Readonly<Record<string, string>>,
libraryNpmStatic?: readonly string[],
) => Frontend;
/** --npm-static=auto (and library mode's mandatory twin): one throwaway
* load finds every bare npm import the program's own modules make, then
* the eligibility heuristics (npm-static.ts) pick the packages whose
@@ -0,0 +1,452 @@
/* `--provenance-sources` — the provenance-assisted static compilation
* pipeline (EXPERIMENTAL prototype; the registry doc in
* provenance-registry.ts states the thesis).
*
* For every bare npm specifier the entry's module graph imports, this
* asks the npm registry for the package's PROVENANCE ATTESTATION
* (GET registry.npmjs.org/-/npm/v1/attestations/<pkg>@<version> — the
* SLSA predicate names the exact {repository, commit} the published dist
* was built from), fetches that source tree once (content-addressed cache
* under ~/.cache/scriptc/provenance/<gitCommit>), locates the package
* directory inside it (monorepos publish from subdirectories), and maps
* the published entry to its TypeScript source (dist/lib/build targets
* rewritten to their src twins). Mapped packages compile as ordinary
* program modules — real types, real statements, the static frontier —
* and everything that cannot be mapped FALLS BACK to the island path
* with a note; a fallback is never a build failure.
*
* PRODUCTION GAPS, deliberately unbuilt in the prototype: no sigstore
* bundle verification (the attestation is trusted as served), no
* dist-tarball ↔ source build reproduction (the behavior differential is
* the honest check today), source-entry mapping is heuristic (exports
* targets rewritten dist→src), and transitive dependency versions
* resolve from the DRIVER's installed tree rather than the package's own
* lockfile.
*
* Offline/test hook: SCRIPTC_PROVENANCE_MANIFEST=<path.json> pre-seeds
* {"packages": {"<name>": {"dir": "<source pkg dir>", "commit"?, "repo"?}}}
* — those packages skip the network entirely (harness fixtures ride
* this); unlisted packages still take the live pipeline. */
import { execFileSync } from "node:child_process";
import { mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
import { builtinModules } from "node:module";
import { homedir, tmpdir } from "node:os";
import { dirname, isAbsolute, join, resolve } from "node:path";
import { sourceImportsOfFile } from "./module-syntax.js";
import type { SourceFile } from "./ts7/ast-types.js";
import type { Ts7SourceKind } from "./ts7/source-parser.js";
import { resolveExports, resolveRelativeModule } from "./resolve.js";
import { packageNameOfSpecifier as packageNameOf } from "./workspace-registry.js";
import type { ProvenancePackageSource, ProvenanceSources } from "./provenance-registry.js";
export type ProvenanceParser = (path: string, source: string, kind: Ts7SourceKind) => SourceFile;
const NODE_IMPORT_CONDITIONS = new Set(["import", "node", "default"]);
const NODE_BUILTINS = new Set(builtinModules);
/** Hard cap on packages one compile will source-map (prototype guard). */
const MAX_PACKAGES = 16;
function isFile(path: string): boolean {
try {
return statSync(path).isFile();
} catch {
return false;
}
}
function isDirectory(path: string): boolean {
try {
return statSync(path).isDirectory();
} catch {
return false;
}
}
function readJson(path: string): Record<string, unknown> | null {
try {
return JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>;
} catch {
return null;
}
}
/* ── the bare-import prescan ─────────────────────────────────────────────
* The provenance pipeline runs BEFORE the program loads (tsgo needs the
* "paths" mapping at creation), so the bare specifiers come from a light
* parse walk of the entry's RELATIVE import closure — the same specifier
* collection shapes npm.ts scans embedded modules with, over the shared
* native TypeScript syntax service. */
function moduleSpecifiersLite(source: string, fileName: string, parseSourceFile: ProvenanceParser): { spec: string; typeOnly: boolean }[] {
return sourceImportsOfFile(parseSourceFile(fileName, source, "ts"));
}
/** Every bare (non-relative, non-builtin, non-"#") VALUE specifier the
* relative closure of `roots` imports, in first-encounter order. */
function bareImportsOf(roots: readonly string[], parseSourceFile: ProvenanceParser): string[] {
const seenFiles = new Set<string>();
const bare: string[] = [];
const bareSeen = new Set<string>();
const queue = [...roots];
while (queue.length > 0) {
const file = resolve(queue.shift()!);
if (seenFiles.has(file)) continue;
seenFiles.add(file);
let text: string;
try {
text = readFileSync(file, "utf8");
} catch {
continue;
}
for (const { spec, typeOnly } of moduleSpecifiersLite(text, file, parseSourceFile)) {
if (typeOnly) continue;
if (spec.startsWith("./") || spec.startsWith("../")) {
const dep = resolveRelativeModule(file, spec);
if (dep !== null && !dep.endsWith(".json") && !dep.endsWith(".d.ts")) queue.push(dep);
continue;
}
if (spec.startsWith("#") || spec.startsWith("node:")) continue;
if (NODE_BUILTINS.has(packageNameOf(spec))) continue;
if (!bareSeen.has(spec)) {
bareSeen.add(spec);
bare.push(spec);
}
}
}
return bare;
}
/* ── installed-package lookup (name/version/published entry) ──────────── */
interface InstalledPackage {
dir: string;
name: string;
version: string;
pkgJson: Record<string, unknown>;
}
/** node_modules/<name> walking up from `fromDir`, realpath-free (the
* published package.json is all this needs). */
function findInstalled(fromDir: string, name: string): InstalledPackage | null {
for (let dir = fromDir; ; ) {
const candidate = join(dir, "node_modules", name);
const pkgJson = readJson(join(candidate, "package.json"));
if (pkgJson !== null && typeof pkgJson["version"] === "string") {
return {
dir: candidate,
name: typeof pkgJson["name"] === "string" ? pkgJson["name"] : name,
version: pkgJson["version"],
pkgJson,
};
}
const parent = dirname(dir);
if (parent === dir) return null;
dir = parent;
}
}
/* ── attestation → {repo, commit} ──────────────────────────────────────── */
interface Attested {
repo: string;
commit: string;
}
/** GET the npm attestation set and extract the SLSA provenance
* predicate's resolved source dependency. Throws with a one-line reason
* on every failure shape (no attestation, network, malformed). */
async function fetchAttestation(name: string, version: string): Promise<Attested> {
const url = `https://registry.npmjs.org/-/npm/v1/attestations/${encodeURIComponent(`${name}@${version}`).replace(/%40/g, "@").replace(/%2F/gi, "/")}`;
const res = await fetch(url, { signal: AbortSignal.timeout(30_000) });
if (res.status === 404) throw new Error("no provenance attestation published");
if (!res.ok) throw new Error(`attestation fetch failed (HTTP ${res.status})`);
const body = (await res.json()) as { attestations?: { predicateType?: string; bundle?: { dsseEnvelope?: { payload?: string } } }[] };
for (const att of body.attestations ?? []) {
if (!att.predicateType?.startsWith("https://slsa.dev/provenance")) continue;
const payload = att.bundle?.dsseEnvelope?.payload;
if (payload === undefined) continue;
const stmt = JSON.parse(Buffer.from(payload, "base64").toString("utf8")) as {
predicate?: { buildDefinition?: { resolvedDependencies?: { uri?: string; digest?: { gitCommit?: string } }[] } };
};
for (const dep of stmt.predicate?.buildDefinition?.resolvedDependencies ?? []) {
const commit = dep.digest?.gitCommit;
if (typeof commit === "string" && commit !== "" && typeof dep.uri === "string") {
return { repo: dep.uri, commit };
}
}
}
throw new Error("attestation set carries no SLSA provenance predicate");
}
/* ── source fetch (content-addressed by the attested commit) ──────────── */
function cacheRoot(): string {
return process.env["SCRIPTC_PROVENANCE_CACHE"] ?? join(homedir(), ".cache", "scriptc", "provenance");
}
/** The cached source tree for an attested commit, fetching it once from
* codeload (github repos only in the prototype). Returns the tree root. */
async function fetchSourceTree(repo: string, commit: string): Promise<string> {
const dest = join(cacheRoot(), commit);
if (isDirectory(dest)) return dest;
const m = /github\.com\/([^/]+)\/([^/@#]+)/.exec(repo);
if (m === null) throw new Error(`source repository is not a github URL (${repo})`);
const url = `https://codeload.github.com/${m[1]}/${m[2]}/tar.gz/${commit}`;
const res = await fetch(url, { signal: AbortSignal.timeout(120_000) });
if (!res.ok) throw new Error(`source fetch failed (HTTP ${res.status} for ${url})`);
const bytes = Buffer.from(await res.arrayBuffer());
mkdirSync(cacheRoot(), { recursive: true });
const tmp = mkdtempSync(join(tmpdir(), "scriptc-provenance-"));
try {
const tarball = join(tmp, "src.tgz");
writeFileSync(tarball, bytes);
const extractDir = join(tmp, "tree");
mkdirSync(extractDir);
execFileSync("tar", ["-xzf", tarball, "-C", extractDir, "--strip-components=1"]);
try {
renameSync(extractDir, dest);
} catch {
// A parallel compile won the rename — its tree is the same content.
if (!isDirectory(dest)) throw new Error("source cache rename failed");
}
} finally {
rmSync(tmp, { recursive: true, force: true });
}
return dest;
}
/* ── package dir + source-entry mapping ────────────────────────────────── */
/** The directory inside `tree` whose package.json names `name`: the root,
* then the conventional monorepo layouts, then a bounded scan. */
function locatePackageDir(tree: string, name: string): string | null {
const nameOf = (dir: string): string | null => {
const pkg = readJson(join(dir, "package.json"));
return pkg !== null && typeof pkg["name"] === "string" ? pkg["name"] : null;
};
if (nameOf(tree) === name) return tree;
const bare = name.startsWith("@") ? name.split("/")[1]! : name;
const candidates = [
join(tree, "packages", bare),
join(tree, "packages", name),
join(tree, "packages", name.replace("@", "").replace("/", "-")),
join(tree, "packages", `babel-${bare}`),
];
for (const c of candidates) {
if (nameOf(c) === name) return c;
}
// Bounded breadth-first scan (depth ≤ 3, node_modules/.git skipped).
const queue: { dir: string; depth: number }[] = [{ dir: tree, depth: 0 }];
while (queue.length > 0) {
const { dir, depth } = queue.shift()!;
if (depth > 0 && nameOf(dir) === name) return dir;
if (depth >= 3) continue;
let entries: string[];
try {
entries = readdirSync(dir).filter((entry) => isDirectory(join(dir, entry))).sort();
} catch {
continue;
}
for (const e of entries) {
if (e === "node_modules" || e.startsWith(".")) continue;
queue.push({ dir: join(dir, e), depth: depth + 1 });
}
}
return null;
}
/** The published dist target for `subpath` — "exports" with the import
* condition, else module/main/types fields (root subpath only). */
function publishedTargetOf(pkgJson: Record<string, unknown>, subpath: string): string | null {
if (pkgJson["exports"] !== undefined) {
return resolveExports(pkgJson["exports"], subpath, NODE_IMPORT_CONDITIONS);
}
if (subpath !== ".") return subpath;
for (const field of ["module", "main", "types"]) {
const v = pkgJson[field];
if (typeof v === "string" && v !== "") return v;
}
return "index.js";
}
/** dist target → source file, heuristically: the built path's leading
* dist/lib/build segment rewrites to src (or drops), an esm/cjs/dts flavor
* segment is skipped for a shared TypeScript source tree, extensions
* rewrite to TypeScript twins, and the root entry falls back to the
* conventional src/index.ts homes. First existing candidate wins. */
function mapEntryToSource(pkgDir: string, target: string, subpath: string): string | null {
const rel = target.replace(/^\.\//, "");
const stems = new Set<string>([rel]);
const distRe = /^(dist|lib|build|out|output|dist-node|dist-src)\//;
if (distRe.test(rel)) {
const withoutDist = rel.replace(distRe, "");
if (/^(esm|cjs|dts)\//.test(withoutDist)) {
stems.add(`src/${withoutDist.replace(/^(esm|cjs|dts)\//, "")}`);
}
stems.add(rel.replace(distRe, "src/"));
stems.add(withoutDist);
} else {
stems.add(`src/${rel}`);
}
const candidates: string[] = [];
for (const stem of stems) {
const base = stem.replace(/\.d\.(ts|mts|cts)$/, ".$1").replace(/\.(js|mjs|cjs)$/, "");
if (/\.(ts|tsx|mts|cts)$/.test(base)) candidates.push(base);
else {
candidates.push(`${base}.ts`, `${base}.mts`, `${base}.cts`, `${base}.tsx`);
candidates.push(join(base, "index.ts"));
}
}
if (subpath === ".") {
candidates.push("src/index.ts", "src/index.mts", "index.ts", "src/index.tsx");
}
for (const c of candidates) {
const abs = join(pkgDir, c);
if (isFile(abs)) return abs;
}
return null;
}
/* ── the pipeline ─────────────────────────────────────────────────────── */
interface ManifestEntry {
dir: string;
commit?: string;
repo?: string;
}
function readManifest(): Map<string, ManifestEntry> {
const path = process.env["SCRIPTC_PROVENANCE_MANIFEST"];
const out = new Map<string, ManifestEntry>();
if (path === undefined || path === "") return out;
const manifest = readJson(resolve(path));
const packages = manifest?.["packages"];
if (packages === null || typeof packages !== "object") return out;
for (const [name, raw] of Object.entries(packages as Record<string, unknown>)) {
if (raw === null || typeof raw !== "object") continue;
const e = raw as { dir?: unknown; commit?: unknown; repo?: unknown };
if (typeof e.dir !== "string") continue;
const dir = isAbsolute(e.dir) ? e.dir : resolve(dirname(resolve(path)), e.dir);
out.set(name, {
dir,
...(typeof e.commit === "string" ? { commit: e.commit } : {}),
...(typeof e.repo === "string" ? { repo: e.repo } : {}),
});
}
return out;
}
/** Resolves provenance sources for everything the entry's module graph
* imports (one transitive round per newly-mapped tree: source imports of
* OTHER packages try the pipeline too). Never throws for a package
* failure — those become notes and the package keeps its island path. */
export async function resolveProvenanceSourcesWithParser(entryPath: string, parseSourceFile: ProvenanceParser): Promise<ProvenanceSources> {
const entry = resolve(entryPath);
const manifest = readManifest();
const packages: ProvenancePackageSource[] = [];
const notes: string[] = [];
/** package name → mapped package (or null after a noted failure). */
const processed = new Map<string, ProvenancePackageSource | null>();
/** All specifiers seen so far, grouped by package name. */
const specifiersByPackage = new Map<string, Set<string>>();
const enqueue = (specs: readonly string[]): string[] => {
const newNames: string[] = [];
for (const spec of specs) {
const name = packageNameOf(spec);
let set = specifiersByPackage.get(name);
if (set === undefined) {
specifiersByPackage.set(name, (set = new Set()));
newNames.push(name);
}
set.add(spec);
}
return newNames;
};
const mapOne = async (name: string): Promise<void> => {
if (processed.has(name)) return;
if (processed.size >= MAX_PACKAGES) {
processed.set(name, null);
notes.push(`${name}: skipped — provenance package limit (${MAX_PACKAGES}) reached; island path used`);
return;
}
processed.set(name, null); // claimed; overwritten on success
const installed = findInstalled(dirname(entry), name);
if (installed === null) {
notes.push(`${name}: not installed under the entry's node_modules; island path used`);
return;
}
let dir: string;
let repo: string;
let commit: string;
const seeded = manifest.get(name);
try {
if (seeded !== undefined) {
dir = seeded.dir;
repo = seeded.repo ?? "(manifest)";
commit = seeded.commit ?? "(manifest)";
if (!isDirectory(dir)) throw new Error(`manifest dir does not exist (${dir})`);
} else {
const attested = await fetchAttestation(installed.name, installed.version);
repo = attested.repo;
commit = attested.commit;
const tree = await fetchSourceTree(repo, commit);
const located = locatePackageDir(tree, installed.name);
if (located === null) {
throw new Error(`package directory not found inside the attested source tree (${tree})`);
}
dir = located;
}
const entries: Record<string, string> = {};
for (const spec of specifiersByPackage.get(name) ?? new Set<string>()) {
const parts = spec.split("/");
const nameLen = spec.startsWith("@") ? 2 : 1;
const subpath = parts.length === nameLen ? "." : `./${parts.slice(nameLen).join("/")}`;
const target = publishedTargetOf(installed.pkgJson, subpath);
const source = target === null ? null : mapEntryToSource(dir, target, subpath);
if (source === null) {
notes.push(
`${name}@${installed.version}: no source mapping for '${spec}' (published target: ${target ?? "unexported"}); island path used`,
);
continue;
}
entries[spec] = source;
}
if (Object.keys(entries).length === 0) return;
const srcPkg = readJson(join(dir, "package.json"));
const sourceVersion = typeof srcPkg?.["version"] === "string" ? srcPkg["version"] : undefined;
const pkg: ProvenancePackageSource = {
name: installed.name,
version: installed.version,
...(sourceVersion !== undefined && sourceVersion !== installed.version ? { sourceVersion } : {}),
repo,
commit,
dir,
entries,
};
if (pkg.sourceVersion !== undefined) {
notes.push(
`${name}: source tree's package.json says ${pkg.sourceVersion}, installed is ${installed.version} (release tooling that bumps at publish) — the behavior differential is the check`,
);
}
packages.push(pkg);
processed.set(name, pkg);
// One transitive round: the mapped source's own bare imports try
// the pipeline too (versions resolve from the DRIVER's tree — a
// prototype heuristic; production wants the package's lockfile).
const inner = enqueue(bareImportsOf(Object.values(entries), parseSourceFile));
for (const n of inner) await mapOne(n);
} catch (e) {
notes.push(`${name}@${installed.version}: ${e instanceof Error ? e.message : String(e)}; island path used`);
}
};
for (const name of enqueue(bareImportsOf([entry], parseSourceFile))) {
await mapOne(name);
}
return { packages, notes };
}
+4 -450
View File
@@ -1,453 +1,7 @@
/* `--provenance-sources` — the provenance-assisted static compilation
* pipeline (EXPERIMENTAL prototype; the registry doc in
* provenance-registry.ts states the thesis).
*
* For every bare npm specifier the entry's module graph imports, this
* asks the npm registry for the package's PROVENANCE ATTESTATION
* (GET registry.npmjs.org/-/npm/v1/attestations/<pkg>@<version> — the
* SLSA predicate names the exact {repository, commit} the published dist
* was built from), fetches that source tree once (content-addressed cache
* under ~/.cache/scriptc/provenance/<gitCommit>), locates the package
* directory inside it (monorepos publish from subdirectories), and maps
* the published entry to its TypeScript source (dist/lib/build targets
* rewritten to their src twins). Mapped packages compile as ordinary
* program modules — real types, real statements, the static frontier —
* and everything that cannot be mapped FALLS BACK to the island path
* with a note; a fallback is never a build failure.
*
* PRODUCTION GAPS, deliberately unbuilt in the prototype: no sigstore
* bundle verification (the attestation is trusted as served), no
* dist-tarball ↔ source build reproduction (the behavior differential is
* the honest check today), source-entry mapping is heuristic (exports
* targets rewritten dist→src), and transitive dependency versions
* resolve from the DRIVER's installed tree rather than the package's own
* lockfile.
*
* Offline/test hook: SCRIPTC_PROVENANCE_MANIFEST=<path.json> pre-seeds
* {"packages": {"<name>": {"dir": "<source pkg dir>", "commit"?, "repo"?}}}
* — those packages skip the network entirely (harness fixtures ride
* this); unlisted packages still take the live pipeline. */
import { execFile } from "node:child_process";
import { readFileSync, readdirSync, statSync } from "node:fs";
import { mkdir, mkdtemp, rename, rm, writeFile } from "node:fs/promises";
import { builtinModules } from "node:module";
import { homedir, tmpdir } from "node:os";
import { dirname, isAbsolute, join, resolve } from "node:path";
import { promisify } from "node:util";
import { sourceImportsOfFile } from "./module-syntax.js";
import { parseSourceFile } from "./ts7/source-parser-node.js";
import { resolveExports, resolveRelativeModule } from "./resolve.js";
import { packageNameOfSpecifier as packageNameOf } from "./workspace-registry.js";
import type { ProvenancePackageSource, ProvenanceSources } from "./provenance-registry.js";
import { resolveProvenanceSourcesWithParser } from "./provenance-core.js";
import type { ProvenanceSources } from "./provenance-registry.js";
const NODE_IMPORT_CONDITIONS = new Set(["import", "node", "default"]);
const execFileAsync = promisify(execFile);
const NODE_BUILTINS = new Set(builtinModules);
/** Hard cap on packages one compile will source-map (prototype guard). */
const MAX_PACKAGES = 16;
function isFile(path: string): boolean {
try {
return statSync(path).isFile();
} catch {
return false;
}
}
function isDirectory(path: string): boolean {
try {
return statSync(path).isDirectory();
} catch {
return false;
}
}
function readJson(path: string): Record<string, unknown> | null {
try {
return JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>;
} catch {
return null;
}
}
/* ── the bare-import prescan ─────────────────────────────────────────────
* The provenance pipeline runs BEFORE the program loads (tsgo needs the
* "paths" mapping at creation), so the bare specifiers come from a light
* parse walk of the entry's RELATIVE import closure — the same specifier
* collection shapes npm.ts scans embedded modules with, over the shared
* native TypeScript syntax service. */
function moduleSpecifiersLite(source: string, fileName: string): { spec: string; typeOnly: boolean }[] {
return sourceImportsOfFile(parseSourceFile(fileName, source, "ts"));
}
/** Every bare (non-relative, non-builtin, non-"#") VALUE specifier the
* relative closure of `roots` imports, in first-encounter order. */
function bareImportsOf(roots: readonly string[]): string[] {
const seenFiles = new Set<string>();
const bare: string[] = [];
const bareSeen = new Set<string>();
const queue = [...roots];
while (queue.length > 0) {
const file = resolve(queue.shift()!);
if (seenFiles.has(file)) continue;
seenFiles.add(file);
let text: string;
try {
text = readFileSync(file, "utf8");
} catch {
continue;
}
for (const { spec, typeOnly } of moduleSpecifiersLite(text, file)) {
if (typeOnly) continue;
if (spec.startsWith("./") || spec.startsWith("../")) {
const dep = resolveRelativeModule(file, spec);
if (dep !== null && !dep.endsWith(".json") && !dep.endsWith(".d.ts")) queue.push(dep);
continue;
}
if (spec.startsWith("#") || spec.startsWith("node:")) continue;
if (NODE_BUILTINS.has(packageNameOf(spec))) continue;
if (!bareSeen.has(spec)) {
bareSeen.add(spec);
bare.push(spec);
}
}
}
return bare;
}
/* ── installed-package lookup (name/version/published entry) ──────────── */
interface InstalledPackage {
dir: string;
name: string;
version: string;
pkgJson: Record<string, unknown>;
}
/** node_modules/<name> walking up from `fromDir`, realpath-free (the
* published package.json is all this needs). */
function findInstalled(fromDir: string, name: string): InstalledPackage | null {
for (let dir = fromDir; ; ) {
const candidate = join(dir, "node_modules", name);
const pkgJson = readJson(join(candidate, "package.json"));
if (pkgJson !== null && typeof pkgJson["version"] === "string") {
return {
dir: candidate,
name: typeof pkgJson["name"] === "string" ? pkgJson["name"] : name,
version: pkgJson["version"],
pkgJson,
};
}
const parent = dirname(dir);
if (parent === dir) return null;
dir = parent;
}
}
/* ── attestation → {repo, commit} ──────────────────────────────────────── */
interface Attested {
repo: string;
commit: string;
}
/** GET the npm attestation set and extract the SLSA provenance
* predicate's resolved source dependency. Throws with a one-line reason
* on every failure shape (no attestation, network, malformed). */
async function fetchAttestation(name: string, version: string): Promise<Attested> {
const url = `https://registry.npmjs.org/-/npm/v1/attestations/${encodeURIComponent(`${name}@${version}`).replace(/%40/g, "@").replace(/%2F/gi, "/")}`;
const res = await fetch(url, { signal: AbortSignal.timeout(30_000) });
if (res.status === 404) throw new Error("no provenance attestation published");
if (!res.ok) throw new Error(`attestation fetch failed (HTTP ${res.status})`);
const body = (await res.json()) as { attestations?: { predicateType?: string; bundle?: { dsseEnvelope?: { payload?: string } } }[] };
for (const att of body.attestations ?? []) {
if (!att.predicateType?.startsWith("https://slsa.dev/provenance")) continue;
const payload = att.bundle?.dsseEnvelope?.payload;
if (payload === undefined) continue;
const stmt = JSON.parse(Buffer.from(payload, "base64").toString("utf8")) as {
predicate?: { buildDefinition?: { resolvedDependencies?: { uri?: string; digest?: { gitCommit?: string } }[] } };
};
for (const dep of stmt.predicate?.buildDefinition?.resolvedDependencies ?? []) {
const commit = dep.digest?.gitCommit;
if (typeof commit === "string" && commit !== "" && typeof dep.uri === "string") {
return { repo: dep.uri, commit };
}
}
}
throw new Error("attestation set carries no SLSA provenance predicate");
}
/* ── source fetch (content-addressed by the attested commit) ──────────── */
function cacheRoot(): string {
return process.env["SCRIPTC_PROVENANCE_CACHE"] ?? join(homedir(), ".cache", "scriptc", "provenance");
}
/** The cached source tree for an attested commit, fetching it once from
* codeload (github repos only in the prototype). Returns the tree root. */
async function fetchSourceTree(repo: string, commit: string): Promise<string> {
const dest = join(cacheRoot(), commit);
if (isDirectory(dest)) return dest;
const m = /github\.com\/([^/]+)\/([^/@#]+)/.exec(repo);
if (m === null) throw new Error(`source repository is not a github URL (${repo})`);
const url = `https://codeload.github.com/${m[1]}/${m[2]}/tar.gz/${commit}`;
const res = await fetch(url, { signal: AbortSignal.timeout(120_000) });
if (!res.ok) throw new Error(`source fetch failed (HTTP ${res.status} for ${url})`);
const bytes = Buffer.from(await res.arrayBuffer());
await mkdir(cacheRoot(), { recursive: true });
const tmp = await mkdtemp(join(tmpdir(), "scriptc-provenance-"));
try {
const tarball = join(tmp, "src.tgz");
await writeFile(tarball, bytes);
const extractDir = join(tmp, "tree");
await mkdir(extractDir);
await execFileAsync("tar", ["-xzf", tarball, "-C", extractDir, "--strip-components=1"]);
try {
await rename(extractDir, dest);
} catch {
// A parallel compile won the rename — its tree is the same content.
if (!isDirectory(dest)) throw new Error("source cache rename failed");
}
} finally {
await rm(tmp, { recursive: true, force: true });
}
return dest;
}
/* ── package dir + source-entry mapping ────────────────────────────────── */
/** The directory inside `tree` whose package.json names `name`: the root,
* then the conventional monorepo layouts, then a bounded scan. */
function locatePackageDir(tree: string, name: string): string | null {
const nameOf = (dir: string): string | null => {
const pkg = readJson(join(dir, "package.json"));
return pkg !== null && typeof pkg["name"] === "string" ? pkg["name"] : null;
};
if (nameOf(tree) === name) return tree;
const bare = name.startsWith("@") ? name.split("/")[1]! : name;
const candidates = [
join(tree, "packages", bare),
join(tree, "packages", name),
join(tree, "packages", name.replace("@", "").replace("/", "-")),
join(tree, "packages", `babel-${bare}`),
];
for (const c of candidates) {
if (nameOf(c) === name) return c;
}
// Bounded breadth-first scan (depth ≤ 3, node_modules/.git skipped).
const queue: { dir: string; depth: number }[] = [{ dir: tree, depth: 0 }];
while (queue.length > 0) {
const { dir, depth } = queue.shift()!;
if (depth > 0 && nameOf(dir) === name) return dir;
if (depth >= 3) continue;
let entries: string[];
try {
entries = readdirSync(dir).filter((entry) => isDirectory(join(dir, entry))).sort();
} catch {
continue;
}
for (const e of entries) {
if (e === "node_modules" || e.startsWith(".")) continue;
queue.push({ dir: join(dir, e), depth: depth + 1 });
}
}
return null;
}
/** The published dist target for `subpath` — "exports" with the import
* condition, else module/main/types fields (root subpath only). */
function publishedTargetOf(pkgJson: Record<string, unknown>, subpath: string): string | null {
if (pkgJson["exports"] !== undefined) {
return resolveExports(pkgJson["exports"], subpath, NODE_IMPORT_CONDITIONS);
}
if (subpath !== ".") return subpath;
for (const field of ["module", "main", "types"]) {
const v = pkgJson[field];
if (typeof v === "string" && v !== "") return v;
}
return "index.js";
}
/** dist target → source file, heuristically: the built path's leading
* dist/lib/build segment rewrites to src (or drops), an esm/cjs/dts flavor
* segment is skipped for a shared TypeScript source tree, extensions
* rewrite to TypeScript twins, and the root entry falls back to the
* conventional src/index.ts homes. First existing candidate wins. */
function mapEntryToSource(pkgDir: string, target: string, subpath: string): string | null {
const rel = target.replace(/^\.\//, "");
const stems = new Set<string>([rel]);
const distRe = /^(dist|lib|build|out|output|dist-node|dist-src)\//;
if (distRe.test(rel)) {
const withoutDist = rel.replace(distRe, "");
if (/^(esm|cjs|dts)\//.test(withoutDist)) {
stems.add(`src/${withoutDist.replace(/^(esm|cjs|dts)\//, "")}`);
}
stems.add(rel.replace(distRe, "src/"));
stems.add(withoutDist);
} else {
stems.add(`src/${rel}`);
}
const candidates: string[] = [];
for (const stem of stems) {
const base = stem.replace(/\.d\.(ts|mts|cts)$/, ".$1").replace(/\.(js|mjs|cjs)$/, "");
if (/\.(ts|tsx|mts|cts)$/.test(base)) candidates.push(base);
else {
candidates.push(`${base}.ts`, `${base}.mts`, `${base}.cts`, `${base}.tsx`);
candidates.push(join(base, "index.ts"));
}
}
if (subpath === ".") {
candidates.push("src/index.ts", "src/index.mts", "index.ts", "src/index.tsx");
}
for (const c of candidates) {
const abs = join(pkgDir, c);
if (isFile(abs)) return abs;
}
return null;
}
/* ── the pipeline ─────────────────────────────────────────────────────── */
interface ManifestEntry {
dir: string;
commit?: string;
repo?: string;
}
function readManifest(): Map<string, ManifestEntry> {
const path = process.env["SCRIPTC_PROVENANCE_MANIFEST"];
const out = new Map<string, ManifestEntry>();
if (path === undefined || path === "") return out;
const manifest = readJson(resolve(path));
const packages = manifest?.["packages"];
if (packages === null || typeof packages !== "object") return out;
for (const [name, raw] of Object.entries(packages as Record<string, unknown>)) {
if (raw === null || typeof raw !== "object") continue;
const e = raw as { dir?: unknown; commit?: unknown; repo?: unknown };
if (typeof e.dir !== "string") continue;
const dir = isAbsolute(e.dir) ? e.dir : resolve(dirname(resolve(path)), e.dir);
out.set(name, {
dir,
...(typeof e.commit === "string" ? { commit: e.commit } : {}),
...(typeof e.repo === "string" ? { repo: e.repo } : {}),
});
}
return out;
}
/** Resolves provenance sources for everything the entry's module graph
* imports (one transitive round per newly-mapped tree: source imports of
* OTHER packages try the pipeline too). Never throws for a package
* failure — those become notes and the package keeps its island path. */
export async function resolveProvenanceSources(entryPath: string): Promise<ProvenanceSources> {
const entry = resolve(entryPath);
const manifest = readManifest();
const packages: ProvenancePackageSource[] = [];
const notes: string[] = [];
/** package name → mapped package (or null after a noted failure). */
const processed = new Map<string, ProvenancePackageSource | null>();
/** All specifiers seen so far, grouped by package name. */
const specifiersByPackage = new Map<string, Set<string>>();
const enqueue = (specs: readonly string[]): string[] => {
const newNames: string[] = [];
for (const spec of specs) {
const name = packageNameOf(spec);
let set = specifiersByPackage.get(name);
if (set === undefined) {
specifiersByPackage.set(name, (set = new Set()));
newNames.push(name);
}
set.add(spec);
}
return newNames;
};
const mapOne = async (name: string): Promise<void> => {
if (processed.has(name)) return;
if (processed.size >= MAX_PACKAGES) {
processed.set(name, null);
notes.push(`${name}: skipped — provenance package limit (${MAX_PACKAGES}) reached; island path used`);
return;
}
processed.set(name, null); // claimed; overwritten on success
const installed = findInstalled(dirname(entry), name);
if (installed === null) {
notes.push(`${name}: not installed under the entry's node_modules; island path used`);
return;
}
let dir: string;
let repo: string;
let commit: string;
const seeded = manifest.get(name);
try {
if (seeded !== undefined) {
dir = seeded.dir;
repo = seeded.repo ?? "(manifest)";
commit = seeded.commit ?? "(manifest)";
if (!isDirectory(dir)) throw new Error(`manifest dir does not exist (${dir})`);
} else {
const attested = await fetchAttestation(installed.name, installed.version);
repo = attested.repo;
commit = attested.commit;
const tree = await fetchSourceTree(repo, commit);
const located = locatePackageDir(tree, installed.name);
if (located === null) {
throw new Error(`package directory not found inside the attested source tree (${tree})`);
}
dir = located;
}
const entries: Record<string, string> = {};
for (const spec of specifiersByPackage.get(name) ?? []) {
const parts = spec.split("/");
const nameLen = spec.startsWith("@") ? 2 : 1;
const subpath = parts.length === nameLen ? "." : `./${parts.slice(nameLen).join("/")}`;
const target = publishedTargetOf(installed.pkgJson, subpath);
const source = target === null ? null : mapEntryToSource(dir, target, subpath);
if (source === null) {
notes.push(
`${name}@${installed.version}: no source mapping for '${spec}' (published target: ${target ?? "unexported"}); island path used`,
);
continue;
}
entries[spec] = source;
}
if (Object.keys(entries).length === 0) return;
const srcPkg = readJson(join(dir, "package.json"));
const sourceVersion = typeof srcPkg?.["version"] === "string" ? srcPkg["version"] : undefined;
const pkg: ProvenancePackageSource = {
name: installed.name,
version: installed.version,
...(sourceVersion !== undefined && sourceVersion !== installed.version ? { sourceVersion } : {}),
repo,
commit,
dir,
entries,
};
if (pkg.sourceVersion !== undefined) {
notes.push(
`${name}: source tree's package.json says ${pkg.sourceVersion}, installed is ${installed.version} (release tooling that bumps at publish) — the behavior differential is the check`,
);
}
packages.push(pkg);
processed.set(name, pkg);
// One transitive round: the mapped source's own bare imports try
// the pipeline too (versions resolve from the DRIVER's tree — a
// prototype heuristic; production wants the package's lockfile).
const inner = enqueue(bareImportsOf(Object.values(entries)));
for (const n of inner) await mapOne(n);
} catch (e) {
notes.push(`${name}@${installed.version}: ${e instanceof Error ? e.message : String(e)}; island path used`);
}
};
for (const name of enqueue(bareImportsOf([entry]))) {
await mapOne(name);
}
return { packages, notes };
export function resolveProvenanceSources(entryPath: string): Promise<ProvenanceSources> {
return resolveProvenanceSourcesWithParser(entryPath, parseSourceFile);
}
+19 -11
View File
@@ -1041,17 +1041,25 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null {
// user aliases with the same names on the normal structural path.
{
const parseArgsSym = widened.getAliasSymbol() ?? widened.getSymbol();
if (
parseArgsSym &&
PARSE_ARGS_DYN_TYPES.has(parseArgsSym.name) &&
checker.declarationsOf(parseArgsSym).some(
(d) =>
ctx.isStdlibFile(d.getSourceFile()) &&
isDeclaredInAmbientModule(d as ts.Declaration, "util"),
)
) {
return DYN;
}
const belongs = (declaration: ts.Node): boolean => {
if (!ctx.isStdlibFile(declaration.getSourceFile())) return false;
let family = false;
for (let node: ts.Node | undefined = declaration; node; node = node.parent) {
if ((ts.isTypeAliasDeclaration(node) || ts.isInterfaceDeclaration(node)) &&
PARSE_ARGS_DYN_TYPES.has(node.name.text)) family = true;
if (ts.isModuleDeclaration(node) && ts.isStringLiteral(node.name)) {
return family && (node.name.text === "util" || node.name.text === "node:util");
}
}
return false;
};
if (parseArgsSym && PARSE_ARGS_DYN_TYPES.has(parseArgsSym.name) &&
checker.declarationsOf(parseArgsSym).some(belongs)) return DYN;
// ReturnType and instantiated conditional types can erase the alias.
// Require every member to retain the util declaration provenance so
// unrelated records with similar property names keep their own layout.
const members = checker.getPropertiesOfType(widened);
if (members.length > 0 && members.every((member) => checker.declarationsOf(member).some(belongs))) return DYN;
}
// The lib's BOXED wrapper interfaces used as TYPES (`const n: Number =
// 5`): every value such a slot can hold IS the primitive — `new
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -5702,7 +5702,7 @@ export type IrExpr =
* undefined arm of its union (an optional field) is DROPPED from the
* output — Node's rule for undefined-valued properties. The value is
* BORROWED; the result string is owned (+1). Never throws. */
| { kind: "jsonStringify"; value: IrExpr; type: IrType; loc: SrcLoc }
| { kind: "jsonStringify"; value: IrExpr; indent?: string; type: IrType; loc: SrcLoc }
/** The dynamic-boundary check — a CHECKED cast `dynValue as T`: validate
* the dyn value's JSON dyn against `type` (a non-dyn, JSON-representable
* IR type) and BUILD the typed value (+1), or THROW a catchable
@@ -77,7 +77,7 @@ export function frontendOutputExclusions(
if (dirname(directory) === directory) break;
}
}
return { outputPaths: outputArtifacts, outputDirectories };
return { outputPaths: outputArtifacts, outputDirectories: [...outputDirectories] };
}
export async function readCachedFile(path: string, expected: string): Promise<Buffer | null> {
+4 -4
View File
@@ -439,14 +439,14 @@ export function evaluateLibraryFences(mod: IrModule, profile: FenceProfileView):
/* ── the generalized teaching rider ────────────────────────────────────── */
function surfaceMatchesDiag(
surface: { code?: string; name: string; kind: SurfaceEntryKind },
surface: { code?: string | undefined; name: string; kind: SurfaceEntryKind },
diag: ScrDiagnostic,
): boolean {
if (surface.code === undefined || surface.code !== diag.code) return false;
// A diagnostic-fence entry IS the code family. Method refusals use a
// receiver description instead of the manifest's prototype spelling.
if (surface.kind === "diagnostic-fence" || diag.message.includes(surface.name)) return true;
for (const [prefix, receiver] of [["number.prototype.", "numbers"], ["string.prototype.", "strings"]] as const) {
for (const [prefix, receiver] of [["number.prototype.", "numbers"], ["string.prototype.", "strings"]] as [string, string][]) {
if (surface.name.startsWith(prefix)) {
const method = surface.name.slice(prefix.length);
return diag.message.includes(`'.${method}()' on ${receiver}`);
@@ -469,14 +469,14 @@ function teachingForRefusal(profile: FenceProfileView, diag: ScrDiagnostic): str
for (const [key, text] of Object.entries(profile.teachings)) {
if (!key.includes(".")) continue;
const entry = fenceTaxonomy().byId.get(key);
if (entry !== undefined && surfaceMatchesDiag(entry, diag)) return text;
if (entry !== undefined && surfaceMatchesDiag({ code: entry.code, name: entry.name, kind: entry.kind }, diag)) return text;
}
// 3. A fence covering the refused surface: the teaching rides the
// surface's own refusal (the non-static half of fence coverage).
for (const fence of profile.fences) {
if (fence.teaching === undefined) continue;
for (const s of fence.surfaces) {
if (surfaceMatchesDiag(s, diag)) return fence.teaching;
if (surfaceMatchesDiag({ code: s.code, name: s.name, kind: s.kind }, diag)) return fence.teaching;
}
}
return undefined;
+10 -9
View File
@@ -613,8 +613,7 @@ function globalEffectsOf(mod: IrModule): GlobalEffects {
default:
break;
}
for (const key of Object.keys(e) as (keyof typeof e)[]) {
const v = e[key] as unknown;
for (const [key, v] of Object.entries(e as unknown as Record<string, unknown>)) {
if (Array.isArray(v)) {
for (const item of v) {
if (item !== null && typeof item === "object" && "kind" in (item as object)) {
@@ -646,7 +645,7 @@ function globalEffectsOf(mod: IrModule): GlobalEffects {
default:
break;
}
for (const v of Object.values(s) as unknown[]) {
for (const v of Object.values(s as unknown as Record<string, unknown>)) {
if (Array.isArray(v)) {
for (const item of v) {
if (item !== null && typeof item === "object" && typeof (item as { kind?: unknown }).kind === "string") {
@@ -684,7 +683,7 @@ function globalEffectsOf(mod: IrModule): GlobalEffects {
unknown.add(name);
changed = true;
}
for (const g of writes.get(callee) ?? []) {
for (const g of writes.get(callee) ?? new Set<string>()) {
if (!w.has(g)) {
w.add(g);
changed = true;
@@ -936,7 +935,8 @@ class FnAnalyzer {
// could select. Multiple classified fields intentionally emit
// independent obligations (their classes and paths may differ).
if (s.overflowOnly !== true) {
for (const slot of this.cfg.records.get(s.shapeId)?.values() ?? []) {
const recordSlots = this.cfg.records.get(s.shapeId);
for (const slot of recordSlots === undefined ? [] : [...recordSlots.values()]) {
this.emitRecordSlot(v, slot, s.loc);
}
}
@@ -1024,7 +1024,7 @@ class FnAnalyzer {
const assigned = new Set<string>();
const visitStmt = (s: IrStmt): void => {
if (s.kind === "assign" || s.kind === "varDecl") assigned.add(s.localId);
for (const v of Object.values(s) as unknown[]) {
for (const v of Object.values(s as unknown as Record<string, unknown>)) {
if (Array.isArray(v)) {
for (const item of v) {
if (item !== null && typeof item === "object" && typeof (item as { kind?: unknown }).kind === "string") {
@@ -1046,7 +1046,7 @@ class FnAnalyzer {
const visitExpr = (e: IrExpr): void => {
if (e.kind === "assignExpr" || e.kind === "incDec") assigned.add(e.localId);
if (e.kind === "seqExpr") e.stmts.forEach(visitStmt);
for (const v of Object.values(e) as unknown[]) {
for (const v of Object.values(e as unknown as Record<string, unknown>)) {
if (Array.isArray(v)) {
for (const item of v) {
if (item !== null && typeof item === "object" && typeof (item as { kind?: unknown }).kind === "string") {
@@ -1579,7 +1579,8 @@ class FnAnalyzer {
});
}
this.havocCall(e.callee, env);
if (slots?.ret != null) return classSeed(slots.ret);
const returnSlot = slots?.ret;
if (returnSlot != null) return classSeed(returnSlot);
return { ...TOP };
}
case "unionWrap": {
@@ -1716,7 +1717,7 @@ function typeContainsFunc(t: unknown): boolean {
* order for the shapes we don't model precisely). */
function childExprs(e: IrExpr): IrExpr[] {
const out: IrExpr[] = [];
for (const [key, v] of Object.entries(e)) {
for (const [key, v] of Object.entries(e as unknown as Record<string, unknown>)) {
if (key === "type") continue;
if (Array.isArray(v)) {
for (const item of v) {
@@ -442,7 +442,7 @@ class ProfileError extends Error {
}
function req<T>(v: unknown, path: string, kind: "string" | "number" | "boolean"): T {
if (typeof v !== kind) {
if (!(kind === "string" ? typeof v === "string" : kind === "number" ? typeof v === "number" : typeof v === "boolean")) {
throw new ProfileError(`'${path}' must be a ${kind}${v === undefined ? " (missing)" : ""}`);
}
return v as T;
@@ -513,7 +513,7 @@ export function loadLibraryProfile(
if (!Array.isArray(npmStaticRaw) || npmStaticRaw.some((name) => typeof name !== "string" || !/^(?:@[a-z0-9_.-]+\/)?[a-z0-9_][a-z0-9_.-]*$/.test(name))) {
throw new ProfileError("'npm_static' must be an array of npm package names (not subpath specifiers)");
}
const npmStatic = [...new Set(npmStaticRaw)] as string[];
const npmStatic = [...new Set(npmStaticRaw as string[])];
if (name === "") throw new ProfileError("'name' must be a non-empty identity string");
const entryRel = req<string>(p["entry"], "entry", "string");
if (entryRel === "") throw new ProfileError("'entry' must name the profile's one entry module");
@@ -868,11 +868,12 @@ export function loadLibraryProfile(
if (prefix === "") throw new ProfileError(`'${path}.prefix' must be a non-empty manifest id prefix`);
decl.prefix = prefix;
}
for (const rider of ["teaching", "remediation"] as const) {
for (const rider of ["teaching", "remediation"] as ("teaching" | "remediation")[]) {
if (ff[rider] === undefined) continue;
const text = req<string>(ff[rider], `${path}.${rider}`, "string");
checkRiderText(text, `${path}.${rider}`);
decl[rider] = text;
if (rider === "teaching") decl.teaching = text;
else decl.remediation = text;
}
return decl;
});
+665
View File
@@ -0,0 +1,665 @@
import { dirname, resolve } from "node:path";
import type { CompileFailure } from "../compile-types.js";
import { moduleWasiUnavailableSurface, targetRefusalDiag } from "../backend/target-diagnostics.js";
import { checkerPanicDiag, libAsyncExportDiag, libAsyncSurfaceDiag, libExportUnresolvedDiag, libGenericExportDiag, libIntBoundaryDiag, libNpmIneligibleDiag, libSidecarDiag, libUnmappableSignatureDiag, iceDiag, isCheckerPanic, LIB_INBOUND_BYTES_TRAP_CODE, LIB_RUNTIME_TRAP_CODES, type ScrDiagnostic } from "../diagnostics/diagnostic.js";
import { checkLibraryIntegerSlots, classSeed, hasIntSlots, numberCarrierKind, type FnIntSlots, type IntSlotConfig } from "./int-infer.js";
import { profileRemediation, profileTeaching, type LibraryProfile } from "./library-profile.js";
import { decorateLibraryRefusals, evaluateLibraryFences } from "./fence-eval.js";
import { assembleTrapTeaching } from "./trap-teaching.js";
import { buildSidecar, canonicalModuleGraph, canonicalPath, libraryIdentityHashes, type SidecarIntegerSlotFacts, type SidecarIrRecordPattern, type SidecarIrTypePattern } from "./sidecar.js";
import { validateSidecar } from "./sidecar-validate.js";
import type { EntryExportInfo } from "../frontend/lib-exports.js";
import type { ContractFacts } from "../frontend/lib-contract.js";
import type { LowerResult } from "../frontend/lowering/lowerer.js";
import type { FrontendFactory } from "../frontend/pipeline.js";
import { moduleLibAsyncSurface, moduleLibNondeterministicSurface, type IrFfiImport, type IrLibSection, type IrModule, type IrRecordShape, type IrType } from "../ir/ir.js";
import { validateModule } from "../ir/validate.js";
/** The marshalling-class fit over IR types (design §4.2 + the ratified
* integer plumbing classes): number is every f64-backed class, bool/string
* map directly, bytes is the u8 element kind. */
function libClassFits(cls: string, t: IrType): boolean {
switch (cls) {
case "bool":
return t.kind === "bool";
case "string":
return t.kind === "string";
case "bytes":
return t.kind === "bytes" && t.elem === "u8";
default: // f64 and the u8/u32/i32 plumbing classes
return t.kind === "f64";
}
}
/** Resolve the profile's export map against the entry module — SC4002/
* SC4004/SC4007 from the declaration facts, SC4003 from the lowered IR
* signatures — and land the library section on the module. */
function resolveLibrarySection(
profile: LibraryProfile,
entryInfo: Map<string, EntryExportInfo>,
mod: IrModule,
entryPath: string,
): { lib: IrLibSection } | { diagnostics: ScrDiagnostic[] } {
const diagnostics: ScrDiagnostic[] = [];
const entryLoc = { file: entryPath, start: 0, end: 0 };
const fnByName = new Map(mod.functions.map((f) => [f.name, f]));
const exports: IrLibSection["exports"] = [];
for (const e of profile.exports) {
const info = entryInfo.get(e.export);
if (info === undefined) {
diagnostics.push(
libExportUnresolvedDiag(e.export, "the entry module has no exported function declaration by that name", entryLoc),
);
continue;
}
if (info.generic) {
diagnostics.push(libGenericExportDiag(e.export, info.loc));
continue;
}
if (info.async || info.generator) {
diagnostics.push(libAsyncExportDiag(e.export, info.async ? "async" : "generator", info.loc));
continue;
}
const fn = fnByName.get(e.export);
if (fn === undefined) {
diagnostics.push(
libExportUnresolvedDiag(e.export, "the export did not lower to a compiled function", info.loc),
);
continue;
}
if (fn.params.length !== e.params.length) {
diagnostics.push(
libUnmappableSignatureDiag(
e.export,
"signature",
`has ${fn.params.length} parameter(s) but the profile declares ${e.params.length} marshalling class(es)`,
info.loc,
),
);
continue;
}
let bad = false;
e.params.forEach((cls, i) => {
if (!libClassFits(cls, fn.params[i]!.type)) {
bad = true;
diagnostics.push(
libUnmappableSignatureDiag(
e.export,
`parameter ${i + 1} ('${fn.params[i]!.name}')`,
`has IR type '${fn.params[i]!.type.kind}', which does not fit the declared marshalling class '${cls}'`,
info.loc,
),
);
}
});
if (e.returns === "void" ? fn.returnType.kind !== "void" : !libClassFits(e.returns, fn.returnType)) {
bad = true;
diagnostics.push(
libUnmappableSignatureDiag(
e.export,
"the return",
`has IR type '${fn.returnType.kind}', which does not fit the declared marshalling class '${e.returns}'`,
info.loc,
),
);
}
if (!bad) {
const resolvedExport: IrLibSection["exports"][number] = {
symbol: e.symbol,
fnName: e.export,
params: e.params,
returns: e.returns,
};
if (e.params.includes("bytes")) {
// The wrapper's one host-contract trap (an inbound bytes length
// past the marshalling class's range) is assembled HERE, once, as
// the structured trap-teaching message: the profile's teaching for
// SC4012 (or the mode's default text), the code, the trapping
// export's C symbol exactly as the host linked it, and the
// profile's remediation when supplied — so the backend emits the
// same bytes and the sink sees one canonical message.
resolvedExport.inboundBytesTrap = assembleTrapTeaching(
profileTeaching(profile, LIB_INBOUND_BYTES_TRAP_CODE) ??
"scriptc: library inbound bytes length out of range\n",
LIB_INBOUND_BYTES_TRAP_CODE,
e.symbol,
profileRemediation(profile, LIB_INBOUND_BYTES_TRAP_CODE),
);
}
if (e.params.includes("i64") || e.params.includes("u64")) {
// The sibling host-contract trap for inbound declared-integer
// parameters (ask 4): a value past ±(2^53−1) cannot ride f64
// exactly, and silent rounding is a coercion the author never
// wrote. Same code (SC4012 — one host-contract story), same
// assembly-once discipline.
resolvedExport.inboundIntTrap = assembleTrapTeaching(
profileTeaching(profile, LIB_INBOUND_BYTES_TRAP_CODE) ??
"scriptc: library inbound integer parameter out of range\n",
LIB_INBOUND_BYTES_TRAP_CODE,
e.symbol,
profileRemediation(profile, LIB_INBOUND_BYTES_TRAP_CODE),
);
}
exports.push(resolvedExport);
}
}
if (diagnostics.length > 0) return { diagnostics };
// The runtime detected-trap overlay rows: one per family code the profile
// declares teaching or remediation text for, in the registry family's
// order. LLVM emits these rows as the program TU's overlay table,
// which the runtime uses to assemble the sink message. (SC4012 stays compile-time
// assembled into the wrapper's message above and never reaches the
// funnel's assembly path.)
const trapOverlays: IrLibSection["trapOverlays"] = [];
for (const code of LIB_RUNTIME_TRAP_CODES) {
const teaching = profileTeaching(profile, code);
const remediation = profileRemediation(profile, code);
if (teaching !== undefined || remediation !== undefined) {
trapOverlays.push({
code,
...(teaching !== undefined ? { teaching } : {}),
...(remediation !== undefined ? { remediation } : {}),
});
}
}
const lib: IrLibSection = {
profileName: profile.name,
prefix: profile.prefix,
initSymbol: profile.initSymbol,
sinkRegisterSymbol: profile.sinkRegisterSymbol,
collectSymbol: profile.collectSymbol,
resultResetSymbol: profile.resultResetSymbol,
threadInstances: profile.instancePerThread,
// Host-callback channels: declaration order is the runtime slot
// assignment, and the unregistered-call trap text is assembled HERE,
// once, for consistent constant bytes (a DETECTED
// trap: the funnel classifies the "scriptc: library callback "
// prefix as SC4025 and names the entry the host called — the entry
// is runtime knowledge, so no compile-time SC4012-style assembly
// can carry it). Both fields stay absent on callback-free profiles
// (the byte-identity guarantee).
exports,
trapOverlays,
};
if (profile.callbacks.length > 0) {
lib.callbackRegisterSymbol = profile.callbackRegisterSymbol!;
lib.callbacks = profile.callbacks.map((cb, slot) => ({
name: cb.name, slot, params: [...cb.params], returns: cb.returns,
unregisteredTrap: `scriptc: library callback '${cb.name}' invoked before registration\n`,
}));
}
return { lib };
}
/** The export map's integer-slot obligations (ask 4): i64/u64 params and
* returns become declared boundary slots keyed `exports.<name>.params[i]`
* / `exports.<name>.return`; the u8/u32/i32 plumbing classes contribute
* their proven inbound shapes as parameter seeds (the wrapper's coercion
* contract), tightening the intraprocedural analysis at zero declaration
* cost. Sidecar-declared slots (record fields, msg arms, helper params
* and returns) merge into the same config at sidecar build. */
function libraryIntSlotConfig(profile: LibraryProfile): IntSlotConfig {
const cfg: IntSlotConfig = { fns: new Map(), records: new Map() };
for (const e of profile.exports) {
const params = e.params.map((c) => (c === "i64" || c === "u64" ? c : null));
const ret = e.returns === "i64" || e.returns === "u64" ? e.returns : null;
const paramSeeds = e.params.map((c) => (c === "u8" || c === "u32" || c === "i32" ? classSeed(c) : null));
if (params.every((p) => p === null) && ret === null && paramSeeds.every((s) => s === null)) continue;
const slots: FnIntSlots = {
fnName: e.export,
params,
paramPaths: e.params.map((c, i) => (c === "i64" || c === "u64" ? `exports.${e.export}.params[${i}]` : null)),
ret,
retPath: ret !== null ? `exports.${e.export}.return` : null,
paramSeeds,
};
cfg.fns.set(e.export, slots);
}
return cfg;
}
/** Match the sidecar syntax's exact structural type projection against the
* frontend's interned IR registries. The pattern deliberately mirrors
* ShapeRegistry's identity: every field name and recursively mapped field
* type participates. Tagged payload records additionally accept omission
* of their `kind` field because the lowering may carry that discriminant
* only in the surrounding union tag. */
function sidecarRecordMatcher(
mod: IrModule,
): (pattern: SidecarIrRecordPattern, shape: IrRecordShape) => boolean {
const records = new Map((mod.records ?? []).map((shape) => [shape.id, shape]));
const unions = new Map((mod.unions ?? []).map((union) => [union.id, union]));
const recordMatches = (
pattern: SidecarIrRecordPattern,
shape: IrRecordShape,
): boolean => {
if (shape.tuple === true || shape.indexValue !== undefined) return false;
const variants = [pattern.fields];
if (pattern.kindMayBeOmitted === true) {
variants.push(pattern.fields.filter((field) => field.name !== "kind"));
}
return variants.some(
(fields) =>
fields.length === shape.fields.length &&
fields.every((field) => {
const actual = shape.fields.find((candidate) => candidate.name === field.name);
return actual !== undefined && typeMatches(field.type, actual.type);
}),
);
};
const unionMatches = (
patterns: SidecarIrTypePattern[],
actual: IrType[],
): boolean => {
if (patterns.length !== actual.length) return false;
const used = new Set<number>();
const visit = (index: number): boolean => {
if (index === patterns.length) return true;
for (let i = 0; i < actual.length; i++) {
if (used.has(i) || !typeMatches(patterns[index]!, actual[i]!)) continue;
used.add(i);
if (visit(index + 1)) return true;
used.delete(i);
}
return false;
};
return visit(0);
};
const typeMatches = (
pattern: SidecarIrTypePattern,
actual: IrType,
): boolean => {
switch (pattern.kind) {
case "f64":
case "string":
case "bool":
case "nullT":
case "undefinedT":
case "dyn":
return actual.kind === pattern.kind;
case "bytes":
return actual.kind === "bytes" && actual.elem === pattern.elem;
case "array":
return actual.kind === "array" && typeMatches(pattern.elem, actual.elem);
case "record": {
if (actual.kind !== "record") return false;
const shape = records.get(actual.shapeId);
return shape !== undefined && recordMatches(pattern, shape);
}
case "union": {
if (actual.kind !== "union") return false;
const union = unions.get(actual.unionId);
return union !== undefined && unionMatches(pattern.arms, union.arms);
}
}
};
return (pattern, shape) => recordMatches(pattern, shape);
}
/** Merge the sidecar-resolved integer slots (ask 4) into the inference
* config: helper slots key by function name and IR parameter index (the
* projection already shifted past the model receiver); record-field
* slots map onto every interned IR shape whose complete structural field
* signature matches the projected record's. Shapes intern structurally,
* so a same-shaped second type shares the obligation. DECLARED paths with
* the same class coalesce while retaining every source path for verdicts;
* differing classes refuse because one lowered field cannot seed or check
* two distinct class contracts without arm provenance. A
* record fact that matches no shape binds nothing: no compiled code
* constructs the type (the contract surface — init/update/subscriptions
* and every helper — is force-lowered whenever integer slots are
* declared, so this is genuine vacuity, not dead-stripping). */
function mergeSidecarIntSlots(
cfg: IntSlotConfig,
facts: SidecarIntegerSlotFacts,
mod: IrModule,
): { ok: true; config: IntSlotConfig } | { ok: false; diagnostic: ScrDiagnostic } {
const recordMatches = sidecarRecordMatcher(mod);
for (const h of facts.helpers) {
const fn = mod.functions.find((f) => f.name === h.fnName);
const arity = Math.max(fn?.params.length ?? 0, (h.index ?? 0) + 1);
let slots = cfg.fns.get(h.fnName);
if (slots === undefined) {
slots = {
fnName: h.fnName,
params: new Array<null>(arity).fill(null),
paramPaths: new Array<null>(arity).fill(null),
ret: null,
retPath: null,
paramSeeds: new Array<null>(arity).fill(null),
};
cfg.fns.set(h.fnName, slots);
}
if (h.kind === "param") {
const i = h.index!;
while (slots.params.length <= i) {
slots.params.push(null);
slots.paramPaths.push(null);
slots.paramSeeds.push(null);
}
slots.params[i] = h.cls;
slots.paramPaths[i] = h.path;
} else {
slots.ret = h.cls;
slots.retPath = h.path;
}
}
for (const r of facts.records) {
for (const shape of mod.records ?? []) {
if (!recordMatches(r.shape, shape)) continue;
const target = shape.fields.find((f) => f.name === r.targetField);
if (target === undefined || numberCarrierKind(target.type, mod) === null) continue;
let m = cfg.records.get(shape.id);
if (m === undefined) {
m = new Map();
cfg.records.set(shape.id, m);
}
const existing = m.get(r.targetField);
if (existing !== undefined && existing.cls !== r.cls) {
const paths = [
...existing.paths.map((path) => `'${path}' (${existing.cls})`),
`'${r.path}' (${r.cls})`,
];
return {
ok: false,
diagnostic: libSidecarDiag(
`integer slots ${paths.join(" and ")} collapse to the same lowered record field '${r.targetField}' — their proof obligations cannot be kept distinct`,
r.loc,
"kind-tagged union arms and structurally identical records may share one lowered shape — same-class declarations coalesce, but differing classes require distinct structural shapes or at most one classified slot",
),
};
}
if (existing === undefined) {
m.set(r.targetField, { cls: r.cls, paths: [r.path] });
} else if (!existing.paths.includes(r.path)) {
existing.paths.push(r.path);
}
}
}
return { ok: true, config: cfg };
}
export interface PreparedLibrary {
ok: true;
mod: IrModule;
sourceTexts: Map<string, string>;
sidecarJson: string | null;
}
export function prepareLibrary(
profile: LibraryProfile,
profilePath: string,
compilerVersion: string,
buildPlatform: string,
createFrontend: FrontendFactory,
timing: (phase: string, detail?: Record<string, unknown>) => void = () => {},
): PreparedLibrary | CompileFailure {
const entryPath = profile.entry;
// Bare npm specifiers in a library graph take the STATIC-OR-REFUSE
// posture: "lib" runs the same auto-detection and eligibility bar as
// the executable lane's --npm-static (own .d.ts, unminified shipped JS,
// no build-transform markers), automatically — the library path has no
// island/dynamic tier to offer. Explicit profile npm_static entries may
// also attempt source inference; any failed attempt remains a refusal.
const fe = createFrontend(entryPath, "lib", undefined, profile.npmStatic);
timing("frontend-load", {
entry_bytes: fe.entryText().length,
source_files: fe.sourceTexts().size,
});
let lowered: LowerResult;
let sourceTexts: Map<string, string>;
let entryInfo: Map<string, EntryExportInfo>;
let contractFacts: ContractFacts | null;
try {
// Every library refusal leaves through the ask-5 teaching decoration:
// profile text attaches by code, manifest id, or fence coverage as the
// attributed note (the SC4004/SC4005 rider generalized).
const fail = (diagnostics: ScrDiagnostic[]): CompileFailure => ({
ok: false,
diagnostics: decorateLibraryRefusals(diagnostics, profile),
sourceTexts: fe.sourceTexts(),
});
// The npm verdicts FIRST: whatever the shared frontend would have
// served from the island — an eligibility miss, an untyped install, a
// preflight offender inside a package's files, a dropped inferred
// surface — refuses here with the package and the specific bar it
// missed. Checked before the general preflight, whose diagnostics for
// these same imports speak executable-lane teachings (SC1010/SC0001 at
// the unresolvable edge); the library answer is this one.
const npmRefused = fe.npmStatic.filter((s) => s.status === "fallback");
if (npmRefused.length > 0) {
return fail(
npmRefused.map((s) =>
libNpmIneligibleDiag(
s.package,
// The one shared offender reason that narrates the executable
// lane's fallback loses that clause here — no island exists on
// this path to serve anything.
(s.detail ?? "its static compilation was refused").replace("; the island serves the package", ""),
fe.npmImportSites.get(s.package) ?? { file: entryPath, start: 0, end: 0 },
),
),
);
}
if (fe.preflight.length > 0) return fail(fe.preflight);
contractFacts = profile.sidecar !== null ? fe.entryContract() : null;
// Ask 4, contract-surface reachability: when the sidecar declares ANY
// integer slot, the designated init/update/subscriptions exports and
// every contract helper (model-first exported function) seed lowering
// too. They are attested surface — a declared record-field or msg-arm
// class obligates EVERY write those bodies perform, and a declared
// helper param is checked at their internal call sites — so the
// attestation must cover COMPILED bodies, never a dead-stripped
// vacuity (the bug this closes: a model-slot declaration whose only
// writers were dead-stripped attested without any proof).
const contractSurfaceRoots: string[] = [];
if (profile.sidecar !== null && profile.sidecar.integerSlots.length > 0) {
const sc = profile.sidecar;
const fnNames = new Set(contractFacts!.functions.filter((f) => !f.generic).map((f) => f.name));
for (const name of [sc.initExport, sc.updateExport, sc.subscriptionsExport]) {
if (fnNames.has(name)) contractSurfaceRoots.push(name);
}
for (const fn of contractFacts!.functions) {
if (fn.generic) continue;
const first = fn.params[0];
if (first !== undefined && first.shape !== null && first.shape.k === "ref" && first.shape.name === sc.model) {
contractSurfaceRoots.push(fn.name);
}
}
}
// The profile's host-callback channels ride the FFI import machinery:
// each channel is a signature-only ambient binding whose direct calls
// lower to ffiCall nodes (the classes are a subset of the FFI's), and
// `libraryCallbacks` flips the recognition to the library flavor —
// SC4024 diagnostics, unused channels legal, undeclared references
// refused with the callback teaching. The library lane never loads a
// native-FFI manifest, so the channel set owns the surface outright.
const cbImports: IrFfiImport[] = profile.callbacks.map((cb) => ({
name: cb.name,
symbol: cb.name,
params: [...cb.params],
returns: cb.returns,
}));
const integerSlotRoots: string[] = [];
for (const slot of profile.sidecar?.integerSlots ?? []) {
const name = /^helpers\.([^.]+)\.(?:params\[\d+\]|return)$/.exec(slot.slot)?.[1];
if (name !== undefined) integerSlotRoots.push(name);
}
try {
lowered = fe.lower({
dynamic: false,
targetPlatform: buildPlatform,
...(cbImports.length > 0 ? { ffiImports: cbImports } : {}),
...(cbImports.length > 0 ? { libraryCallbacks: true } : {}),
// The profile-mapped exports are called from OUTSIDE the graph:
// they seed reachability beside the entry's top level (an
// executable build would dead-strip an uncalled export). A helper
// with a declared integer slot (ask 4) seeds too: its attestation
// must cover a COMPILED body, never a dead-stripped vacuity — the
// sidecar advertises the slot's class, so the proof must exist.
libRoots: [
...new Set([
...profile.exports.map((e) => e.export),
...integerSlotRoots,
...contractSurfaceRoots,
]),
],
});
timing("lower", {
lib_roots: profile.exports.length + contractSurfaceRoots.length,
});
} catch (e) {
if (!isCheckerPanic(e)) throw e;
return fail([checkerPanicDiag(e.message.split("\n", 1)[0]!, { file: entryPath, start: 0, end: 0 })]);
}
if (lowered.module === null) return fail(lowered.diagnostics);
entryInfo = fe.entryExports();
sourceTexts = fe.sourceTexts();
} finally {
fe.dispose();
}
const mod = lowered.module!;
timing("frontend-dispose");
const fail = (diagnostics: ScrDiagnostic[]): CompileFailure => ({
ok: false,
diagnostics: decorateLibraryRefusals(diagnostics, profile),
sourceTexts,
});
// Export resolution first (SC4002/SC4003/SC4004/SC4007 anchor at the
// mapped declaration — a mapped async export reports as SC4004, not the
// graph-wide gate), then the async_free requirement (ratified, SC4005),
// then the profile's determinism fences (ask 5, SC4008) over the same
// compiled graph the attestation scan reads: all refused before anything
// is emitted, so the narrowed library link set below is structural fact.
const resolved = resolveLibrarySection(profile, entryInfo, mod, entryPath);
if ("diagnostics" in resolved) return fail(resolved.diagnostics);
const asyncSurface = moduleLibAsyncSurface(mod);
if (asyncSurface !== null) {
return fail([libAsyncSurfaceDiag(asyncSurface.surface, asyncSurface.loc)]);
}
const fenced = evaluateLibraryFences(mod, profile);
if (fenced.length > 0) return fail(fenced);
mod.lib = resolved.lib;
if (buildPlatform === "wasi") {
const unavailable = moduleWasiUnavailableSurface(mod);
if (unavailable !== null) return fail([targetRefusalDiag("wasm32-wasi", unavailable.surface, unavailable.loc)]);
}
// Ask 4's declared integer slots: the export map's i64/u64 classes
// seed the config here; sidecar-declared slots (record fields, msg
// arms, helper params/returns) merge in after the projection resolves
// them below.
let intCfg = libraryIntSlotConfig(profile);
// The ask-2 contract sidecar rides the same invocation. Identity first
// (schema §2's worked build_id definition over compiler version, profile
// bytes, and the sorted canonical module graph; source_hash per the
// profile's "module-graph" contract) — the u64 lands on the IR so native
// archive assembly emits the identity getters from the ONE value the
// sidecar records (V12's coherence by construction), then the projection into
// the schema (declaration orders from the AST) and the V1–V14
// self-check before anything is written.
let sidecarJson: string | null = null;
if (profile.sidecar !== null) {
const rootDir = dirname(resolve(profilePath));
const modules = canonicalModuleGraph(rootDir, sourceTexts);
const { buildId, sourceHash } = libraryIdentityHashes(compilerVersion, profile.profileBytes, modules);
mod.lib.identity = {
buildIdSymbol: profile.sidecar.buildIdSymbol,
abiVersionSymbol: profile.sidecar.abiVersionSymbol,
buildId,
abiVersion: profile.sidecar.abiVersion,
};
const built = buildSidecar({
profile,
facts: contractFacts!,
compilerVersion: compilerVersion,
entry: canonicalPath(rootDir, entryPath),
buildId,
sourceHash,
deterministic: moduleLibNondeterministicSurface(mod) === null,
});
if (!built.ok) return fail(built.diagnostics);
// Validate the serialized contract, including its omitted optional
// properties, through the same checked-value path used by consumers.
const violations = validateSidecar(JSON.parse(built.json));
if (violations.length > 0) {
// The projection above refuses every user-caused shape; a rule
// violation surviving to here is an emitter bug.
return fail(violations.map((v) => iceDiag(`sidecar self-check failed — ${v}`, { file: entryPath, start: 0, end: 0 })));
}
sidecarJson = built.json;
const merged = mergeSidecarIntSlots(intCfg, built.integerSlotFacts, mod);
if (!merged.ok) return fail([merged.diagnostic]);
intCfg = merged.config;
}
timing("contract-sidecar", { source_files: sourceTexts.size });
// Ask 4: the integer-boundary inference — every value that can reach a
// profile-declared i64/u64 slot must PROVE representability, wholeness,
// and range, or the build refuses with the failed obligation, the
// observed evidence, and the author's fix (SC4021/SC4022/SC4023). Runs
// only when at least one integer slot is declared; the sidecar (already
// built above, written only on success) may then attest the classes —
// §5's invariant that an attested integer class means the proof was
// discharged holds because no artifact leaves this function otherwise.
if (hasIntSlots(intCfg)) {
const refusals = checkLibraryIntegerSlots(mod, intCfg).filter((v) => v.outcome === "refuse");
if (refusals.length > 0) {
return fail(refusals.map((v) => libIntBoundaryDiag(v.path, v.cls, v.obligation!, v.detail!, v.fix!, v.loc)));
}
}
timing("integer-proof");
const validation = validateModule(mod);
if (validation.length > 0) return fail(validation.map((v) => iceDiag(v.message, v.loc)));
timing("ir-validate");
return { ok: true, mod, sourceTexts, sidecarJson };
}
export function libraryLocalizeSymbols(profile: LibraryProfile): string[] | undefined {
return profile.localizeRuntime
? [
profile.initSymbol,
profile.sinkRegisterSymbol,
...(profile.collectSymbol !== null ? [profile.collectSymbol] : []),
...(profile.resultResetSymbol !== null ? [profile.resultResetSymbol] : []),
...(profile.callbackRegisterSymbol !== null ? [profile.callbackRegisterSymbol] : []),
...(profile.sidecar !== null
? [profile.sidecar.buildIdSymbol, profile.sidecar.abiVersionSymbol]
: []),
...profile.exports.map((entry) => entry.symbol),
]
: undefined;
}
export function libraryWasmExports(profile: LibraryProfile): string[] {
return [
profile.initSymbol, "scriptc_alloc", "scriptc_free",
...(profile.collectSymbol === null ? [] : [profile.collectSymbol]),
...(profile.resultResetSymbol === null ? [] : [profile.resultResetSymbol]),
...(profile.sidecar === null ? [] : [profile.sidecar.buildIdSymbol, profile.sidecar.abiVersionSymbol]),
...profile.exports.map((entry) => entry.symbol),
];
}
export function libraryWasmRefusal(profile: LibraryProfile, sanitize: boolean): ScrDiagnostic | null {
const reserved = new Set(["scriptc_alloc", "scriptc_free", "memory", "_initialize", "_start"]);
const symbols = [profile.initSymbol, profile.sinkRegisterSymbol, profile.collectSymbol, profile.resultResetSymbol,
profile.callbackRegisterSymbol, ...profile.exports.map((entry) => entry.symbol), profile.sidecar?.buildIdSymbol, profile.sidecar?.abiVersionSymbol];
const surface = sanitize ? "sanitized library builds"
: profile.instancePerThread ? "thread-instanced libraries (instantiate separate Wasm instances instead)"
: profile.localizeRuntime ? "runtime localization (Wasm instances already isolate their runtime)"
: symbols.some((symbol) => symbol != null && reserved.has(symbol)) ? "library symbols reserved by the Wasm embedding ABI"
: profile.callbacks.some((cb) => cb.name === "panic") ? "a callback named 'panic' (reserved by the Wasm embedding ABI)"
: null;
return surface === null ? null : targetRefusalDiag("wasm32-wasi", surface, { file: profile.entry, start: 0, end: 0 });
}
@@ -14,7 +14,7 @@
type Dict = Record<string, unknown>;
const TOP_LEVEL_ORDER = [
const TOP_LEVEL_ORDER: readonly string[] = [
"format",
"wire_version",
"abi_version",
@@ -35,11 +35,11 @@ const TOP_LEVEL_ORDER = [
"integer_slots",
"deterministic",
"async_free",
] as const;
];
const HASH_RE = /^[0-9a-f]{16}$/;
const TYPEREF_KINDS = new Set(["bool", "f64", "i64", "bytes", "void", "optional", "slice", "node", "value", "enum", "union"]);
const FUNCTION_CHANNELS = ["command_msg", "frame_msg", "key_msg", "pinch_msg"] as const;
const FUNCTION_CHANNELS = ["command_msg", "frame_msg", "key_msg", "pinch_msg"];
function isDict(v: unknown): v is Dict {
return v !== null && typeof v === "object" && !Array.isArray(v);
@@ -58,12 +58,12 @@ export function validateSidecar(doc: unknown): string[] {
/* ── V1: required fields, format, top-level key order ─────────────── */
const keys = Object.keys(doc);
for (const k of TOP_LEVEL_ORDER) {
if (!(k in doc)) bad("V1", `required field '${k}' is missing`);
if (!(Object.hasOwn(doc, k))) bad("V1", `required field '${k}' is missing`);
}
for (const k of keys) {
if (!(TOP_LEVEL_ORDER as readonly string[]).includes(k)) bad("V1", `unknown top-level field '${k}' (emit only format-1 fields)`);
}
const present = TOP_LEVEL_ORDER.filter((k) => k in doc);
const present = TOP_LEVEL_ORDER.filter((k) => Object.hasOwn(doc, k));
const actual = keys.filter((k) => (TOP_LEVEL_ORDER as readonly string[]).includes(k));
if (present.join(",") !== actual.join(",")) {
bad("V1", "top-level keys are not emitted in the schema's §1 order");
@@ -348,7 +348,7 @@ export function validateSidecar(doc: unknown): string[] {
return;
}
visiting.add(name);
for (const next of edges.get(name) ?? []) visit(next, [...path, name]);
for (const next of edges.get(name) ?? new Set<string>()) visit(next, [...path, name]);
visiting.delete(name);
done.add(name);
};
@@ -405,7 +405,7 @@ export function validateSidecar(doc: unknown): string[] {
bad("V9", `channels.${ch} is ${v} but the suffix '${ch}' is ${v ? "absent from" : "present in"} abi.exports`);
}
}
for (const ch of ["appearance_msg", "chrome_msg"] as const) {
for (const ch of ["appearance_msg", "chrome_msg"]) {
const v = channels[ch];
if (v === null) continue;
if (typeof v !== "string") {
@@ -450,7 +450,7 @@ export function validateSidecar(doc: unknown): string[] {
while (grew) {
grew = false;
for (const name of [...reachable]) {
for (const next of edges.get(name) ?? []) {
for (const next of edges.get(name) ?? new Set<string>()) {
if (!reachable.has(next)) {
reachable.add(next);
grew = true;
+11 -5
View File
@@ -145,6 +145,10 @@ export interface SidecarDoc {
/* ── identity hashing (schema §2 + the "module-graph" source contract) ─── */
let releaseVersion: string | null = null;
let installedReleaseVersion: string | null = null;
/** Installed native clients read their release identity from the distribution. */
export function setCompilerReleaseVersion(version: string): void { installedReleaseVersion = version; }
/** The package version is stable within one compilation, but a long-lived
* source/worktree process may observe a release stamp between compilations. */
@@ -157,6 +161,7 @@ export function clearSidecarCaches(): void {
* module lives two levels below the package root in src/ and dist/
* alike). build_id input 1 and the sidecar's `compiler_version`. */
export function compilerReleaseVersion(): string {
if (installedReleaseVersion !== null) return installedReleaseVersion;
if (releaseVersion === null) {
const pkgPath = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "package.json");
releaseVersion = (JSON.parse(readFileSync(pkgPath, "utf8")) as { version: string }).version;
@@ -806,7 +811,7 @@ class Projector {
if (shape.k === "object") return shape.fields;
if (shape.k === "array") return this.inlineRecordFields(shape.elem);
if (shape.k === "union") {
const present = shape.parts.filter((part) => part.k !== "absent");
const present = shape.parts.filter((part): boolean => part.k !== "absent");
return present.length === 1 && present.length !== shape.parts.length
? this.inlineRecordFields(present[0]!)
: null;
@@ -1008,7 +1013,7 @@ class Projector {
case "array":
return { kind: "slice", elem: this.shapeRef(shape.elem, container, member, loc, synthesizedContext) };
case "union": {
const present = shape.parts.filter((p) => p.k !== "absent");
const present = shape.parts.filter((p): boolean => p.k !== "absent");
const absents = shape.parts.length - present.length;
if (absents > 0 && present.length === 1) {
const inner = this.shapeRef(present[0]!, container, member, loc, synthesizedContext);
@@ -1486,8 +1491,9 @@ export function buildSidecar(input: SidecarBuildInput): SidecarBuildResult {
}
const r = fn.returns;
if (r !== null && r.k === "ref" && r.name === config.model) return false;
if (r !== null && r.k === "tuple" && r.elems.length === 2 && r.elems[0]!.k === "ref" && (r.elems[0] as { name: string }).name === config.model) {
return true;
if (r !== null && r.k === "tuple" && r.elems.length === 2) {
const first = r.elems[0]!;
if (first.k === "ref" && first.name === config.model) return true;
}
throw new SidecarError(
`${which} export '${exportName}' must declare its return as '${config.model}' (bare state) or a two-element tuple '[${config.model}, ...]' (state plus an effect value)`,
@@ -1523,7 +1529,7 @@ export function buildSidecar(input: SidecarBuildInput): SidecarBuildResult {
const exports = abiExportSuffixes(profile);
const exportSet = new Set(exports);
const namedChannel = (constName: "appearanceMsg" | "chromeMsg"): string | null => {
const c = facts[constName];
const c = constName === "appearanceMsg" ? facts.appearanceMsg : facts.chromeMsg;
if (c === null) return null;
const payload = armByName.get(c.value);
if (payload === undefined) {
@@ -1,34 +0,0 @@
import { expect, test } from "vitest";
import { parseNativeArguments } from "./arguments.js";
test("native build arguments preserve paths and select explicit output modes", () => {
expect(parseNativeArguments([
"build", "source with spaces.ts", "-o", "result with spaces", "--backend=llvm", "--emit=obj",
"--toolchain", "/installed/compiler.json", "--dev", "--strip", "--keep-llvm", "--ffi=bindings.json", "--npm-static=one,@scope/two",
], "default.json")).toEqual({
help: false, toolchainPath: "/installed/compiler.json",
build: {
entryPath: "source with spaces.ts", outputPath: "result with spaces", backend: "llvm", outputKind: "obj",
optimization: "dev", strip: true, keepLlvm: true, ffiProfilePath: "bindings.json", npmStatic: ["one", "@scope/two"],
},
});
expect(parseNativeArguments(["--out=result", "--npm-static", "auto", "--", "-entry.ts"], "default.json")).toMatchObject({
toolchainPath: "default.json", build: { entryPath: "-entry.ts", outputKind: "exe", backend: "llvm", npmStatic: "auto" },
});
expect(parseNativeArguments(["--help"], "default.json").help).toBe(true);
});
test("native build rejects unknown, missing and ambiguous arguments", () => {
for (const [args, message] of [
[[], "entry source"],
[["main.ts"], "output path"],
[["main.ts", "-o"], "requires a value"],
[["main.ts", "-o", "--dev"], "requires a value"],
[["main.ts", "-o=x", "extra.ts"], "unexpected argument"],
[["main.ts", "-o=x", "--dynamic"], "unknown native compiler option"],
[["main.ts", "-o=x", "--backend=other"], "LLVM is the only backend"],
[["main.ts", "-o=x", "--backend=c"], "LLVM is the only backend"],
[["main.ts", "-o=x", "--emit=c"], "--emit must"],
[["main.ts", "-o=x", "--emit=other"], "--emit must"],
] as const) expect(() => parseNativeArguments(args, "toolchain.json")).toThrow(message);
});
-79
View File
@@ -1,79 +0,0 @@
import type { NativeBuildOptions } from "./driver.js";
export interface NativeArguments {
help: boolean;
toolchainPath: string;
build: NativeBuildOptions;
}
export function parseNativeArguments(args: readonly string[], defaultToolchain: string): NativeArguments {
let toolchainPath = defaultToolchain;
let entryPath = "";
let outputPath = "";
let backend = "llvm" as const;
let outputKind: "exe" | "obj" | "llvm" = "exe";
let optimization: "release" | "dev" = "release";
let strip = false;
let keepLlvm = false;
let help = false;
let ffiProfilePath: string | undefined;
let npmStatic: string[] | "auto" | undefined;
let positional = false;
for (let index = 0; index < args.length; index++) {
const arg = args[index]!;
if (!positional && (arg === "--help" || arg === "-h")) { help = true; continue; }
if (!positional && arg === "--") { positional = true; continue; }
if (!positional && arg === "--dev") { optimization = "dev"; continue; }
if (!positional && arg === "--strip") { strip = true; continue; }
if (!positional && arg === "--keep-llvm") { keepLlvm = true; continue; }
if (!positional && arg.startsWith("-")) {
const equals = arg.indexOf("=");
const name = equals < 0 ? arg : arg.slice(0, equals);
if (!["-o", "--out", "--toolchain", "--backend", "--emit", "--ffi", "--npm-static"].includes(name)) {
throw new Error(`unknown native compiler option: ${name}`);
}
const value = equals < 0 ? args[++index] : arg.slice(equals + 1);
if (value === undefined || value === "" || (equals < 0 && value.startsWith("--"))) throw new Error(`${name} requires a value`);
if (name === "-o" || name === "--out") outputPath = value;
else if (name === "--toolchain") toolchainPath = value;
else if (name === "--ffi") ffiProfilePath = value;
else if (name === "--npm-static") npmStatic = value === "auto" ? "auto" : value.split(",");
else if (name === "--backend") {
if (value !== "llvm") throw new Error("LLVM is the only backend");
backend = value;
} else if (name === "--emit") {
if (value !== "exe" && value !== "obj" && value !== "llvm") throw new Error("--emit must be exe, obj, or llvm");
outputKind = value;
}
continue;
}
if (!positional && index === 0 && arg === "build") continue;
if (entryPath !== "") throw new Error(`unexpected argument: ${arg}`);
entryPath = arg;
}
if (!help && entryPath === "") throw new Error("a TypeScript entry source is required");
if (!help && outputPath === "") throw new Error("an output path is required (-o <path>)");
return {
help, toolchainPath,
build: {
entryPath, outputPath, backend, outputKind, optimization, strip,
...(keepLlvm ? { keepLlvm: true } : {}),
...(ffiProfilePath === undefined ? {} : { ffiProfilePath }),
...(npmStatic === undefined ? {} : { npmStatic }),
},
};
}
export const NATIVE_HELP = `Usage: scriptc-native build <entry.ts> -o <output> [options]
--backend <llvm> Code generation backend (default: llvm)
--emit <exe|obj|llvm> Output artifact (default: exe)
--dev Disable optimization and include debug information
--strip Strip executable symbols
--keep-llvm Keep the generated LLVM at <output>.ll
--ffi <profile.json> Native FFI bindings and link inputs
--npm-static <packages> Compile comma-separated packages, or auto
--toolchain <file> Native toolchain manifest (default: beside executable)
--help Show this help
The native compiler builds static programs; unsupported statements are errors.`;
@@ -0,0 +1,69 @@
import { mkdtempSync, readFileSync, rmSync, utimesSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, expect, test, vi } from "vitest";
import { contentDigest, NativeCache, openNativeCache } from "./cache.js";
const directories: string[] = [];
function directory(): string {
const path = mkdtempSync(join(tmpdir(), "scriptc-native-cache-test-"));
directories.push(path);
return path;
}
afterEach(() => {
vi.unstubAllEnvs();
for (const path of directories.splice(0)) rmSync(path, { recursive: true, force: true });
});
test("cache hits require an intact payload and digest", () => {
const cache = new NativeCache(directory());
const key = contentDigest("program identity");
const content = Buffer.from([0, 10, 128, 255]);
cache.write("object", key, content);
expect(cache.read("object", key)).toEqual(content);
const path = join(cache.root, "object", key);
writeFileSync(path, Buffer.from([0, 10, 128, 254]));
expect(cache.read("object", key)).toBeNull();
cache.write("object", key, content);
rmSync(path + ".sha256");
expect(cache.read("object", key)).toBeNull();
});
test("a failed cache publication does not fail the build or replace unrelated files", () => {
const cache = new NativeCache(directory());
const occupied = join(cache.root, "object");
writeFileSync(occupied, "keep");
expect(() => cache.write("object", contentDigest("key"), "payload")).not.toThrow();
expect(readFileSync(occupied, "utf8")).toBe("keep");
});
test("eviction removes oldest complete entries and leaves unrelated paths alone", () => {
const cache = new NativeCache(directory());
const oldKey = contentDigest("old");
const newKey = contentDigest("new");
cache.write("frontend", oldKey, "a".repeat(700));
cache.write("object", newKey, "b".repeat(700));
utimesSync(join(cache.root, "frontend", oldKey), new Date(0), new Date(0));
const unrelated = join(cache.root, "object", "unrelated");
writeFileSync(unrelated, "keep");
vi.stubEnv("SCRIPTC_CACHE_MAX_MB", String(1000 / (1024 * 1024)));
cache.prune();
expect(cache.read("frontend", oldKey)).toBeNull();
expect(cache.read("object", newKey)?.length).toBe(700);
expect(readFileSync(unrelated, "utf8")).toBe("keep");
});
test("private-directory admission and cache disable options fail closed", () => {
vi.stubEnv("SCRIPTC_NO_CACHE", "0");
vi.stubEnv("SCRIPTC_CACHE_DIR", directory());
const inspect = vi.fn(() => false);
expect(openNativeCache(inspect)).toBeNull();
expect(inspect).toHaveBeenCalledWith(process.env["SCRIPTC_CACHE_DIR"], false);
const rootOnly = vi.fn().mockReturnValueOnce(true).mockReturnValueOnce(false);
expect(openNativeCache(rootOnly)).toBeNull();
expect(openNativeCache(() => true)).toBeInstanceOf(NativeCache);
vi.stubEnv("SCRIPTC_NO_CACHE", "1");
const disabled = vi.fn(() => true);
expect(openNativeCache(disabled)).toBeNull();
expect(disabled).not.toHaveBeenCalled();
});
+92
View File
@@ -0,0 +1,92 @@
import { createHash } from "node:crypto";
import { mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { resolveBuildCacheRoot } from "../backend/cache-root.js";
export function contentDigest(value: string | Uint8Array): string {
const hash = createHash("sha256");
if (typeof value === "string") hash.update(value);
else hash.update(value);
return hash.digest("hex");
}
/** A compiler cache contains private source and executable data. The host
* checks ownership and permissions before this class accepts a root. */
export class NativeCache {
readonly root: string;
constructor(root: string) {
this.root = join(root, "native-v1");
mkdirSync(this.root, { recursive: true, mode: 0o700 });
}
read(family: string, key: string): Buffer | null {
try {
const path = join(this.root, family, key);
const bytes = readFileSync(path);
if (contentDigest(bytes) !== readFileSync(path + ".sha256", "utf8").trim()) return null;
return bytes;
} catch { return null; }
}
write(family: string, key: string, bytes: string | Uint8Array): void {
let stage: string | null = null;
try {
const directory = join(this.root, family);
mkdirSync(directory, { recursive: true, mode: 0o700 });
stage = mkdtempSync(join(directory, ".write-"));
writeFileSync(join(stage, "payload"), bytes);
writeFileSync(join(stage, "digest"), contentDigest(bytes) + "\n");
const path = join(directory, key);
// Concurrent writers publish identical content under the same key;
// a reader between the two renames simply treats it as a miss.
renameSync(join(stage, "payload"), path);
renameSync(join(stage, "digest"), path + ".sha256");
} catch { /* Cache failures never change a build's result. */ }
finally {
try { if (stage !== null) rmSync(stage, { recursive: true, force: true }); } catch { /* Cleanup is best effort too. */ }
}
}
prune(): void {
try {
const configured = Number(process.env["SCRIPTC_CACHE_MAX_MB"] ?? "4096");
const limit = (Number.isFinite(configured) && configured >= 0 ? configured : 4096) * 1024 * 1024;
const files: { path: string; size: number; time: number }[] = [];
let total = 0;
for (const family of ["frontend", "object", "sanitizer", "executable", "binary", "dsym"]) {
const directory = join(this.root, family);
let names: string[];
try { names = readdirSync(directory); } catch { continue; }
for (const name of names) {
if (!/^[0-9a-f]{64}$/.test(name)) continue;
const path = join(directory, name);
const info = statSync(path);
if (!info.isFile()) continue;
files.push({ path, size: info.size, time: info.mtimeMs });
total += info.size;
}
}
if (total <= limit) return;
files.sort((a, b) => a.time - b.time);
for (const file of files) {
if (total <= limit) break;
rmSync(file.path, { force: true });
rmSync(file.path + ".sha256", { force: true });
total -= file.size;
}
} catch { /* Eviction is best effort. */ }
}
}
export function openNativeCache(isPrivate: (path: string, harden: boolean) => boolean): NativeCache | null {
const root = resolveBuildCacheRoot();
if (root === null) return null;
try {
mkdirSync(root, { recursive: true, mode: 0o700 });
if (!isPrivate(root, process.env["SCRIPTC_CACHE_DIR"] === undefined)) return null;
const cache = new NativeCache(root);
if (!isPrivate(cache.root, false)) return null;
return cache;
} catch { return null; }
}
+100
View File
@@ -0,0 +1,100 @@
import { spawnSync } from "node:child_process";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { runCli } from "../cli/command.js";
import type { NativeCacheWarmProfile } from "../cli/host.js";
import { selectNativeRuntimePack, stageNativeRuntimeSelection } from "../backend/runtime-pack-native.js";
import { setDeclarationRoot } from "../frontend/dts-paths.js";
import { FrontendServices } from "../frontend/services.js";
import { createNativeTs7Api } from "../frontend/ts7/native-api.js";
import { resolveProvenanceSourcesWithParser } from "../frontend/provenance-core.js";
import { setCompilerReleaseVersion } from "../library/sidecar.js";
import { NativeCompiler } from "./compiler.js";
import { openNativeCache } from "./cache.js";
import { loadNativeToolchain } from "./toolchain.js";
import { buildSanitizedRuntime } from "./sanitizer.js";
import { runCompilerTask } from "./task.js";
declare function compilerNativePrivateDirectory(path: string, harden: boolean): boolean;
async function main(): Promise<number> {
const manifestPath = process.env["SCRIPTC_TOOLCHAIN"] ?? process.execPath + ".json";
let compiler: NativeCompiler | null = null;
let cache: ReturnType<typeof openNativeCache> = null;
const getCompiler = (): NativeCompiler => {
if (compiler !== null) return compiler;
const toolchain = loadNativeToolchain(manifestPath, process.env);
if (toolchain.declarationsRoot !== undefined) setDeclarationRoot(toolchain.declarationsRoot);
setCompilerReleaseVersion(toolchain.compilerVersion);
cache = openNativeCache((path, harden) => compilerNativePrivateDirectory(path, harden));
compiler = new NativeCompiler(toolchain, cache);
return compiler;
};
return runCli(process.argv.slice(2), {
version: () => loadNativeToolchain(manifestPath).compilerVersion,
sourceTargetPlatform: () => getCompiler().toolchain.target.platform,
analyze: (entry, options) => runCompilerTask(() => getCompiler().analyze(entry, options)),
compile: (entry, options) => runCompilerTask(() => getCompiler().compile(entry, options)),
compileLibrary: (options) => runCompilerTask(() => getCompiler().compileLibrary(options)),
resolveProvenanceSources: async (entry) => {
const toolchain = getCompiler().toolchain;
const services = new FrontendServices((options) => createNativeTs7Api({ ...options, executable: toolchain.ts7Executable }));
try { return await resolveProvenanceSourcesWithParser(entry, (path, source, kind) => services.parse(path, source, kind)); }
finally { services.close(); }
},
warmNativeCaches: async (options) => {
const toolchain = getCompiler().toolchain;
if (cache === null) throw new Error("the native build cache is disabled or unavailable");
if (!toolchain.target.supports.exe) throw new Error(`native cache warming requires an executable target; ${toolchain.target.name} supports library archives`);
const profiles: { profile: NativeCacheWarmProfile; elapsedMs: number }[] = [];
for (const profile of [...new Set(options.profiles ?? ["runtime", "tls", "dynamic"] as NativeCacheWarmProfile[])]) {
const start = performance.now();
const stage = mkdtempSync(join(tmpdir(), "scriptc-warm-"));
try {
const pack = selectNativeRuntimePack(toolchain.runtimePackRoot, toolchain.target, toolchain.compilerVersion, {
dynamic: profile === "dynamic", fetch: profile === "tls", regex: false,
copying: false, textDecoderLegacy: false, fileHandle: false, netIsland: false,
zlib: false, assert: false, inspect: false, dynInvoke: false, dc: false,
dynAsync: false, events: false, emitter: false, symbol: false, bigint: false,
searchParams: false, qs: false, parseArgs: false, stream: false, net: false,
http: false, http2: false, dgram: false, watch: false, foreignFfi: false,
nodeTest: false, tls: false, tlsCa: false,
}, options.optimization ?? "release");
if (options.sanitize) buildSanitizedRuntime(toolchain, pack, stage, "executable", cache);
else stageNativeRuntimeSelection(pack, stage);
} finally { rmSync(stage, { recursive: true, force: true }); }
profiles.push({ profile, elapsedMs: performance.now() - start });
}
return { cacheRoot: cache.root, profiles };
},
run: async (binary) => {
const toolchain = getCompiler().toolchain;
let executable = binary;
const args: string[] = [];
if (toolchain.target.platform === "wasi") {
if (toolchain.wasiNodeRunner === undefined) throw new Error("the WASI runner is missing from this installation");
executable = "node";
args.push("--no-warnings", toolchain.wasiNodeRunner, binary);
}
const result = spawnSync(executable, args, { stdio: "inherit" });
if (result.error) {
if (toolchain.target.platform === "wasi") {
throw new Error(`could not start the Node.js WASI host: ${result.error.message}; running WASI modules requires Node.js 24 or newer on PATH; use scriptc build to compile without Node`);
}
throw new Error(result.error.message);
}
if (result.signal) {
process.stderr.write(`scriptc: program killed by ${result.signal}\n`);
return 1;
}
return result.status ?? 0;
},
});
}
try { process.exitCode = await main(); }
catch (error) {
process.stderr.write(`scriptc: ${error instanceof Error ? error.message : String(error)}\n`);
process.exitCode = 1;
}
@@ -2,7 +2,8 @@ import { mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "n
import { join } from "node:path";
import { tmpdir } from "node:os";
import { afterEach, expect, test, vi } from "vitest";
import { buildNative, type NativeToolchain } from "./driver.js";
import { NativeCompiler } from "./compiler.js";
import type { NativeToolchain } from "./toolchain.js";
import { runNativeFrontend } from "../frontend/pipeline-native.js";
import { emitNativeObject } from "../backend/native-tools.js";
import { MACOS_ARM64_TARGET } from "../backend/targets.js";
@@ -54,18 +55,19 @@ test.each([false, true])("object generation releases frontend resources and pres
helperExecutable: "unused", helperPackageRoot: "unused", runtimePackRoot: "unused",
linker: "unused", linkerArgs: [], dsymutil: "unused",
};
const build = () => buildNative({
entryPath: entry, outputPath: output, backend: "llvm", outputKind: "obj",
optimization: "release", strip: true, keepLlvm: true,
}, toolchain);
const build = () => new NativeCompiler(toolchain).compile(entry, {
outDir: directory, outPath: output, backend: "llvm", outputKind: "obj",
optimization: "release", strip: true,
});
if (fail) {
expect(build).toThrow("codegen failed");
expect(build()).toMatchObject({ ok: false, diagnostics: [expect.objectContaining({ message: "codegen failed" })] });
expect(readFileSync(output, "utf8")).toBe("previous object");
expect(readFileSync(llvmPath, "utf8")).toBe("previous LLVM");
} else {
expect(build()).toMatchObject({ outputPath: output, llvmPath });
expect(build()).toMatchObject({ ok: true, artifact: { kind: "obj", path: output } });
expect(readFileSync(output, "utf8")).toBe("new object");
expect(readFileSync(llvmPath, "utf8")).toBe(emitted);
expect(emitted).toContain("define i32 @main");
expect(readFileSync(llvmPath, "utf8")).toBe("previous LLVM");
}
expect(dispose).toHaveBeenCalledTimes(1);
expect(readdirSync(directory).sort()).toEqual(["output.o", "output.o.ll"]);
+286
View File
@@ -0,0 +1,286 @@
import { mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
import { basename, dirname, join, resolve } from "node:path";
import type { AnalyzeOptions, AnalyzeResult, CompileFailure, CompileLibraryOptions, CompileLibraryResult, CompileRequestOptions, CompileRequestResult } from "../compile-types.js";
import { LlvmUnsupportedError } from "../backend/llvm/emitter.js";
import { executableLinkInputs } from "../backend/link-plan-core.js";
import { formatNativeLinkInfo } from "../backend/native-link-info-core.js";
import { emitNativeObject, requireNativeArtifact, runNativeTool, verifyNativeHelper } from "../backend/native-tools.js";
import { selectNativeRuntimePack, stageNativeRuntimeSelection } from "../backend/runtime-pack-native.js";
import { RuntimePackError } from "../backend/runtime-pack-core.js";
import { NativeCodegenError } from "../backend/native-codegen-core.js";
import { llvmRefusalDiag } from "../backend/target-diagnostics.js";
import { nativeCodegenDiag } from "../diagnostics/diagnostic.js";
import { loadFfiProfile, type FfiProfile } from "../ffi/ffi-manifest.js";
import { analyzeWithFrontend } from "../frontend/analysis.js";
import type { FrontendFactory } from "../frontend/pipeline.js";
import { runNativeFrontend } from "../frontend/pipeline-native.js";
import { loadLibraryProfile } from "../library/library-profile.js";
import { libraryLocalizeSymbols, libraryWasmExports, libraryWasmRefusal } from "../library/prepare.js";
import { clearFenceEvalCaches } from "../library/fence-eval.js";
import { archiveNativeLibrary, localizeNativeLibrary, linkNativeWasmLibrary } from "./library.js";
import type { NativeToolchain } from "./toolchain.js";
import { evaluateNativeComptime } from "./comptime.js";
import { contentDigest, type NativeCache } from "./cache.js";
import { splitLlvmProgram, splitLlvmLibraryProgram } from "../backend/llvm/split.js";
import { prepareNativeExecutable, prepareNativeLibrary } from "./prepare.js";
import { buildSanitizedRuntime, sanitizerDriver, sanitizerFlags } from "./sanitizer.js";
import { openNativeExecutableCache } from "./executable-cache.js";
import { nativeFileIdentity } from "./file-identity.js";
function failure(error: unknown, entry: string, sources: Map<string, string>): CompileFailure {
return { ok: false, diagnostics: [nativeCodegenDiag("SC3004", error instanceof Error ? error.message : String(error), entry)], sourceTexts: sources };
}
/** Shared frontend semantics with native processes for code generation and linking. */
export class NativeCompiler {
private readonly frontend: FrontendFactory;
constructor(readonly toolchain: NativeToolchain, private readonly cache: NativeCache | null = null) {
const evaluator = toolchain.comptimeExecutable;
this.frontend = (entry, npmStatic, externalTypes, libraryNpmStatic) => runNativeFrontend(entry, toolchain.ts7Executable, npmStatic, externalTypes,
evaluator === undefined ? undefined : (source, timeout) => evaluateNativeComptime(source, timeout, toolchain.ts7Executable, evaluator), libraryNpmStatic);
}
analyze(entry: string, options: AnalyzeOptions): AnalyzeResult {
return analyzeWithFrontend(resolve(entry), options, this.toolchain.target.platform, this.frontend);
}
private emitObject(input: string, output: string, source: string, optimization: "release" | "dev", outputKind: "obj" | "asm"): void {
const toolchain = this.toolchain;
const helperOptions = {
executable: toolchain.helperExecutable, packageRoot: toolchain.helperPackageRoot,
compilerVersion: toolchain.compilerVersion, target: toolchain.target, helper: toolchain.helper,
};
let key: string | null = null;
let identity: string | null = null;
if (this.cache !== null && process.env["SCRIPTC_LLVM_HELPER"] === undefined) {
try {
identity = nativeFileIdentity(toolchain.helperExecutable);
key = contentDigest(JSON.stringify({ schema: 1, llvm: contentDigest(readFileSync(input)), outputKind, optimization, source,
target: toolchain.target, helper: identity,
identity: readFileSync(join(toolchain.helperPackageRoot, "package.json"), "utf8"), version: toolchain.compilerVersion }));
const bytes = this.cache.read("object", key);
if (bytes !== null) {
verifyNativeHelper(helperOptions);
if (nativeFileIdentity(toolchain.helperExecutable) === identity) {
writeFileSync(output, bytes);
return;
}
key = null;
}
} catch { key = null; }
}
emitNativeObject({
executable: toolchain.helperExecutable, packageRoot: toolchain.helperPackageRoot,
compilerVersion: toolchain.compilerVersion, target: toolchain.target, helper: toolchain.helper,
inputPath: input, outputPath: output, sourcePath: source, optimization, outputKind,
});
if (this.cache !== null && key !== null) {
try {
if (nativeFileIdentity(toolchain.helperExecutable) === identity) this.cache.write("object", key, readFileSync(output));
} catch { /* An updated helper or unavailable cache only prevents reuse. */ }
}
}
private emitProgramObject(input: string, output: string, source: string, optimization: "release" | "dev", stage: string, library: boolean): void {
const llvm = optimization === "dev" && this.toolchain.target.platform !== "wasi" ? readFileSync(input, "utf8") : null;
const split = llvm === null ? null : library ? splitLlvmLibraryProgram(llvm) : splitLlvmProgram(llvm);
if (split === null) { this.emitObject(input, output, source, optimization, "obj"); return; }
const objects: string[] = [];
for (const [index, shard] of split.shards.entries()) {
const shardInput = join(stage, `shard-${index}.ll`);
const shardOutput = join(stage, `shard-${index}` + this.toolchain.target.outputSuffixes.obj);
writeFileSync(shardInput, shard.source);
this.emitObject(shardInput, shardOutput, source + "." + shard.name, optimization, "obj");
objects.push(shardOutput);
}
const merged = localizeNativeLibrary(this.toolchain, stage, objects, [], split.publicSymbols);
renameSync(merged, output);
}
compile(entry: string, options: CompileRequestOptions): CompileRequestResult {
const toolchain = this.toolchain;
const target = toolchain.target;
const output = resolve(options.outPath);
const outputKind = options.outputKind ?? "exe";
const optimization = options.optimization ?? "release";
entry = resolve(entry);
let sourceTexts = new Map<string, string>();
let stage: string | null = null;
try {
if (entry === output) throw new Error("output path must differ from the entry source");
if (outputKind === "exe" && !target.supports.exe) throw new NativeCodegenError("SC3002", `${target.name} supports library archives, not executables`);
if (options.sanitize && (outputKind === "asm" || outputKind === "obj")) {
throw new NativeCodegenError("SC3002", `--sanitize is not supported with --emit=${outputKind}; AddressSanitizer instrumentation parity is not available in the LLVM native helper yet`);
}
if (options.sanitize) sanitizerDriver(toolchain);
let ffi: FfiProfile | null = null;
if (options.ffiProfilePath !== undefined) {
const loaded = loadFfiProfile(resolve(options.ffiProfilePath));
if (!loaded.ok) return { ok: false, diagnostics: loaded.diagnostics, sourceTexts };
ffi = loaded.profile;
}
const prepared = prepareNativeExecutable(entry, options, ffi, toolchain, this.frontend, this.cache);
if (!prepared.ok) return prepared;
sourceTexts = new Map(prepared.sources);
mkdirSync(dirname(output), { recursive: true });
stage = mkdtempSync(join(dirname(output), ".scriptc-native-"));
const stagedOutput = join(stage, basename(output));
const stem = basename(entry).replace(/\.(ts|mts|cts|js|mjs|cjs)$/, "");
const llvmPath = join(options.outDir, `${stem}.ll`);
if (outputKind === "ir") {
writeFileSync(stagedOutput, prepared.ir!);
renameSync(stagedOutput, output);
return { ok: true, artifact: { kind: "ir", path: output } };
}
const { llvm, features } = prepared;
const inputDirectory = join(stage, "input");
mkdirSync(inputDirectory);
const llvmInput = outputKind === "llvm" ? stagedOutput : join(inputDirectory, "program.ll");
writeFileSync(llvmInput, llvm);
if (outputKind === "llvm") {
renameSync(stagedOutput, output);
return { ok: true, artifact: { kind: "llvm", path: output } };
}
const object = outputKind === "exe" ? join(inputDirectory, "program" + target.outputSuffixes.obj) : stagedOutput;
const executablePack = outputKind === "exe"
? selectNativeRuntimePack(toolchain.runtimePackRoot, target, toolchain.compilerVersion, features, optimization) : null;
const executableCache = executablePack === null ? null
: openNativeExecutableCache(this.cache, toolchain, llvm, options, ffi, executablePack);
const restored = executableCache?.restore(stagedOutput) ?? false;
if (!restored) {
if (options.sanitize) {
runNativeTool(sanitizerDriver(toolchain), [...sanitizerFlags(toolchain, optimization), "-c", llvmInput, "-o", object]);
requireNativeArtifact(object);
} else if (outputKind === "exe") this.emitProgramObject(llvmInput, object, entry, optimization, stage, false);
else this.emitObject(llvmInput, object, entry, optimization, outputKind === "asm" ? "asm" : "obj");
if (outputKind === "asm" || outputKind === "obj") {
const pack = outputKind === "obj" && options.nativeLinkInfo
? selectNativeRuntimePack(toolchain.runtimePackRoot, target, toolchain.compilerVersion, features, optimization) : null;
if (pack !== null) stageNativeRuntimeSelection(pack, join(stage, "runtime"));
const info = pack === null ? undefined : formatNativeLinkInfo({ programObject: output, target, ffi }, toolchain.compilerVersion, pack);
renameSync(stagedOutput, output);
return { ok: true, artifact: { kind: outputKind, path: output, ...(info === undefined ? {} : { nativeLinkInfo: info }) } };
}
const pack = executablePack!;
const runtime = options.sanitize
? buildSanitizedRuntime(toolchain, pack, join(stage, "runtime"), "executable", this.cache)
: stageNativeRuntimeSelection(pack, join(stage, "runtime"));
const plan = executableLinkInputs({
target, programObject: object, ffiLibraries: ffi?.libraries ?? [], ffiSystemLibraries: ffi?.systemLibraries ?? [],
ffiFrameworks: ffi?.frameworks ?? [], runtimeObjects: runtime.runtimeObjects, runtimeArchives: runtime.archives,
runtimeSystemLibraries: runtime.systemLibraries, optimization, strip: options.strip ?? false,
...(options.windowsSubsystem === undefined ? {} : { windowsSubsystem: options.windowsSubsystem }),
});
const linkArgs = [...(options.sanitize ? ["-fsanitize=address"] : toolchain.linkerArgs), ...plan.driverFlags, ...plan.inputs,
...plan.systemLibraries.map((name) => `-l${name}`), "-o", stagedOutput];
const cacheable = executableCache?.trace(linkArgs, stage) ?? false;
runNativeTool(options.sanitize ? sanitizerDriver(toolchain) : toolchain.linker, linkArgs);
requireNativeArtifact(stagedOutput);
if (target.platform === "darwin" && optimization === "dev" && !options.strip) {
runNativeTool(toolchain.dsymutil, [stagedOutput, "-o", stagedOutput + ".dSYM"]);
}
if (cacheable) executableCache?.publish(stagedOutput);
}
if (target.platform === "darwin" && optimization === "dev" && !options.strip) {
rmSync(output + ".dSYM", { recursive: true, force: true });
renameSync(stagedOutput + ".dSYM", output + ".dSYM");
}
mkdirSync(options.outDir, { recursive: true });
writeFileSync(llvmPath, llvm);
let irPath: string | undefined;
if (options.emitIr) {
irPath = join(options.outDir, `${stem}.ir.json`);
writeFileSync(irPath, prepared.ir!);
}
renameSync(stagedOutput, output);
if (target.platform === "darwin" && (optimization !== "dev" || options.strip)) rmSync(output + ".dSYM", { recursive: true, force: true });
return { ok: true, artifact: { kind: "exe", path: output, translationUnitPath: llvmPath, backend: "llvm" },
binaryPath: output, llvmPath, backend: "llvm", ...(irPath === undefined ? {} : { irPath }) };
} catch (error) {
// Keep subclass reads at the catch boundary, where the thrown object
// retains its class identity and source location.
if (error instanceof LlvmUnsupportedError) return { ok: false, diagnostics: [llvmRefusalDiag(error, entry)], sourceTexts };
if (error instanceof NativeCodegenError) return { ok: false, diagnostics: [nativeCodegenDiag(error.diagnosticCode, error.message, entry)], sourceTexts };
if (error instanceof RuntimePackError) return { ok: false, diagnostics: [nativeCodegenDiag(error.code === "unsupported" ? "SC3002" : "SC3003", error.message, entry)], sourceTexts };
return failure(error, entry, sourceTexts);
}
finally {
if (stage !== null) rmSync(stage, { recursive: true, force: true });
this.cache?.prune();
}
}
compileLibrary(options: CompileLibraryOptions): CompileLibraryResult {
clearFenceEvalCaches();
const toolchain = this.toolchain;
let sourceTexts = new Map<string, string>();
let stage: string | null = null;
try {
const loaded = loadLibraryProfile(resolve(options.profilePath));
if (!loaded.ok) return { ok: false, diagnostics: loaded.diagnostics, sourceTexts };
const profile = loaded.profile;
const wasm = toolchain.target.platform === "wasi";
if (wasm) {
const refusal = libraryWasmRefusal(profile, options.sanitize ?? false);
if (refusal !== null) return { ok: false, diagnostics: [refusal], sourceTexts };
}
if (options.sanitize) sanitizerDriver(toolchain);
const stem = basename(profile.entry).replace(/\.(ts|mts|cts|js|mjs|cjs)$/, "");
const archivePath = resolve(options.outPath ?? join(options.outDir, `${stem}${wasm ? ".wasm" : ".lib.a"}`));
const llvmPath = join(options.outDir, `${stem}.lib.ll`);
const prepared = prepareNativeLibrary(profile, options, archivePath, toolchain, this.frontend, this.cache);
if (!prepared.ok) return prepared;
sourceTexts = new Map(prepared.sources);
if (archivePath === profile.entry) throw new Error("output path must differ from the entry source");
mkdirSync(dirname(archivePath), { recursive: true });
mkdirSync(options.outDir, { recursive: true });
stage = mkdtempSync(join(dirname(archivePath), ".scriptc-library-"));
const llvm = prepared.llvm;
const llvmInput = join(stage, "program.ll");
writeFileSync(llvmInput, llvm);
const features = prepared.features;
const pack = selectNativeRuntimePack(toolchain.runtimePackRoot, toolchain.target, toolchain.compilerVersion,
features, profile.optimization, profile.instancePerThread ? "library-thread" : "library");
const runtime = options.sanitize
? buildSanitizedRuntime(toolchain, pack, join(stage, "runtime"), profile.instancePerThread ? "library-thread" : "library", this.cache)
: stageNativeRuntimeSelection(pack, join(stage, "runtime"));
const stagedArchive = join(stage, wasm ? "output.wasm" : "output.a");
const localizeSymbols = libraryLocalizeSymbols(profile);
const program = join(stage, "program" + toolchain.target.outputSuffixes.obj);
if (options.sanitize) {
runNativeTool(sanitizerDriver(toolchain), [...sanitizerFlags(toolchain, profile.optimization), "-c", llvmInput, "-o", program]);
requireNativeArtifact(program);
} else this.emitProgramObject(llvmInput, program, profile.entry, profile.optimization, stage, true);
if (wasm) {
linkNativeWasmLibrary({ toolchain, programObject: program, outputPath: stagedArchive,
runtime, optimization: profile.optimization, exports: libraryWasmExports(profile) });
} else {
archiveNativeLibrary({ toolchain, programObject: program, outputPath: stagedArchive,
stage, runtime, ...(localizeSymbols === undefined ? {} : { localizeSymbols }) });
}
writeFileSync(llvmPath, llvm);
let irPath: string | undefined;
if (options.emitIr) {
irPath = join(options.outDir, `${stem}.lib.ir.json`);
writeFileSync(irPath, prepared.ir!);
}
let sidecarPath: string | undefined;
if (prepared.sidecarJson !== null) {
sidecarPath = profile.sidecar!.path === null ? `${archivePath}.contract.json` : resolve(dirname(archivePath), profile.sidecar!.path);
writeFileSync(sidecarPath, prepared.sidecarJson);
}
renameSync(stagedArchive, archivePath);
return { ok: true, archivePath, llvmPath, backend: "llvm",
...(irPath === undefined ? {} : { irPath }), ...(sidecarPath === undefined ? {} : { sidecarPath }) };
} catch (error) {
// Keep subclass reads at the catch boundary, where the thrown object
// retains its class identity and source location.
if (error instanceof LlvmUnsupportedError) return { ok: false, diagnostics: [llvmRefusalDiag(error, options.profilePath)], sourceTexts };
if (error instanceof NativeCodegenError) return { ok: false, diagnostics: [nativeCodegenDiag(error.diagnosticCode, error.message, options.profilePath)], sourceTexts };
if (error instanceof RuntimePackError) return { ok: false, diagnostics: [nativeCodegenDiag(error.code === "unsupported" ? "SC3002" : "SC3003", error.message, options.profilePath)], sourceTexts };
return failure(error, options.profilePath, sourceTexts);
}
finally { if (stage !== null) rmSync(stage, { recursive: true, force: true }); }
}
}
@@ -0,0 +1,68 @@
import { execFileSync } from "node:child_process";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { afterAll, beforeAll, expect, test } from "vitest";
import { nativeCodegenTarget } from "../backend/targets.js";
import { ts7Executable } from "../frontend/ts7/rpc-api.js";
import { evaluateNativeComptime } from "./comptime.js";
const root = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");
const target = nativeCodegenTarget({});
const stage = mkdtempSync(join(process.platform === "win32" ? tmpdir() : "/tmp", "scriptc-comptime-test-"));
const evaluator = join(stage, "comptime" + (process.platform === "win32" ? ".exe" : ""));
beforeAll(() => {
if (target === null) throw new Error("a supported native host is required");
const runtime = join(root, target.runtimePackPackage.replace("@scriptc/", ""));
execFileSync(target.defaultLinker, [...target.defaultLinkerArgs, "-std=c11", "-O2", "-Wall", "-Wextra", "-Werror",
"-I", join(root, "runtime/vendor/quickjs-ng"), join(root, "compiler/native/comptime.c"),
join(runtime, "artifacts/vendor/quickjs/libscriptc-quickjs.a"), "-lm", "-lpthread", "-o", evaluator]);
});
afterAll(() => { rmSync(stage, { recursive: true, force: true }); });
function evaluate(source: string, timeout = 2000): unknown {
return evaluateNativeComptime(source, timeout, ts7Executable(), evaluator);
}
test("native comptime erases TypeScript and returns the actual structured value", () => {
expect(evaluate(`() => {
interface Pair { value: number; text: string }
const make = <T>(value: T): T => value;
const items: Pair[] = [];
for (let i = 0; i < 4; i++) items.push(make({ value: i * i, text: String(i) }));
return { items, zero: -0, text: "hello \\ud83c\\udf0d", lone: "\\ud800", empty: undefined };
}`)).toEqual({ items: [0, 1, 4, 9].map((value, index) => ({ value, text: String(index) })), zero: -0,
text: "hello 🌍", lone: "\ud800", empty: undefined });
});
test("native comptime retains non-finite values for shared result validation", () => {
const values = evaluate("() => [NaN, Infinity, -Infinity, -0, undefined, null, 123n]") as unknown[];
expect(values).toEqual([NaN, Infinity, -Infinity, -0, undefined, null, 123n]);
});
test("callback changes to globals and serialization hooks cannot replace its result", () => {
expect(evaluate(`() => {
JSON.stringify = () => '"spoofed"';
Array.prototype.toJSON = () => "spoofed";
Object.prototype.toJSON = () => "spoofed";
return { values: [1, 2], nested: { ok: true } };
}`)).toEqual({ values: [1, 2], nested: { ok: true } });
});
test("native comptime isolates callbacks and exposes no host bindings", () => {
expect(evaluate("() => { globalThis.saved = 1; return 2; }")).toBe(2);
expect(evaluate("() => [typeof saved, typeof process, typeof require, typeof console, typeof fetch]")).toEqual(
["undefined", "undefined", "undefined", "undefined", "undefined"],
);
});
test("throws, cycles, and runaway callbacks report bounded failures", () => {
expect(() => evaluate('() => { throw new Error("user failure") }')).toThrow("user failure");
expect(() => evaluate("() => { const result = {}; result.self = result; return result; }")).toThrow("cyclic compile-time result");
let failure: unknown;
try { evaluate("() => { while (true) {} }", 20); } catch (error) { failure = error; }
expect(failure).toMatchObject({ code: "ERR_SCRIPT_EXECUTION_TIMEOUT" });
expect(evaluate("() => 42")).toBe(42);
});
+64
View File
@@ -0,0 +1,64 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { runNativeTool } from "../backend/native-tools.js";
/** Tagged values preserve -0, non-finite numbers, and missing properties so
* shared result validation sees the value the callback actually returned. */
export function decodeComptimeValue(wire: unknown): unknown {
if (!Array.isArray(wire) || typeof wire[0] !== "string") throw new Error("invalid compile-time result");
const value = wire as unknown[];
switch (value[0]) {
case "error": {
if (value[1] === "ERR_SCRIPT_EXECUTION_TIMEOUT") throw { code: "ERR_SCRIPT_EXECUTION_TIMEOUT", message: String(value[2]) };
throw new Error(String(value[2]));
}
case "undefined": return undefined;
case "null": return null;
case "boolean":
if (typeof value[1] === "boolean") return value[1];
break;
case "string":
if (typeof value[1] === "string") return value[1];
break;
case "number":
if (typeof value[1] === "string") return value[1] === "-0" ? -0 : Number(value[1]);
break;
case "bigint": return BigInt(String(value[1]));
case "function": return () => {};
case "symbol": throw new Error("compile-time result contains a symbol");
case "array":
if (Array.isArray(value[1])) return value[1].map((item) => decodeComptimeValue(item));
break;
case "object": {
if (!Array.isArray(value[1])) break;
const result: Record<string, unknown> = Object.create(null) as Record<string, unknown>;
for (const item of value[1]) {
if (!Array.isArray(item) || typeof item[0] !== "string") throw new Error("invalid compile-time property");
result[item[0]] = decodeComptimeValue(item[1]);
}
return result;
}
}
throw new Error("invalid compile-time result");
}
export function comptimeScript(source: string): string {
return `(${source})();\n`;
}
export function evaluateNativeComptime(source: string, timeoutMs: number, ts7: string, evaluator: string): unknown {
const stage = mkdtempSync(join(tmpdir(), "scriptc-comptime-"));
try {
const sourcePath = join(stage, "eval.ts");
const output = join(stage, "output");
mkdirSync(output);
writeFileSync(sourcePath, comptimeScript(source));
// Type erasure remains TypeScript's job, including nested annotations,
// type assertions, generic functions, and enums in closed callbacks.
runNativeTool(ts7, ["--ignoreConfig", "--noCheck", "--target", "esnext", "--module", "esnext",
"--moduleDetection", "legacy", "--outDir", output, sourcePath]);
const result = runNativeTool(evaluator, [join(output, "eval.js"), String(timeoutMs)]);
return decodeComptimeValue(JSON.parse(result));
} finally { rmSync(stage, { recursive: true, force: true }); }
}
-142
View File
@@ -1,142 +0,0 @@
/** Static executable compiler. The TS7 parser/checker and native toolchain
* are external native processes; lowering, validation and LLVM emission run
* inside this binary. Installed paths are supplied by the distribution. */
import { mkdirSync, mkdtempSync, renameSync, rmSync, writeFileSync } from "node:fs";
import { basename, dirname, join, resolve } from "node:path";
import { emitLlvmModule } from "../backend/llvm/emitter.js";
import { executableLinkFeatures } from "../backend/executable-features.js";
import { executableLinkInputs } from "../backend/link-plan-core.js";
import type { NativeLinkFeatures } from "../backend/native-link-info.js";
import { stageNativeRuntimePack } from "../backend/runtime-pack-native.js";
import { emitNativeObject, requireNativeArtifact, runNativeTool } from "../backend/native-tools.js";
import type { NativeHelperSpec, NativeTargetSpec } from "../backend/targets.js";
import { loadFfiProfile, type FfiProfile } from "../ffi/ffi-manifest.js";
import { runNativeFrontend } from "../frontend/pipeline-native.js";
import type { LowerStats } from "../frontend/lowering/lowerer.js";
import { validateModule } from "../ir/validate.js";
export interface NativeToolchain {
compilerVersion: string;
ts7Executable: string;
target: NativeTargetSpec;
helper: NativeHelperSpec;
helperExecutable: string;
helperPackageRoot: string;
runtimePackRoot: string;
linker: string;
linkerArgs: string[];
dsymutil: string;
}
export interface NativeBuildOptions {
entryPath: string;
outputPath: string;
backend: "llvm";
outputKind: "exe" | "obj" | "llvm";
optimization: "release" | "dev";
strip: boolean;
keepLlvm?: boolean;
ffiProfilePath?: string;
npmStatic?: readonly string[] | "auto";
}
export interface NativeBuildResult {
outputPath: string;
llvmPath?: string;
stats: LowerStats;
}
/** Keep the program graph and its TS7 session inside the frontend phase.
* Native object generation can need several GB of its own working memory. */
function emitNativeInput(
options: NativeBuildOptions, toolchain: NativeToolchain,
entry: string, ffi: FfiProfile | null, path: string,
): { features: NativeLinkFeatures; stats: LowerStats } {
const frontend = runNativeFrontend(entry, toolchain.ts7Executable, options.npmStatic);
try {
if (frontend.preflight.length !== 0) throw new Error(JSON.stringify(frontend.preflight));
const lowered = frontend.lower({ dynamic: false, targetPlatform: toolchain.target.platform, ffiImports: ffi?.functions ?? [] });
if (lowered.module === null || lowered.stats.statementsFailed !== 0 || lowered.stats.statementsIsland !== 0 || lowered.stats.functionsSkipped !== 0) {
throw new Error(JSON.stringify(lowered.diagnostics));
}
const module = lowered.module;
const errors = validateModule(module);
if (errors.length !== 0) throw new Error(JSON.stringify(errors));
const features = executableLinkFeatures(module, false);
const debug = options.optimization === "dev" && !options.strip;
const debugSources = debug ? frontend.sourceTexts() : new Map<string, string>();
const llvm = emitLlvmModule(module, {
targetTriple: toolchain.target.llvmTriple,
pointerBits: toolchain.target.pointerBits,
wasi: toolchain.target.platform === "wasi",
runtimeAbiMarker: true,
...(debug ? { debugSources } : {}),
});
writeFileSync(path, llvm);
return { features, stats: lowered.stats };
} finally { frontend.dispose(); }
}
export function buildNative(options: NativeBuildOptions, toolchain: NativeToolchain): NativeBuildResult {
const entry = resolve(options.entryPath);
const output = resolve(options.outputPath);
if (entry === output) throw new Error("output path must differ from the entry source");
let ffi: FfiProfile | null = null;
if (options.ffiProfilePath !== undefined) {
const loaded = loadFfiProfile(options.ffiProfilePath);
if (!loaded.ok) throw new Error(JSON.stringify(loaded.diagnostics));
ffi = loaded.profile;
}
mkdirSync(dirname(output), { recursive: true });
// A sibling temporary directory keeps installation on the same volume
// and preserves the basename used by Mach-O's ad-hoc signature.
const stage = mkdtempSync(join(dirname(output), ".scriptc-native-"));
try {
const outputDirectory = join(stage, "output");
const inputDirectory = join(stage, "input");
mkdirSync(outputDirectory);
mkdirSync(inputDirectory);
const stagedOutput = join(outputDirectory, basename(output));
const sourceOutput = options.outputKind === "llvm";
const input = sourceOutput ? stagedOutput : join(inputDirectory, "program.ll");
const prepared = emitNativeInput(options, toolchain, entry, ffi, input);
if (!sourceOutput) {
const object = options.outputKind === "obj" ? stagedOutput : join(inputDirectory, "program" + toolchain.target.outputSuffixes.obj);
emitNativeObject({
executable: toolchain.helperExecutable, packageRoot: toolchain.helperPackageRoot,
compilerVersion: toolchain.compilerVersion, target: toolchain.target, helper: toolchain.helper,
inputPath: input, outputPath: object, sourcePath: entry, optimization: options.optimization,
});
if (options.outputKind === "exe") {
const runtime = stageNativeRuntimePack(toolchain.runtimePackRoot, join(stage, "runtime"),
toolchain.target, toolchain.compilerVersion, prepared.features, options.optimization);
const plan = executableLinkInputs({
target: toolchain.target, programObject: object,
ffiLibraries: ffi?.libraries ?? [], ffiSystemLibraries: ffi?.systemLibraries ?? [],
ffiFrameworks: ffi?.frameworks ?? [],
runtimeObjects: runtime.runtimeObjects, runtimeArchives: runtime.archives,
runtimeSystemLibraries: runtime.systemLibraries, optimization: options.optimization, strip: options.strip,
});
runNativeTool(toolchain.linker, [
...toolchain.linkerArgs, ...plan.driverFlags, ...plan.inputs,
...plan.systemLibraries.map((name) => `-l${name}`), "-o", stagedOutput,
]);
requireNativeArtifact(stagedOutput);
if (toolchain.target.platform === "darwin" && options.optimization === "dev" && !options.strip) {
runNativeTool(toolchain.dsymutil, [stagedOutput, "-o", stagedOutput + ".dSYM"]);
rmSync(output + ".dSYM", { recursive: true, force: true });
renameSync(stagedOutput + ".dSYM", output + ".dSYM");
}
}
}
if (options.keepLlvm && !sourceOutput) renameSync(join(inputDirectory, "program.ll"), output + ".ll");
renameSync(stagedOutput, output);
if (options.outputKind === "exe" && toolchain.target.platform === "darwin" && (options.optimization !== "dev" || options.strip)) {
rmSync(output + ".dSYM", { recursive: true, force: true });
}
return {
outputPath: output, stats: prepared.stats,
...(options.keepLlvm && !sourceOutput ? { llvmPath: output + ".ll" } : {}),
};
} finally { rmSync(stage, { recursive: true, force: true }); }
}
@@ -0,0 +1,159 @@
import { spawnSync } from "node:child_process";
import { mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, statSync, symlinkSync, utimesSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, expect, test, vi } from "vitest";
import { contentDigest, NativeCache } from "./cache.js";
import { NativeExecutableCache } from "./executable-cache.js";
vi.mock("node:child_process", async (original) => ({
...await original<typeof import("node:child_process")>(), spawnSync: vi.fn(),
}));
const directories: string[] = [];
afterEach(() => {
vi.resetAllMocks();
for (const path of directories.splice(0)) rmSync(path, { recursive: true, force: true });
});
function fixture(debug = false) {
const root = mkdtempSync(join(tmpdir(), "scriptc-native-executable-"));
directories.push(root);
const inputs = join(root, "inputs");
const sdk = join(root, "sdk");
const stage = join(root, "stage");
const restored = join(root, "restored");
for (const directory of [inputs, sdk, stage, restored]) mkdirSync(directory);
const runtime = join(inputs, "runtime.o");
const library = join(sdk, "libSystem.tbd");
writeFileSync(runtime, "runtime");
writeFileSync(library, "system library");
const cache = new NativeCache(join(root, "cache"));
const key = contentDigest("llvm and options");
const open = () => new NativeExecutableCache(cache, key, "clang", debug, [runtime]);
vi.mocked(spawnSync).mockReturnValue({ pid: 1, status: 0, signal: null, output: [], stdout: library + "\n", stderr: "" });
const output = join(stage, "program");
writeFileSync(output, Buffer.from([0, 128, 255, 10]));
if (debug) {
const contents = join(output + ".dSYM", "Contents");
mkdirSync(join(contents, "Resources", "DWARF"), { recursive: true });
writeFileSync(join(contents, "Info.plist"), "property list");
writeFileSync(join(contents, "Resources", "DWARF", "program"), "debug information");
}
const publish = () => {
const entry = open();
expect(entry.trace(["-o", output], stage)).toBe(true);
entry.publish(output);
};
return { root, inputs, sdk, stage, output, destination: join(restored, "program"), runtime, library, cache, key, open, publish };
}
test.each([false, true])("restores a complete executable and its debug information (%s)", (debug) => {
const f = fixture(debug);
f.publish();
vi.mocked(spawnSync).mockClear();
expect(f.open().restore(f.destination)).toBe(true);
expect(readFileSync(f.destination)).toEqual(readFileSync(f.output));
if (process.platform !== "win32") expect(statSync(f.destination).mode & 0o111).toBe(0o111);
expect(spawnSync).not.toHaveBeenCalled();
if (debug) {
expect(readFileSync(join(f.destination + ".dSYM/Contents/Resources/DWARF/program"), "utf8")).toBe("debug information");
expect(readFileSync(join(f.destination + ".dSYM/Contents/Info.plist"), "utf8")).toBe("property list");
}
});
test.each(["runtime", "library"] as const)("invalidates %s replacement even with restored size and mtime", (input) => {
const f = fixture();
f.publish();
const before = statSync(f[input]);
writeFileSync(f[input] + ".new", "x".repeat(before.size));
utimesSync(f[input] + ".new", before.atime, before.mtime);
renameSync(f[input] + ".new", f[input]);
expect(f.open().restore(f.destination)).toBe(false);
});
test("adding a new SDK library search candidate invalidates the previous link", () => {
const f = fixture();
f.publish();
writeFileSync(join(f.sdk, "libSystem.dylib"), "new candidate");
expect(f.open().restore(f.destination)).toBe(false);
});
test("a library search change during tracing prevents publication", () => {
const f = fixture();
vi.mocked(spawnSync).mockImplementation(() => {
if (vi.mocked(spawnSync).mock.calls.length === 2) writeFileSync(join(f.sdk, "new-library.tbd"), "library");
return { pid: 1, status: 0, signal: null, output: [], stdout: f.library + "\n", stderr: "" };
});
const entry = f.open();
expect(entry.trace([], f.stage)).toBe(false);
entry.publish(f.output);
expect(f.open().restore(f.destination)).toBe(false);
});
test.skipIf(process.platform === "win32")("retargeting an input symlink invalidates the previous link", () => {
const f = fixture();
const link = join(f.inputs, "current.o");
symlinkSync(f.runtime, link);
const open = () => new NativeExecutableCache(f.cache, f.key, "clang", false, [link]);
const entry = open();
expect(entry.trace([], f.stage)).toBe(true);
entry.publish(f.output);
expect(open().restore(f.destination)).toBe(true);
const replacement = join(f.inputs, "replacement.o");
writeFileSync(replacement, "runtime");
rmSync(link);
symlinkSync(replacement, link);
expect(open().restore(f.destination)).toBe(false);
});
test.each(["executable", "binary", "dsym"])("corrupt %s payloads are misses", (family) => {
const f = fixture(true);
f.publish();
const directory = join(f.cache.root, family);
const key = readdirSync(directory).find((name) => /^[0-9a-f]{64}$/.test(name))!;
writeFileSync(join(directory, key), "corrupt");
expect(f.open().restore(f.destination)).toBe(false);
});
test("invalid cache metadata is rejected even with a matching integrity digest", () => {
const f = fixture();
f.publish();
const bytes = f.cache.read("executable", f.key)!;
const entry = JSON.parse(bytes.toString());
entry.inputs = [];
f.cache.write("executable", f.key, JSON.stringify(entry));
expect(f.open().restore(f.destination)).toBe(false);
});
test("changing an input during emission prevents cache publication", () => {
const f = fixture();
const entry = f.open();
expect(entry.trace([], f.stage)).toBe(true);
writeFileSync(f.runtime, "replacement runtime");
entry.publish(f.output);
expect(f.open().restore(f.destination)).toBe(false);
});
test("metadata cannot omit a required runtime input", () => {
const f = fixture();
f.publish();
const bytes = f.cache.read("executable", f.key)!;
const entry = JSON.parse(bytes.toString());
entry.inputs = entry.inputs.filter((input: { path: string }) => input.path !== f.runtime);
f.cache.write("executable", f.key, JSON.stringify(entry));
expect(f.open().restore(f.destination)).toBe(false);
});
test("a failed or empty link trace cannot publish a completed executable", () => {
const f = fixture();
const entry = f.open();
entry.publish(f.output);
expect(f.open().restore(f.destination)).toBe(false);
vi.mocked(spawnSync).mockReturnValue({ pid: 1, status: 0, signal: null, output: [], stdout: "", stderr: "" });
expect(entry.trace([], f.stage)).toBe(false);
entry.publish(f.output);
expect(f.open().restore(f.destination)).toBe(false);
vi.mocked(spawnSync).mockReturnValue({ pid: 1, status: 1, signal: null, output: [], stdout: "", stderr: "failed" });
expect(entry.trace([], f.stage)).toBe(false);
});
@@ -0,0 +1,210 @@
import { spawnSync } from "node:child_process";
import { chmodSync, mkdirSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
import { basename, delimiter, dirname, isAbsolute, join, resolve } from "node:path";
import type { CompileRequestOptions } from "../compile-types.js";
import type { FfiProfile } from "../ffi/ffi-manifest.js";
import { driverTraceCandidates, linkTraceCandidate } from "../backend/link-trace.js";
import { toolchainEnvironmentCachePolicy, toolchainEnvironmentFingerprint } from "../backend/toolchain-environment.js";
import { runNativeTool } from "../backend/native-tools.js";
import type { NativeRuntimeSelection } from "../backend/runtime-pack-native.js";
import type { NativeToolchain } from "./toolchain.js";
import { contentDigest, NativeCache } from "./cache.js";
interface InputIdentity {
path: string;
canonical: string;
kind: "file" | "directory";
dev: number;
ino: number;
size: number;
mtime: number;
ctime: number;
}
interface ExecutableEntry {
schema: "scriptc.native-executable.v1";
inputs: InputIdentity[];
binary: string;
symbols: string | null;
}
function identity(path: string): InputIdentity {
path = resolve(path);
const info = statSync(path);
if (!info.isFile() && !info.isDirectory()) throw new Error(`unsupported native cache input: ${path}`);
return { path, canonical: realpathSync(path), kind: info.isFile() ? "file" : "directory",
dev: info.dev, ino: info.ino, size: info.size, mtime: info.mtimeMs, ctime: info.ctimeMs };
}
function stillMatches(inputs: InputIdentity[]): boolean {
try { return inputs.every((input) => JSON.stringify(identity(input.path)) === JSON.stringify(input)); }
catch { return false; }
}
function commandPath(command: string): string {
if (command.includes("/") || command.includes("\\")) return resolve(command);
for (const directory of (process.env["PATH"] ?? "/usr/bin:/bin").split(delimiter)) {
const candidate = join(directory || process.cwd(), command);
try { if (statSync(candidate).isFile()) return candidate; } catch { /* Try the next PATH entry. */ }
}
throw new Error(`native command is unavailable: ${command}`);
}
function toolOutput(executable: string, args: string[]): string {
const result = spawnSync(executable, args, { encoding: "utf8", stdio: "pipe" });
if (result.error) throw result.error;
if (result.status !== 0 || result.signal !== null) throw new Error("could not trace native link inputs");
return result.stdout + "\n" + result.stderr;
}
function tracePaths(output: string, driver: boolean, roots: string[]): string[] {
const paths = new Set<string>();
for (const line of output.split(/\r?\n/)) {
for (const candidate of driver ? driverTraceCandidates(line) : linkTraceCandidate(line)) {
const option = driver ? ["-L", "-F", "--sysroot="].find((prefix) => candidate.startsWith(prefix)) : undefined;
const spelling = option === undefined ? candidate : candidate.slice(option.length);
if (!isAbsolute(spelling)) continue;
const path = resolve(spelling);
if (roots.some((root) => path === root || path.startsWith(root + "/"))) continue;
try {
const input = identity(path);
paths.add(path);
paths.add(dirname(path));
if (input.kind === "directory" && path.endsWith(".sdk")) {
for (const name of ["SDKSettings.json", "SDKSettings.plist"]) {
const settings = join(path, name);
try { identity(settings); paths.add(settings); } catch { /* The SDK directory observes new settings files. */ }
}
}
} catch { /* Non-path trace tokens are not dependencies. */ }
}
}
return [...paths].sort();
}
function validEntry(value: unknown): value is ExecutableEntry {
if (value === null || typeof value !== "object") return false;
const entry = value as Partial<ExecutableEntry>;
return entry.schema === "scriptc.native-executable.v1" && typeof entry.binary === "string" &&
/^[0-9a-f]{64}$/.test(entry.binary) && (entry.symbols === null ||
(typeof entry.symbols === "string" && /^[0-9a-f]{64}$/.test(entry.symbols))) &&
Array.isArray(entry.inputs) && entry.inputs.length > 0 && entry.inputs.every((input) =>
input !== null && typeof input === "object" && typeof input.path === "string" && isAbsolute(input.path) &&
typeof input.canonical === "string" && (input.kind === "file" || input.kind === "directory") &&
[input.dev, input.ino, input.size, input.mtime, input.ctime].every((value) => typeof value === "number" && Number.isFinite(value)));
}
function readSymbols(binary: string): Buffer {
const contents = join(binary + ".dSYM", "Contents");
const plist = readFileSync(join(contents, "Info.plist"));
const dwarf = readFileSync(join(contents, "Resources", "DWARF", basename(binary)));
const header = Buffer.alloc(12);
header.write("SCDSYM01");
header.writeUInt32LE(plist.length, 8);
return Buffer.concat([header, plist, dwarf]);
}
function stageSymbols(bytes: Buffer, binary: string): void {
if (bytes.length < 12 || bytes.subarray(0, 8).toString() !== "SCDSYM01") throw new Error("invalid cached dSYM");
const end = 12 + bytes.readUInt32LE(8);
if (end <= 12 || end >= bytes.length) throw new Error("invalid cached dSYM sizes");
const contents = join(binary + ".dSYM", "Contents");
const dwarf = join(contents, "Resources", "DWARF");
mkdirSync(dwarf, { recursive: true });
writeFileSync(join(contents, "Info.plist"), bytes.subarray(12, end));
writeFileSync(join(dwarf, basename(binary)), bytes.subarray(end));
}
/** A completed executable is reusable only after the frontend has validated
* its source observations and emitted the same LLVM. Native inputs bracket
* helper verification, runtime staging and the actual link. */
export class NativeExecutableCache {
private inputs: InputIdentity[];
private traced = false;
constructor(private readonly cache: NativeCache, private readonly key: string,
private readonly linker: string, private readonly debug: boolean, paths: string[]) {
this.inputs = [...new Set(paths)].sort().map(identity);
}
restore(output: string): boolean {
try {
const metadata = this.cache.read("executable", this.key);
if (metadata === null) return false;
const entry: unknown = JSON.parse(metadata.toString("utf8"));
if (!validEntry(entry) || !stillMatches(entry.inputs) || !stillMatches(this.inputs)) return false;
if (!this.inputs.every((input) => entry.inputs.some((saved) => saved.path === input.path &&
JSON.stringify(saved) === JSON.stringify(input)))) return false;
if (this.debug !== (entry.symbols !== null)) return false;
const binary = this.cache.read("binary", entry.binary);
const symbols = entry.symbols === null ? null : this.cache.read("dsym", entry.symbols);
if (binary === null || binary.length === 0 || (this.debug && symbols === null)) return false;
if (!stillMatches(entry.inputs)) return false;
if (symbols !== null) stageSymbols(symbols, output);
writeFileSync(output, binary);
chmodSync(output, 0o755);
return true;
} catch { return false; }
}
/** Trace the real object-only link, including the SDK's transitive stubs.
* Directories from the driver's search line detect a newly added candidate.
* Private build inputs already belong to the LLVM/runtime cache identity. */
trace(args: string[], stage: string): boolean {
this.traced = false;
try {
const roots = [resolve(stage), realpathSync(stage)];
const dry = toolOutput(this.linker, [...args, "-###"]);
const driverInputs = tracePaths(dry, true, roots).map(identity);
const linked = toolOutput(this.linker, [...args, "-Wl,-t"]);
const paths = tracePaths(linked, false, roots);
if (paths.length === 0 || !stillMatches(this.inputs) || !stillMatches(driverInputs)) return false;
this.inputs.push(...driverInputs, ...paths.map(identity));
this.traced = true;
return true;
} catch { return false; }
}
publish(output: string): void {
try {
if (!this.traced || !stillMatches(this.inputs)) return;
const binary = readFileSync(output);
const symbols = this.debug ? readSymbols(output) : null;
const binaryKey = contentDigest(binary);
const symbolsKey = symbols === null ? null : contentDigest(symbols);
this.cache.write("binary", binaryKey, binary);
if (symbols !== null && symbolsKey !== null) this.cache.write("dsym", symbolsKey, symbols);
if (!stillMatches(this.inputs)) return;
const entry: ExecutableEntry = { schema: "scriptc.native-executable.v1", inputs: this.inputs, binary: binaryKey, symbols: symbolsKey };
this.cache.write("executable", this.key, JSON.stringify(entry));
} catch { /* Build artifacts remain valid when caching is unavailable. */ }
}
}
export function openNativeExecutableCache(cache: NativeCache | null, toolchain: NativeToolchain,
llvm: string, options: CompileRequestOptions, ffi: FfiProfile | null, pack: NativeRuntimeSelection): NativeExecutableCache | null {
// Match the established complete-cache contract: default Apple driver,
// known runtime inputs, and no wrapper or caller-supplied library searches.
if (cache === null || toolchain.target.platform !== "darwin" || options.sanitize ||
process.env["SCRIPTC_LINKER"] !== undefined || process.env["SCRIPTC_LLVM_HELPER"] !== undefined ||
toolchain.linkerArgs.length !== 0 || ffi !== null || !toolchainEnvironmentCachePolicy().completeArtifacts) return null;
try {
const linker = commandPath(toolchain.linker);
if (realpathSync(linker) !== "/usr/bin/clang") return null;
const effective = commandPath(runNativeTool(linker, ["-print-prog-name=clang"]).trim());
const debug = options.optimization === "dev" && !options.strip;
const paths = [linker, effective, toolchain.helperExecutable, join(toolchain.helperPackageRoot, "package.json"),
join(toolchain.runtimePackRoot, "package.json"), join(toolchain.runtimePackRoot, "runtime-pack.json"),
...[...pack.selected.runtime, ...pack.selected.archives, ...pack.manifest.licenses].map((artifact) => join(pack.root, artifact.path))];
if (debug) {
const dsymutil = commandPath(toolchain.dsymutil);
if (realpathSync(dsymutil) !== "/usr/bin/dsymutil") return null;
paths.push(dsymutil);
}
const key = contentDigest(JSON.stringify({ schema: 1, llvm: contentDigest(llvm), options, toolchain,
runtimeIdentity: pack.packageText, runtimeManifest: pack.manifestText,
linker: identity(linker), effective: identity(effective), environment: toolchainEnvironmentFingerprint(),
cwd: process.cwd(), path: process.env["PATH"] ?? null }));
return new NativeExecutableCache(cache, key, linker, debug, paths);
} catch { return null; }
}

Some files were not shown because too many files have changed in this diff Show More