diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b2d39e48f..409809d4e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,7 +41,9 @@ jobs: with: node-version-file: .node-version cache: pnpm + - run: brew install llvm@22 - run: pnpm install --frozen-lockfile + - run: pnpm --filter @scriptc/llvm-darwin-arm64 build:native - run: pnpm build # Separate vitest invocations because the shard axes must not mix: a file # lands in exactly ONE --shard slice, so an env-sharded file behind @@ -108,6 +110,64 @@ jobs: pnpm test packages/compiler/test/cc-driver.test.ts --testNamePattern "host-native clang static build" + llvm_artifacts_macos_arm64: + name: test (macOS arm64 LLVM artifacts, no clang, ${{ matrix.shard }}/3) + runs-on: macos-15 + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + shard: [1, 2, 3] + env: + # The helper differential uses the harness's stable per-case sharding, + # keeping each macOS job below its timeout while the matrix union still + # exercises every LLVM-tier program. + SCRIPTC_TEST_SHARD: ${{ matrix.shard }}/3 + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 11.1.3 + - uses: actions/setup-node@v4 + with: + node-version-file: .node-version + cache: pnpm + - run: brew install llvm@22 + - run: pnpm install --frozen-lockfile + - run: pnpm --filter @scriptc/llvm-darwin-arm64 build:native + - run: pnpm build + - name: No-clang assembly/object contract + if: matrix.shard == 1 + run: pnpm test packages/cli/test/native-output.test.ts + - name: Helper diagnostics and object/link parity + if: matrix.shard == 1 + run: pnpm test packages/compiler/test/native-codegen-integration.test.ts + - name: LLVM-tier helper object differential (${{ matrix.shard }}/3) + env: + SCRIPTC_LLVM_HELPER_ONLY: "1" + SCRIPTC_TEST_WORKERS: "4" + run: pnpm test tests/harness/llvm-differential.test.ts + - name: Packaged helper contract + if: matrix.shard == 1 + run: | + TARBALL=$(pnpm --dir packages/llvm-darwin-arm64 pack --pack-destination "$RUNNER_TEMP" --silent) + node scripts/verify-llvm-package.mjs "$RUNNER_TEMP/$(basename "$TARBALL")" + - name: Packed npm installation smoke + if: matrix.shard == 1 + run: | + pnpm --dir packages/runtime pack --pack-destination "$RUNNER_TEMP" --silent + pnpm --dir packages/compiler 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 \ + "$RUNNER_TEMP/scriptc-runtime-$(node -p "require('./packages/runtime/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-$(node -p "require('./packages/cli/package.json').version").tgz" + "$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' + # Exercises the supported Windows GNU target and the built CLI end to end: # TS7 must open its synthetic project, ambient files must resolve across # slash styles, and the default executable must use the .exe suffix. @@ -183,7 +243,7 @@ jobs: # single "test" check (branch protection, badges) keeps resolving. Fails # unless every matrix shard and both platform integration jobs succeeded. test: - needs: [tests, linux_host_clang, windows_cli] + needs: [tests, linux_host_clang, llvm_artifacts_macos_arm64, windows_cli] if: always() runs-on: ubuntu-latest steps: @@ -191,4 +251,5 @@ jobs: run: | test "${{ needs.tests.result }}" = success test "${{ needs.linux_host_clang.result }}" = success + test "${{ needs.llvm_artifacts_macos_arm64.result }}" = success test "${{ needs.windows_cli.result }}" = success diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c231de3f1..d71d70b0b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -53,7 +53,7 @@ jobs: name: Publish to npm needs: check-release if: needs.check-release.outputs.should_release == 'true' - runs-on: ubuntu-latest + runs-on: macos-15 timeout-minutes: 15 environment: Release permissions: @@ -74,10 +74,14 @@ jobs: node-version: "24" registry-url: "https://registry.npmjs.org" + - name: Install pinned LLVM + run: brew install llvm@22 + # Publishing uses npm trusted publishing (OIDC): the job's id-token # permission lets npm mint short-lived credentials, so no npm token - # secret exists anywhere in this repo. All three packages — - # @scriptc/runtime, @scriptc/compiler, scriptc — must each be + # secret exists anywhere in this repo. All four packages — + # @scriptc/runtime, @scriptc/llvm-darwin-arm64, @scriptc/compiler, + # and scriptc — must each be # configured on npmjs.com with a GitHub Actions trusted publisher # pointing at repository vercel-labs/scriptc, workflow release.yml, # environment Release. A package missing that configuration fails @@ -87,12 +91,13 @@ jobs: - name: Install and build run: | pnpm install --frozen-lockfile + pnpm --filter @scriptc/llvm-darwin-arm64 build:native pnpm -r build - name: Check version sync run: | VERSION="${{ needs.check-release.outputs.version }}" - for pkg in packages/runtime packages/compiler packages/cli; do + for pkg in packages/runtime packages/llvm-darwin-arm64 packages/compiler packages/cli; do V=$(node -p "require('./$pkg/package.json').version") if [ "$V" != "$VERSION" ]; then echo "Version mismatch: $pkg is $V, expected $VERSION" @@ -101,6 +106,13 @@ jobs: fi done + - name: Package and verify LLVM helper + run: | + TARBALL=$(pnpm --dir packages/llvm-darwin-arm64 pack --pack-destination "$RUNNER_TEMP" --silent) + HELPER_TARBALL="$RUNNER_TEMP/$(basename "$TARBALL")" + node scripts/verify-llvm-package.mjs "$HELPER_TARBALL" + echo "HELPER_TARBALL=$HELPER_TARBALL" >> "$GITHUB_ENV" + - name: Publish to npm run: | VERSION="${{ needs.check-release.outputs.version }}" @@ -123,16 +135,21 @@ jobs: # Re-runs skip anything already on the registry at this version. publish_dir() { dir="$1" + packed="${2:-}" name=$(node -p "require('./$dir/package.json').name") if npm view "$name@$VERSION" version >/dev/null 2>&1; then echo "$name@$VERSION already published, skipping" return 0 fi - tarball=$(cd "$dir" && pnpm pack --silent | tail -1) - npm publish "$dir/$tarball" $PROVENANCE --access public + if [ -z "$packed" ]; then + tarball=$(cd "$dir" && pnpm pack --silent | tail -1) + packed="$dir/$tarball" + fi + npm publish "$packed" $PROVENANCE --access public } publish_dir packages/runtime + publish_dir packages/llvm-darwin-arm64 "$HELPER_TARBALL" publish_dir packages/compiler publish_dir packages/cli env: @@ -142,9 +159,9 @@ jobs: # 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). - # scriptc has no platform binary assets to stage (programs compile on - # the user's machine), so the job runs AFTER a successful npm publish - # and never gates it. The body is the CHANGELOG.md block between the + # 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. github-release: diff --git a/.gitignore b/.gitignore index f41c8283d..efe9e4d57 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ node_modules/ !tests/fixtures/gateway-e2e/node_modules/ !tests/fixtures/node-types/node_modules/ dist/ +/packages/llvm-darwin-arm64/bin/ !tests/fixtures/fetch/node_modules/eventsource-parser/dist/ !tests/fixtures/npm/node_modules/*/dist/ !tests/fixtures/npm/workspace/*/dist/ diff --git a/AGENTS.md b/AGENTS.md index dadadc37f..49ac17e3e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,6 +9,12 @@ pnpm install && pnpm -r build # build the workspace pnpm test:sandbox # full gate: ~4m custom image, ~9m cold managed fallback ``` +The ordinary workspace build does not rebuild the packaged macOS LLVM helper. +When changing native assembly/object emission, install CMake, Ninja, and +Homebrew `llvm@22`, then run +`pnpm --filter @scriptc/llvm-darwin-arm64 build:native` explicitly. The macOS +full test suite also needs that generated helper. + Use focused local tests while iterating, then use `pnpm test:sandbox` whenever a full validation gate is required. It loads Sandbox configuration from the shell and `.env.local`, runs portable coverage across disposable Linux diff --git a/README.md b/README.md index c2b51a19c..c237e1c2e 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # scriptc -scriptc compiles TypeScript and JavaScript to typed IR, readable C, textual LLVM IR, native executables, and WebAssembly modules. It uses the TypeScript compiler for parsing and type checking. Source outputs require only Node; executable builds currently use clang to compile and link the emitted program and runtime. +scriptc compiles TypeScript and JavaScript to typed IR, readable C, textual LLVM IR, native assembly and objects, native executables, and WebAssembly modules. It uses the TypeScript compiler for parsing and type checking. Source outputs require only Node; macOS 15+ arm64 assembly/object output uses scriptc's bundled LLVM helper; executable builds currently use clang to compile/link the runtime. Static builds include a small native runtime, but no Node or JavaScript engine. Code that cannot compile statically is reported as a diagnostic. For npm packages and `any`-typed code, `--dynamic` embeds [quickjs-ng](https://github.com/quickjs-ng/quickjs) explicitly. @@ -8,7 +8,7 @@ scriptc is experimental and targets macOS, Linux, Windows, and WebAssembly via W ## Installation -The compiler requires Node.js 24 or newer. Executable builds also require clang; `--emit=ir|c|llvm` does not. The executables it produces do not require Node. +The compiler requires Node.js 24 or newer. `--emit=ir|c|llvm` needs only Node. On macOS 15+ arm64, `--emit=asm|obj` additionally uses the optional platform helper installed with scriptc, but needs no compiler, archiver, linker, or SDK. Executable builds still require clang and the platform SDK. The executables it produces do not require Node. ```console $ npm install -g scriptc @@ -50,8 +50,23 @@ hello.c $ scriptc build hello.ts --emit=llvm >/dev/null $ ls .scriptc/ hello.ll +$ scriptc build hello.ts --emit=asm >/dev/null +$ ls .scriptc/ +hello.s +$ scriptc build hello.ts --emit=obj >/dev/null +$ ls .scriptc/ +hello.o ``` +`--emit=obj` writes a relocatable program object, not a standalone library. It +has undefined `scr_*` runtime references and a required +`scr_runtime_abi_v1` marker; `scriptc build --lib --profile ...` remains the +self-contained archive interface. The helper runs on macOS 15+ arm64 and emits +artifacts with an `arm64-apple-macosx14.0.0` deployment target. Sanitized +assembly/object +emission is rejected until the helper's AddressSanitizer pipeline matches the +executable path. + ## Use Node APIs Supported Node APIs compile to the native runtime. For example, `server.ts`: @@ -131,6 +146,12 @@ $ vercel link && vercel env pull # writes a project-scoped VERCEL_OIDC_TOKEN $ pnpm test:sandbox ``` +The normal workspace build needs no local LLVM installation. To rebuild the +optional macOS arm64 assembly/object helper, install CMake, Ninja, and +Homebrew `llvm@22`, then run +`pnpm --filter @scriptc/llvm-darwin-arm64 build:native`. The macOS full test +suite also uses that generated helper. + `pnpm test:sandbox` loads `.env.local`, preflights Vercel authentication and project access, and uses the managed `vercel/sandbox/universal` image by default. It installs the repository-pinned Node, pnpm, and LLVM toolchain plus diff --git a/RELEASING.md b/RELEASING.md index c252b6b6e..8a1cffa36 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -1,19 +1,29 @@ # Releasing -Releases are manual, single-commit affairs. The maintainer controls the changelog voice and format. The three npm packages — `@scriptc/runtime`, `@scriptc/compiler`, `scriptc` — always publish together at the same version. +Releases are manual, single-commit affairs. The maintainer controls the changelog voice and format. The four npm packages — `@scriptc/runtime`, `@scriptc/llvm-darwin-arm64`, `@scriptc/compiler`, and `scriptc` — always publish together at the same version. To prepare a release: 1. Bump the version in `packages/cli/package.json` -2. Run `node scripts/sync-versions.mjs` to stamp the same version into `packages/runtime` and `packages/compiler`, then `pnpm manifest` to restamp `packages/compiler/surface-manifest.json` with the new version, and commit both (the test suite's staleness guard fails on a version drift) +2. Run `node scripts/sync-versions.mjs` to stamp the same version into `packages/runtime`, `packages/llvm-darwin-arm64`, and `packages/compiler`, then `pnpm manifest` to restamp `packages/compiler/surface-manifest.json` with the new version, and commit both (the test suite's staleness guard fails on a version drift) 3. Fold the `## Unreleased` section of `CHANGELOG.md` into a new `## ` entry (newest first, below `## Unreleased`), and leave `## Unreleased` empty for the next cycle 4. Wrap the new entry in `` and `` markers; this marked block is also the GitHub release body 5. Remove the `` and `` markers from the previous release entry; only the latest release should have markers 6. With Zig on `PATH`, run `SCRIPTC_CROSS=1 pnpm exec vitest run tests/harness/library-cross.test.ts` and require the cross-target library conformance lane to pass 7. Commit to `main` -CI (`.github/workflows/release.yml`) compares the version in `packages/cli/package.json` to what `scriptc` has on npm. If it differs, it builds the workspace, verifies all three package versions match (a mismatch fails with a hint to run `scripts/sync-versions.mjs`), and publishes to npm in dependency order — `@scriptc/runtime`, then `@scriptc/compiler`, then `scriptc` — so each package's dependencies are resolvable the moment it lands. After the publish succeeds, a separate job creates the git tag `v` 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`. +CI (`.github/workflows/release.yml`) compares the version in `packages/cli/package.json` to what `scriptc` has on npm. If it differs, it builds the workspace, verifies all four package versions match (a mismatch fails with a hint to run `scripts/sync-versions.mjs`), and publishes to npm in dependency order — `@scriptc/runtime`, `@scriptc/llvm-darwin-arm64`, `@scriptc/compiler`, then `scriptc` — so each package's dependencies are resolvable the moment it lands. After the publish succeeds, a separate job creates the git tag `v` 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`. -Two deliberate differences from repositories that ship prebuilt binaries: there are no platform binary assets to build or stage — scriptc compiles programs on the user's machine with the local clang. The npm package's best-effort postinstall warms runtime, TLS, and engine caches against that exact local toolchain; it does not ship foreign objects. The GitHub release is therefore a tag, release notes, and the manifest asset only, and the npm publish never waits on the GitHub release (the release job runs after the publish, not before it). +The release job runs on macOS arm64, builds and strips the pinned LLVM helper, +and publishes its constrained platform package before `@scriptc/compiler`. +Executable/runtime compilation still uses the user's local clang; the helper +owns only assembly/object code generation. The npm package's best-effort +postinstall warms runtime, TLS, and engine caches against that exact local +toolchain. The GitHub release remains a tag, release notes, and the manifest +asset; the npm publish never waits on the GitHub release. -Publishing uses npm trusted publishing (OIDC) — there is no npm token secret. The one-time setup is already done: each of the three packages is configured on npmjs.com with a GitHub Actions trusted publisher pointing at repository `vercel-labs/scriptc`, workflow `release.yml`, environment `Release`. A package missing that configuration fails with an OIDC authentication error before anything is uploaded. Re-runs are safe: any package already on the registry at the target version is skipped, so a partially published release can be resumed by re-running the workflow. +Publishing uses npm trusted publishing (OIDC) — there is no npm token secret. +Each of the four packages must have a GitHub Actions trusted publisher for +`release.yml` and the `Release` environment. A missing configuration fails +before upload. Re-runs skip package versions already present on npm, so a +partially published release can be resumed safely. diff --git a/docs/src/app/cli/page.mdx b/docs/src/app/cli/page.mdx index 858db84de..03d984bc2 100644 --- a/docs/src/app/cli/page.mdx +++ b/docs/src/app/cli/page.mdx @@ -19,7 +19,7 @@ Usage: ## scriptc build -Compiles a TypeScript (or JavaScript) entry file to serialized typed IR, readable C, textual LLVM IR, a native executable, or a WebAssembly module when the wasm32-wasi target is selected. The program is type-checked first — by the real TypeScript compiler, honoring the nearest `tsconfig.json` — and any construct without a lowering is a compile error with an `SC`-prefixed code, a code frame, and usually a rewrite hint. +Compiles a TypeScript (or JavaScript) entry file to serialized typed IR, readable C, textual LLVM IR, native assembly, a relocatable object, a native executable, or a WebAssembly module when the wasm32-wasi target is selected. The program is type-checked first — by the real TypeScript compiler, honoring the nearest `tsconfig.json` — and any construct without a lowering is a compile error with an `SC`-prefixed code, a code frame, and usually a rewrite hint. ```console $ scriptc build fib.ts -o fib @@ -39,9 +39,21 @@ fib.c $ scriptc build fib.ts --emit=llvm >/dev/null $ ls .scriptc/ fib.ll +$ scriptc build fib.ts --emit=asm >/dev/null +$ ls .scriptc/ +fib.s +$ scriptc build fib.ts --emit=obj >/dev/null +$ ls .scriptc/ +fib.o ``` -These three source outputs require only Node. They do not resolve or invoke a compiler, archiver, or linker. `--emit=exe` is the default and retains the existing executable behavior. +The three source outputs require only Node. On macOS 15+ arm64, assembly and object +outputs use the matching native helper installed with scriptc and do not +invoke an external compiler, archiver, linker, or SDK. The object is a +relocatable program object with undefined scr_* runtime symbols +and a required scr_runtime_abi_v1 marker, not a standalone library. +--emit=exe is the default and retains the existing executable +behavior. ## scriptc run @@ -66,10 +78,10 @@ Prebuilds the release runtime objects and native TLS/dynamic-engine archives aga
-o, --out <path>
-
Primary artifact path. An explicit path is exact. Defaults are .scriptc/<name>.ir.json, .c, .ll, or the platform executable name.
+
Primary artifact path. An explicit path is exact. Defaults are .scriptc/<name>.ir.json, .c, .ll, .s, .o, or the platform executable name.
-
--emit <ir|c|llvm|exe>
-
Select the invocation's one primary artifact. ir, c, and llvm stop before native compilation and need no external toolchain. exe is the default. asm and obj are reserved for the native-helper release and currently report an unsupported-output error.
+
--emit <ir|c|llvm|asm|obj|exe>
+
Select the invocation's one primary artifact. ir, c, and llvm need only Node. asm and obj use the bundled LLVM helper on macOS 15+ arm64 and emit artifacts targeting macOS 14.0. exe is the default.
--dynamic
Embed the dynamic engine (~620KB) so npm dependencies and any-typed code can run. Static stays the default — without this flag, dynamic-tier sites are per-site compile errors. See npm Dependencies.
@@ -90,7 +102,7 @@ Prebuilds the release runtime objects and native TLS/dynamic-engine archives aga
Coverage only. Map an exact bare module specifier to a local declaration file supplied by an embedder. Repeat the option for multiple modules. The declaration supplies checker types so application coverage can continue; runtime imports and values remain explicit SC1010 blockers. Relative paths resolve from the current working directory. Accepted files end in .d.ts, .d.mts, or .d.cts.
--sanitize
-
Build with AddressSanitizer plus the runtime reference-count audit — the same lane the compiler's own test corpus runs under. Useful when a program misbehaves and you want leaks or memory errors to be loud.
+
Build an executable with AddressSanitizer plus the runtime reference-count audit — the same lane the compiler's own test corpus runs under. --emit=asm|obj rejects this option until the helper's sanitizer pipeline has parity.
--emit-ir
Additive IR side artifact. It is deprecated for executable builds for one release cycle; prefer --emit=ir when IR is the primary output. Library mode retains --emit-ir because --emit does not select library artifacts.
@@ -164,6 +176,12 @@ An explicit --backend llvm pins the LLVM backend and fails with dia Not used Not used + + --emit=asm|obj (macOS 15+ arm64) + Required + Bundled scriptc LLVM helper + Not used + --emit=exe Required to run scriptc diff --git a/docs/src/app/how-it-works/page.mdx b/docs/src/app/how-it-works/page.mdx index b3b5114ed..a38e4a0c5 100644 --- a/docs/src/app/how-it-works/page.mdx +++ b/docs/src/app/how-it-works/page.mdx @@ -3,14 +3,15 @@ ## The pipeline ``` -TypeScript ──tsc: parse + typecheck──▶ lowering ──▶ typed IR ──▶ LLVM IR ──clang──▶ native executable - │ └─────▶ C ─────────┘ +TypeScript ──tsc: parse + typecheck──▶ lowering ──▶ typed IR ──▶ LLVM IR ──scriptc LLVM helper──▶ assembly/object + │ │ └──clang + runtime/SDK──▶ executable + │ └─────▶ C ────────────────clang───────┘ └── serialized IR ``` 1. **Frontend** — the real TypeScript compiler parses and type-checks your program against `es2025` (plus `@types/node` when your project has it), honoring your `tsconfig.json` for checker strictness. The frontend then lowers the checked AST into a typed intermediate representation, using tsc's own type and narrowing answers to drive every decision. A construct with no lowering is a precise diagnostic at this stage — never a miscompile later. 2. **Typed IR** — the only interface between the ends: a validated, serializable representation (`--emit=ir` writes it as JSON and stops). Types are concrete here; generics have been monomorphized, unions are tagged values, closures have explicit captures. -3. **Backends** — `--emit=c` writes readable C and stops; `--emit=llvm` writes textual LLVM IR and stops. Neither source-output command discovers or invokes a native toolchain. Executable builds default to LLVM and can fall back to C on a native program outside the LLVM tier (one stderr note; `--backend llvm` pins it and fails with a diagnostic instead). The production wasm32-wasi target never falls back. For executable builds, both backends are compiled by the same clang, and differential tests require byte-identical program output wherever they overlap. +3. **Backends** — `--emit=c` writes readable C and stops; `--emit=llvm` writes textual LLVM IR and stops. Neither source-output command discovers or invokes a native toolchain. On macOS 15+ arm64, `--emit=asm|obj` sends LLVM IR to a version-matched out-of-process helper linked to LLVM 22; it needs no clang or linker and emits macOS 14-targeted artifacts. Executable builds default to LLVM and can fall back to C on a native program outside the LLVM tier (one stderr note; `--backend llvm` pins it and fails with a diagnostic instead). The production wasm32-wasi target never falls back. 4. **Link** — the runtime is a C library of link-gated feature units: binaries pay only for what they use. A hello-world links nothing but libSystem; a regex-using program links the regex engine; an `http` server links the net stack. Inspect any stage yourself: @@ -25,6 +26,12 @@ fib.c $ scriptc build fib.ts --emit=llvm $ ls .scriptc/ fib.ll +$ scriptc build fib.ts --emit=asm +$ ls .scriptc/ +fib.s +$ scriptc build fib.ts --emit=obj +$ ls .scriptc/ +fib.o ``` ## The runtime @@ -60,7 +67,11 @@ Where matching Node byte-for-byte is impossible or deliberately not the goal (ti packages/compiler - The frontend (tsc API → IR), the typed IR with validator and serializer, the C and LLVM backends, the coverage analyzer. + The frontend (tsc API → IR), the typed IR with validator and serializer, the C and LLVM backends, native-helper integration, and the coverage analyzer. + + + native/llvm-codegen + The LLVM 22 sidecar that verifies, optimizes, and emits target assembly or objects. packages/runtime diff --git a/docs/src/app/platforms/page.mdx b/docs/src/app/platforms/page.mdx index d504d1e2d..3ed560ab5 100644 --- a/docs/src/app/platforms/page.mdx +++ b/docs/src/app/platforms/page.mdx @@ -2,7 +2,7 @@ ## macOS (arm64) -The primary platform. clang — preinstalled with the Xcode Command Line Tools — is the only system dependency for producing executables. Source artifacts selected with --emit=ir|c|llvm need only Node and do not invoke a compiler, archiver, or linker. The full executable surface is supported: the language, the stdlib, the Node API surface including the server stack, `--dynamic`, and the sanitizer lane. +The primary platform. clang — preinstalled with the Xcode Command Line Tools — is the only system dependency for producing executables. Source artifacts selected with --emit=ir|c|llvm need only Node. On macOS 15+ arm64, --emit=asm|obj uses the version-matched @scriptc/llvm-darwin-arm64 helper installed with scriptc, emits arm64-apple-macosx14.0.0 artifacts, and does not invoke a compiler, archiver, linker, or SDK. Object output retains undefined runtime references and is not a library archive. The full executable surface is supported: the language, the stdlib, the Node API surface including the server stack, `--dynamic`, and the sanitizer lane. ## Cross-compilation via zig diff --git a/docs/src/app/quickstart/page.mdx b/docs/src/app/quickstart/page.mdx index db11bc782..9aeff097e 100644 --- a/docs/src/app/quickstart/page.mdx +++ b/docs/src/app/quickstart/page.mdx @@ -6,7 +6,7 @@ Install the CLI from npm and compile your first binary in a couple of minutes. - **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 --emit=ir|c|llvm. +- **clang** — preinstalled with the Xcode Command Line Tools; required for executable builds, but not for --emit=ir|c|llvm|asm|obj. Assembly/object output uses the matching helper installed with scriptc and requires macOS 15+. ## Install diff --git a/native/llvm-codegen/CMakeLists.txt b/native/llvm-codegen/CMakeLists.txt new file mode 100644 index 000000000..26cebe2d6 --- /dev/null +++ b/native/llvm-codegen/CMakeLists.txt @@ -0,0 +1,79 @@ +cmake_minimum_required(VERSION 3.24) + +# These must be cache variables before project() enables the Apple toolchain. +# A target property named OSX_DEPLOYMENT_TARGET is not recognized by CMake and +# silently leaves the executable at the build host/SDK's deployment version. +if(CMAKE_HOST_APPLE) + set(CMAKE_OSX_ARCHITECTURES "arm64" CACHE STRING + "Architectures for the macOS helper" FORCE) + # Homebrew's pinned LLVM 22 bottle is built for macOS 15. The helper must + # tell the truth about that host requirement; the objects it emits retain + # their separate arm64-apple-macosx14.0.0 deployment target. + set(CMAKE_OSX_DEPLOYMENT_TARGET "15.0" CACHE STRING + "Minimum macOS version for the packaged helper" FORCE) +endif() + +project(scriptc_llvm_codegen LANGUAGES C CXX) + +find_package(LLVM 22.1.8 EXACT REQUIRED CONFIG) + +set(SCRIPTC_PACKAGE_VERSION "0.0.0-dev" CACHE STRING + "scriptc npm package version reported by the helper protocol") + +# LLVM's exported Support target names zstd's shared target even when the +# selected LLVM components are static. The npm sidecar must not retain a +# Homebrew zstd dylib dependency. +if(TARGET zstd::libzstd_shared AND TARGET zstd::libzstd_static) + get_target_property(SCRIPTC_ZSTD_STATIC zstd::libzstd_static IMPORTED_LOCATION_RELEASE) + if(NOT SCRIPTC_ZSTD_STATIC) + get_target_property(SCRIPTC_ZSTD_STATIC zstd::libzstd_static IMPORTED_LOCATION) + endif() + if(SCRIPTC_ZSTD_STATIC) + set_target_properties(zstd::libzstd_shared PROPERTIES + IMPORTED_LOCATION "${SCRIPTC_ZSTD_STATIC}" + IMPORTED_LOCATION_RELEASE "${SCRIPTC_ZSTD_STATIC}" + ) + endif() +endif() + +add_executable(scriptc-llvm-codegen + src/diagnostics.cpp + src/emit.cpp + src/main.cpp + src/target.cpp +) + +target_compile_features(scriptc-llvm-codegen PRIVATE cxx_std_17) +target_include_directories(scriptc-llvm-codegen PRIVATE ${LLVM_INCLUDE_DIRS}) +target_compile_definitions(scriptc-llvm-codegen PRIVATE + ${LLVM_DEFINITIONS} + SCRIPTC_PACKAGE_VERSION="${SCRIPTC_PACKAGE_VERSION}" +) + +llvm_map_components_to_libnames(SCRIPTC_LLVM_LIBS + AArch64CodeGen + CodeGen + Core + Coroutines + IRReader + Passes + Support + Target +) +target_link_libraries(scriptc-llvm-codegen PRIVATE ${SCRIPTC_LLVM_LIBS}) + +if(APPLE) + # Homebrew's static LLVM archives contain broad component object files. + # Export only the process entry point so archive-global LLVM symbols do not + # become roots, then discard functions/data the small helper protocol cannot + # reach. This also keeps the result stable across LLVM bottle layouts whose + # static archives expose different sets of global symbols. + target_link_options(scriptc-llvm-codegen PRIVATE + "LINKER:-exported_symbol,_main" + "LINKER:-dead_strip" + # Homebrew's unversioned LLVM 22 bottle adds Z3 as an unconditional + # transitive dylib even though this helper never reaches the solver. Drop + # unused dylibs so the npm sidecar remains runnable without Homebrew. + "LINKER:-dead_strip_dylibs" + ) +endif() diff --git a/native/llvm-codegen/README.md b/native/llvm-codegen/README.md new file mode 100644 index 000000000..d6d57fbb6 --- /dev/null +++ b/native/llvm-codegen/README.md @@ -0,0 +1,34 @@ +# scriptc LLVM code-generation helper + +This out-of-process helper owns LLVM assembly and object emission for +scriptc. It is built against exactly LLVM 22.1.8 and currently contains only +the AArch64 backend. The shipping `@scriptc/llvm-darwin-arm64` package builds +and carries the executable; the compiler resolves that package directly and +never searches `PATH` for this program. + +The ordinary workspace `pnpm -r build` does not rebuild this release artifact. +On macOS arm64, install CMake, Ninja, and Homebrew `llvm@22`, then build it +explicitly when working on native emission or preparing a package: + +```console +$ brew install cmake ninja llvm@22 +$ pnpm --filter @scriptc/llvm-darwin-arm64 build:native +``` + +The protocol is intentionally small and versioned: + +The packaged helper itself requires macOS 15 or newer because that is the +minimum version of the pinned LLVM bottle it statically links. Its emitted +assembly and objects separately target macOS 14 via the triple below. + +```console +scriptc-llvm-codegen version --format=json +scriptc-llvm-codegen emit --input app.ll --output app.o --filetype obj \ + --target arm64-apple-macosx14.0.0 --opt-level 2 \ + --relocation-model pic --diagnostic-format json --source-path app.ts +``` + +Emission uses LLVM 22's default per-module O2 pipeline, including coroutine +lowering, verifies before and after optimization, and publishes through a +private sibling file so a failed or interrupted request cannot truncate the +requested output. diff --git a/native/llvm-codegen/src/diagnostics.cpp b/native/llvm-codegen/src/diagnostics.cpp new file mode 100644 index 000000000..d94735259 --- /dev/null +++ b/native/llvm-codegen/src/diagnostics.cpp @@ -0,0 +1,44 @@ +#include "diagnostics.h" + +#include "llvm/Support/ErrorHandling.h" +#include "llvm/Support/FormatVariadic.h" +#include "llvm/Support/JSON.h" +#include "llvm/Support/raw_ostream.h" + +#include + +using namespace llvm; + +namespace scriptc { + +int reportError(StringRef Code, const Twine &Message, + StringRef DiagnosticFormat) { + if (DiagnosticFormat == "json") { + json::Object Diagnostic{ + {"ok", false}, + {"code", Code}, + {"message", Message.str()}, + }; + errs() << formatv("{0}\n", json::Value(std::move(Diagnostic))); + } else { + errs() << "scriptc-llvm-codegen: " << Message << '\n'; + } + return 1; +} + +static void fatalDiagnostic(void *, const char *Reason, bool) { + json::Object Diagnostic{ + {"ok", false}, + {"code", "llvm_fatal"}, + {"message", Reason == nullptr ? "LLVM reported a fatal error" : Reason}, + }; + errs() << formatv("{0}\n", json::Value(std::move(Diagnostic))); + errs().flush(); + std::_Exit(70); +} + +void installFatalDiagnosticHandler() { + install_fatal_error_handler(fatalDiagnostic, nullptr); +} + +} // namespace scriptc diff --git a/native/llvm-codegen/src/diagnostics.h b/native/llvm-codegen/src/diagnostics.h new file mode 100644 index 000000000..eb62d97f0 --- /dev/null +++ b/native/llvm-codegen/src/diagnostics.h @@ -0,0 +1,12 @@ +#pragma once + +#include "llvm/ADT/StringRef.h" +#include "llvm/ADT/Twine.h" + +namespace scriptc { + +int reportError(llvm::StringRef Code, const llvm::Twine &Message, + llvm::StringRef DiagnosticFormat = "json"); +void installFatalDiagnosticHandler(); + +} // namespace scriptc diff --git a/native/llvm-codegen/src/emit.cpp b/native/llvm-codegen/src/emit.cpp new file mode 100644 index 000000000..75652f00f --- /dev/null +++ b/native/llvm-codegen/src/emit.cpp @@ -0,0 +1,197 @@ +#include "emit.h" + +#include "diagnostics.h" +#include "target.h" + +#include "llvm/ADT/SmallString.h" +#include "llvm/ADT/StringRef.h" +#include "llvm/IR/LegacyPassManager.h" +#include "llvm/IR/Module.h" +#include "llvm/IR/Verifier.h" +#include "llvm/IRReader/IRReader.h" +#include "llvm/Passes/PassBuilder.h" +#include "llvm/Support/FileSystem.h" +#include "llvm/Support/SourceMgr.h" +#include "llvm/Support/raw_ostream.h" +#include "llvm/Target/TargetMachine.h" +#include "llvm/TargetParser/Triple.h" + +#include +#include +#include + +using namespace llvm; + +namespace scriptc { + +std::optional parseEmitOptions(int Argc, char **Argv) { + EmitOptions Options; + Options.Target = DefaultTarget.str(); + for (int I = 2; I < Argc; ++I) { + StringRef Arg(Argv[I]); + if (!Arg.starts_with("--") || I + 1 >= Argc) + return std::nullopt; + StringRef Value(Argv[++I]); + if (Arg == "--input") + Options.Input = Value.str(); + else if (Arg == "--output") + Options.Output = Value.str(); + else if (Arg == "--filetype") + Options.FileType = Value.str(); + else if (Arg == "--target") + Options.Target = Value.str(); + else if (Arg == "--opt-level") + Options.OptLevel = Value.str(); + else if (Arg == "--relocation-model") + Options.RelocationModel = Value.str(); + else if (Arg == "--diagnostic-format") + Options.DiagnosticFormat = Value.str(); + else if (Arg == "--source-path") + Options.SourcePath = Value.str(); + else + return std::nullopt; + } + if (Options.Input.empty() || Options.Output.empty()) + return std::nullopt; + return Options; +} + +static OptimizationLevel optimizationLevel(StringRef Level) { + if (Level == "0") + return OptimizationLevel::O0; + if (Level == "1") + return OptimizationLevel::O1; + if (Level == "3") + return OptimizationLevel::O3; + if (Level == "s") + return OptimizationLevel::Os; + if (Level == "z") + return OptimizationLevel::Oz; + return OptimizationLevel::O2; +} + +int emit(const EmitOptions &Options) { + if (Options.Target != DefaultTarget) + return reportError("unsupported_target", + Twine("unsupported target '") + Options.Target + + "' (supported: " + DefaultTarget + ")", + Options.DiagnosticFormat); + if (Options.FileType != "obj" && Options.FileType != "asm") + return reportError("invalid_filetype", "filetype must be obj or asm", + Options.DiagnosticFormat); + if (Options.OptLevel != "0" && Options.OptLevel != "1" && + Options.OptLevel != "2" && Options.OptLevel != "3" && + Options.OptLevel != "s" && Options.OptLevel != "z") + return reportError("invalid_opt_level", + "opt-level must be 0, 1, 2, 3, s, or z", + Options.DiagnosticFormat); + if (Options.RelocationModel != "pic") + return reportError("invalid_relocation_model", + "only the pic relocation model is supported", + Options.DiagnosticFormat); + + SMDiagnostic ParseDiagnostic; + LLVMContext Context; + std::unique_ptr Mod = + parseIRFile(Options.Input, ParseDiagnostic, Context); + if (!Mod) { + std::string Detail; + raw_string_ostream Stream(Detail); + ParseDiagnostic.print("scriptc-llvm-codegen", Stream); + return reportError("invalid_ir", Stream.str(), Options.DiagnosticFormat); + } + if (!Options.SourcePath.empty()) + Mod->setSourceFileName(Options.SourcePath); + + std::string LookupError; + std::unique_ptr Machine = + createTargetMachine(Options.Target, Options.OptLevel, LookupError); + if (!Machine) + return reportError("target_machine_failed", LookupError, + Options.DiagnosticFormat); + + Triple TargetTriple(Options.Target); + Mod->setTargetTriple(TargetTriple); + Mod->setDataLayout(Machine->createDataLayout()); + if (Mod->getDataLayoutStr() != DefaultDataLayout) + return reportError("data_layout_mismatch", + Twine("LLVM produced unexpected data layout '") + + Mod->getDataLayoutStr() + "'", + Options.DiagnosticFormat); + + std::string VerificationError; + raw_string_ostream VerificationStream(VerificationError); + if (verifyModule(*Mod, &VerificationStream)) + return reportError("verification_failed", VerificationStream.str(), + Options.DiagnosticFormat); + + LoopAnalysisManager LAM; + FunctionAnalysisManager FAM; + CGSCCAnalysisManager CGAM; + ModuleAnalysisManager MAM; + PassBuilder PB(Machine.get()); + PB.registerModuleAnalyses(MAM); + PB.registerCGSCCAnalyses(CGAM); + PB.registerFunctionAnalyses(FAM); + PB.registerLoopAnalyses(LAM); + PB.crossRegisterProxies(LAM, FAM, CGAM, MAM); + ModulePassManager Optimizations = + PB.buildPerModuleDefaultPipeline(optimizationLevel(Options.OptLevel)); + Optimizations.run(*Mod, MAM); + + VerificationError.clear(); + if (verifyModule(*Mod, &VerificationStream)) + return reportError("post_optimization_verification_failed", + VerificationStream.str(), Options.DiagnosticFormat); + + SmallString<256> OutputPath(Options.Output); + SmallString<256> TemporaryPath(OutputPath); + TemporaryPath.append(".tmp-%%%%%%"); + int TemporaryFd = -1; + if (std::error_code EC = + sys::fs::createUniqueFile(TemporaryPath, TemporaryFd, TemporaryPath)) + return reportError("output_open_failed", EC.message(), + Options.DiagnosticFormat); + + { + raw_fd_ostream Output(TemporaryFd, true); + legacy::PassManager CodeGeneration; + CodeGenFileType Type = Options.FileType == "obj" + ? CodeGenFileType::ObjectFile + : CodeGenFileType::AssemblyFile; + if (Machine->addPassesToEmitFile(CodeGeneration, Output, nullptr, Type)) { + sys::fs::remove(TemporaryPath); + return reportError("emission_not_supported", + "target does not support the requested file type", + Options.DiagnosticFormat); + } + CodeGeneration.run(*Mod); + Output.flush(); + if (Output.has_error()) { + std::error_code EC = Output.error(); + sys::fs::remove(TemporaryPath); + return reportError("output_write_failed", EC.message(), + Options.DiagnosticFormat); + } + } + + uint64_t Size = 0; + if (std::error_code EC = sys::fs::file_size(TemporaryPath, Size)) { + sys::fs::remove(TemporaryPath); + return reportError("output_verify_failed", EC.message(), + Options.DiagnosticFormat); + } + if (Size == 0) { + sys::fs::remove(TemporaryPath); + return reportError("output_verify_failed", "LLVM emitted an empty file", + Options.DiagnosticFormat); + } + if (std::error_code EC = sys::fs::rename(TemporaryPath, OutputPath)) { + sys::fs::remove(TemporaryPath); + return reportError("output_publish_failed", EC.message(), + Options.DiagnosticFormat); + } + return 0; +} + +} // namespace scriptc diff --git a/native/llvm-codegen/src/emit.h b/native/llvm-codegen/src/emit.h new file mode 100644 index 000000000..b5f7ec7fa --- /dev/null +++ b/native/llvm-codegen/src/emit.h @@ -0,0 +1,22 @@ +#pragma once + +#include +#include + +namespace scriptc { + +struct EmitOptions { + std::string Input; + std::string Output; + std::string FileType = "obj"; + std::string Target; + std::string OptLevel = "2"; + std::string RelocationModel = "pic"; + std::string DiagnosticFormat = "json"; + std::string SourcePath; +}; + +std::optional parseEmitOptions(int Argc, char **Argv); +int emit(const EmitOptions &Options); + +} // namespace scriptc diff --git a/native/llvm-codegen/src/main.cpp b/native/llvm-codegen/src/main.cpp new file mode 100644 index 000000000..3408d7869 --- /dev/null +++ b/native/llvm-codegen/src/main.cpp @@ -0,0 +1,74 @@ +#include "diagnostics.h" +#include "emit.h" +#include "target.h" + +#include "llvm/ADT/StringRef.h" +#include "llvm/Support/FormatVariadic.h" +#include "llvm/Support/JSON.h" +#include "llvm/Support/ErrorHandling.h" +#include "llvm/Support/TargetSelect.h" +#include "llvm/Support/raw_ostream.h" +#include "llvm/Target/TargetMachine.h" +#include "llvm/TargetParser/Host.h" + +#include +#include +#include + +using namespace llvm; + +#ifndef SCRIPTC_PACKAGE_VERSION +#define SCRIPTC_PACKAGE_VERSION "0.0.0-dev" +#endif + +namespace { + +int version(int Argc, char **Argv) { + if (Argc != 3 || StringRef(Argv[2]) != "--format=json") + return scriptc::reportError("usage", "version requires --format=json"); + std::string Error; + std::unique_ptr Machine = scriptc::createTargetMachine( + scriptc::DefaultTarget, "2", Error); + if (!Machine) + return scriptc::reportError("target_machine_failed", Error); + + json::Object Response{ + {"ok", true}, + {"protocol_version", scriptc::ProtocolVersion}, + {"scriptc_package_version", SCRIPTC_PACKAGE_VERSION}, + {"llvm_version", LLVM_VERSION_STRING}, + {"host_triple", sys::getDefaultTargetTriple()}, + {"targets", json::Array{"AArch64"}}, + {"default_target", scriptc::DefaultTarget}, + {"data_layout", Machine->createDataLayout().getStringRepresentation()}, + }; + outs() << formatv("{0}\n", json::Value(std::move(Response))); + return 0; +} + +} // namespace + +int main(int Argc, char **Argv) { + scriptc::installFatalDiagnosticHandler(); + // Process-isolated test seam for the fatal handler itself. It is inert in + // every ordinary invocation and ensures a future LLVM fatal never becomes + // an unstructured abort/stack trace in the Node caller. + if (std::getenv("SCRIPTC_LLVM_TEST_FATAL") != nullptr) + report_fatal_error("scriptc LLVM fatal diagnostic self-test"); + if (Argc >= 2 && StringRef(Argv[1]) == "version") + return version(Argc, Argv); + if (Argc >= 2 && StringRef(Argv[1]) == "emit") { + std::optional Options = + scriptc::parseEmitOptions(Argc, Argv); + if (!Options) + return scriptc::reportError( + "usage", + "emit requires --input --output and accepts " + "--filetype --target --opt-level <0|1|2|3|s|z> " + "--relocation-model pic --diagnostic-format json " + "--source-path "); + return scriptc::emit(*Options); + } + return scriptc::reportError("usage", + "expected 'version --format=json' or 'emit'"); +} diff --git a/native/llvm-codegen/src/target.cpp b/native/llvm-codegen/src/target.cpp new file mode 100644 index 000000000..55405b9ec --- /dev/null +++ b/native/llvm-codegen/src/target.cpp @@ -0,0 +1,55 @@ +#include "target.h" + +#include "llvm/MC/TargetRegistry.h" +#include "llvm/Support/CodeGen.h" +#include "llvm/Target/TargetMachine.h" +#include "llvm/Target/TargetOptions.h" +#include "llvm/TargetParser/Triple.h" + +using namespace llvm; + +extern "C" { +void LLVMInitializeAArch64TargetInfo(); +void LLVMInitializeAArch64Target(); +void LLVMInitializeAArch64TargetMC(); +void LLVMInitializeAArch64AsmPrinter(); +} + +namespace scriptc { + +void initializeTargets() { + static bool Initialized = false; + if (Initialized) + return; + LLVMInitializeAArch64TargetInfo(); + LLVMInitializeAArch64Target(); + LLVMInitializeAArch64TargetMC(); + LLVMInitializeAArch64AsmPrinter(); + Initialized = true; +} + +static CodeGenOptLevel codeGenLevel(StringRef Level) { + if (Level == "0") + return CodeGenOptLevel::None; + if (Level == "1") + return CodeGenOptLevel::Less; + if (Level == "3") + return CodeGenOptLevel::Aggressive; + return CodeGenOptLevel::Default; +} + +std::unique_ptr createTargetMachine(StringRef TripleName, + StringRef OptLevel, + std::string &Error) { + initializeTargets(); + Triple TargetTriple(TripleName); + const Target *Definition = TargetRegistry::lookupTarget(TargetTriple, Error); + if (Definition == nullptr) + return nullptr; + TargetOptions Options; + return std::unique_ptr(Definition->createTargetMachine( + TargetTriple, "generic", "", Options, Reloc::PIC_, CodeModel::Small, + codeGenLevel(OptLevel))); +} + +} // namespace scriptc diff --git a/native/llvm-codegen/src/target.h b/native/llvm-codegen/src/target.h new file mode 100644 index 000000000..fc6cd7d54 --- /dev/null +++ b/native/llvm-codegen/src/target.h @@ -0,0 +1,26 @@ +#pragma once + +#include "llvm/ADT/StringRef.h" + +#include +#include + +namespace llvm { +class TargetMachine; +} + +namespace scriptc { + +inline constexpr llvm::StringLiteral ProtocolVersion = "1"; +inline constexpr llvm::StringLiteral LlvmVersion = "22.1.8"; +inline constexpr llvm::StringLiteral DefaultTarget = + "arm64-apple-macosx14.0.0"; +inline constexpr llvm::StringLiteral DefaultDataLayout = + "e-m:o-p270:32:32-p271:32:32-p272:64:64-i64:64-i128:128-n32:64-S128-Fn32"; + +void initializeTargets(); +std::unique_ptr +createTargetMachine(llvm::StringRef Triple, llvm::StringRef OptLevel, + std::string &Error); + +} // namespace scriptc diff --git a/packages/cli/README.md b/packages/cli/README.md index fa15bcd5b..0da61a06d 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -22,7 +22,7 @@ $ scriptc build fib.ts -o fib && ./fib $ npm install -g scriptc ``` -Requires Node.js 24. Executable builds require clang on the PATH (Xcode Command Line Tools on macOS, `clang` package on Linux); `--emit=ir|c|llvm` requires no external compiler, archiver, or linker. +Requires Node.js 24. Executable builds require clang on the PATH (Xcode Command Line Tools on macOS, `clang` package on Linux). `--emit=ir|c|llvm` requires only Node. On macOS 15+ arm64, `--emit=asm|obj` uses the matching optional `@scriptc/llvm-darwin-arm64` helper installed with scriptc and requires no external compiler, archiver, linker, or SDK. Builds use a bounded persistent cache by default. Exact unchanged library builds validate their recorded TypeScript/module-resolution inputs and restore the generated C/LLVM unit before starting the frontend. TypeScript comment-only edits can restore validated lowered IR instead, rebasing source locations and regenerating exact-source build identity before emission; directives, JSDoc-bearing JavaScript, token edits, configuration, package resolution, and newly appearing candidates still invalidate it. Library identity getters live in a tiny C translation unit, so build-id-only changes reuse the large compiled program object and compile only that small member before rearchiving. The native cache then applies its independent toolchain checks. Unchanged executables and library archives skip native code generation and linking after fresh compiler metadata probes, while edited builds reuse stable runtime objects. Experimental provenance-source builds bypass the early frontend tier because their fetched-source registry is process state. FFI builds with archive/object inputs or ambient `system_libraries` relink every time but still reuse runtime objects. Mutable compiler input paths such as `CPATH` and `SDKROOT`, and compiler wrappers, bypass persistent native artifacts and objects so same-path dependency edits cannot go stale. Opaque archiver wrappers rebuild library program members and archives while retaining runtime-object reuse. Direct Clang, Apple's system Clang shim, `zig cc`, trusted platform archivers, and `zig ar` retain their applicable persistent tiers. Set `SCRIPTC_NO_CACHE=1` to bypass every cache or `SCRIPTC_CACHE_DIR` to choose its location; an existing POSIX override must already be private, otherwise caching is bypassed without changing its permissions. @@ -32,7 +32,15 @@ Builds use a bounded persistent cache by default. Exact unchanged library builds - `scriptc run ` — compile and run - `scriptc coverage ` — what compiles statically, and why the rest doesn't -`scriptc build app.ts --emit=ir|c|llvm` selects serialized typed IR, readable C, or textual LLVM IR as the one primary artifact and stops before native compilation. `--emit=exe` is the default. +`scriptc build app.ts --emit=ir|c|llvm|asm|obj` selects serialized typed IR, +readable C, textual LLVM IR, target assembly, or a relocatable program object +as the one primary artifact. `--emit=exe` is the default. Assembly/object +emission currently requires a macOS 15+ arm64 host and emits objects targeting +macOS 14.0 (`arm64-apple-macosx14.0.0`). +Objects retain undefined `scr_*` runtime references plus the +`scr_runtime_abi_v1` compatibility marker; they are not library archives. +`--emit=asm|obj --sanitize` is rejected until ASan pipeline parity is +available. For embedder-hosted modules that are not installed npm packages, coverage can map an exact bare specifier to a local declaration with repeatable diff --git a/packages/cli/src/bootstrap.ts b/packages/cli/src/bootstrap.ts index 7a451beba..833af553a 100644 --- a/packages/cli/src/bootstrap.ts +++ b/packages/cli/src/bootstrap.ts @@ -112,7 +112,7 @@ async function tryFastPath(): Promise { ...(optimization === "dev" ? { optimization: "dev" as const } : {}), npmStatic, ffiProfile: ffiPath === null ? null : { path: ffiPath, bytes: ffiBytes! }, - target: `${process.env["SCRIPTC_TARGET"] ?? "native"}:${buildPlatform}:${arch}`, + target: `${process.env["SCRIPTC_TARGET"] ?? "native"}:${buildPlatform}:${arch}:driver-tu`, compiler: [process.env["SCRIPTC_CC"] ?? "clang"], nativeEnvironment, nodeVersion: process.version, diff --git a/packages/cli/src/output-options.ts b/packages/cli/src/output-options.ts index b7dced94e..09e6593f7 100644 --- a/packages/cli/src/output-options.ts +++ b/packages/cli/src/output-options.ts @@ -24,6 +24,7 @@ export type OutputOptionResolution = | { ok: false; message: string }; const SOURCE_KINDS = new Set(["ir", "c", "llvm"]); +const NATIVE_ARTIFACT_KINDS = new Set(["asm", "obj"]); /** Pure compatibility/validation matrix for build/run output selection. */ export function resolveOutputOptions( @@ -41,16 +42,10 @@ export function resolveOutputOptions( ) { return { ok: false, - message: `unknown emit kind "${rawEmit}" (supported: ir, c, llvm, exe; asm and obj require the native helper)`, + message: `unknown emit kind "${rawEmit}" (supported: ir, c, llvm, asm, obj, exe)`, }; } const emit = (rawEmit ?? "exe") as CliOutputKind; - if (emit === "asm" || emit === "obj") { - return { - ok: false, - message: `--emit=${emit} requires the scriptc LLVM native helper and is not supported in this release`, - }; - } if (command === "run" && emit !== "exe") { return { ok: false, message: `scriptc run requires --emit=exe` }; } @@ -69,8 +64,8 @@ export function resolveOutputOptions( if (emit === "c" && backend === "llvm") { return { ok: false, message: `--emit=c cannot be combined with --backend=llvm` }; } - if (emit === "llvm" && backend === "c") { - return { ok: false, message: `--emit=llvm cannot be combined with --backend=c` }; + if ((emit === "llvm" || NATIVE_ARTIFACT_KINDS.has(emit)) && backend === "c") { + return { ok: false, message: `--emit=${emit} cannot be combined with --backend=c` }; } if (SOURCE_KINDS.has(emit)) { if (!values.keepC) { @@ -83,12 +78,19 @@ export function resolveOutputOptions( return { ok: false, message: `--optimization is only meaningful with --emit=exe` }; } } + if (NATIVE_ARTIFACT_KINDS.has(emit) && !values.keepC) { + return { ok: false, message: `--no-keep-c is only meaningful with --emit=exe` }; + } const outputKind = emit as CompileOutputKind; return { ok: true, outputKind, cliOutputKind: emit, - ...(emit === "c" ? { backend: "c" as const } : emit === "llvm" ? { backend: "llvm" as const } : backend === undefined ? {} : { backend }), + ...(emit === "c" + ? { backend: "c" as const } + : emit === "llvm" || NATIVE_ARTIFACT_KINDS.has(emit) + ? { backend: "llvm" as const } + : backend === undefined ? {} : { backend }), emitIr: values.emitIr && emit === "exe", deprecateEmitIr: values.emitIr, }; diff --git a/packages/cli/src/usage.ts b/packages/cli/src/usage.ts index 68a36facf..e68ff03d7 100644 --- a/packages/cli/src/usage.ts +++ b/packages/cli/src/usage.ts @@ -18,8 +18,8 @@ Usage: Options: -o, --out primary output path (default: .scriptc/) - --emit primary output: ir, c, llvm, or exe (default: exe). - asm and obj are reserved for the native-helper release + --emit primary output: ir, c, llvm, asm, obj, or exe + (default: exe). asm/obj currently support macOS 15+ arm64 --backend code generator. llvm is the default and the output that ships; c emits readable C for inspecting what the compiler produced, and program behavior is identical diff --git a/packages/cli/test/native-output.test.ts b/packages/cli/test/native-output.test.ts new file mode 100644 index 000000000..e4c3c1fce --- /dev/null +++ b/packages/cli/test/native-output.test.ts @@ -0,0 +1,110 @@ +import { execFile } from "node:child_process"; +import { createHash } from "node:crypto"; +import { createRequire } from "node:module"; +import { chmod, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { release as osRelease, tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { promisify } from "node:util"; +import { afterEach, describe, expect, test } from "vitest"; + +const execFileAsync = promisify(execFile); +const require = createRequire(import.meta.url); +const repoRoot = join(import.meta.dirname, "../../.."); +const cliEntry = join(repoRoot, "packages/cli/src/main.ts"); +const tsxLoader = join(dirname(require.resolve("tsx/package.json")), "dist/loader.mjs"); +const supported = process.platform === "darwin" && process.arch === "arm64" && + Number.parseInt(osRelease().split(".", 1)[0] ?? "", 10) >= 24; +const dirs: string[] = []; + +afterEach(async () => { + await Promise.all(dirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); +}); + +async function fixture() { + const dir = await mkdtemp(join(tmpdir(), "scriptc-cli-native-output-")); + dirs.push(dir); + const entry = join(dir, "hello.ts"); + await writeFile(entry, 'console.log("native output");\n'); + return { dir, entry }; +} + +function cli(args: string[], env: NodeJS.ProcessEnv = process.env) { + return execFileAsync(process.execPath, ["--import", tsxLoader, cliEntry, ...args], { + env, + maxBuffer: 4 * 1024 * 1024, + }); +} + +test("unsupported hosts fail with SC3002 before creating an artifact", async () => { + if (supported) return; + const { dir, entry } = await fixture(); + const output = join(dir, "hello.o"); + await expect(cli(["build", entry, "--emit=obj", "-o", output])).rejects.toMatchObject({ + code: 1, + stderr: expect.stringContaining("SC3002"), + }); + await expect(readFile(output)).rejects.toMatchObject({ code: "ENOENT" }); +}); + +describe.runIf(supported)("macOS arm64 native outputs", () => { + test("default names and exact paths identify assembly and object artifacts", async () => { + const { dir, entry } = await fixture(); + for (const [kind, name] of [["asm", "hello.s"], ["obj", "hello.o"]] as const) { + const result = await cli(["build", entry, `--emit=${kind}`]); + expect(result.stdout).toBe(`${join(dir, ".scriptc", name)}\n`); + } + const exact = join(dir, "exact.custom"); + await cli(["build", entry, "--emit=obj", "-o", exact]); + expect((await readFile(exact)).subarray(0, 4)).toEqual(Buffer.from([0xcf, 0xfa, 0xed, 0xfe])); + }); + + test("asm and obj do not execute compiler, archiver, or linker traps", async () => { + const { dir, entry } = await fixture(); + const traps = join(dir, "traps"); + await execFileAsync("mkdir", [traps]); + const trapLog = join(dir, "trap.log"); + for (const tool of ["clang", "cc", "gcc", "zig", "ar", "ld", "xcrun"]) { + const path = join(traps, tool); + await writeFile(path, `#!/bin/sh\nprintf '${tool}\\n' >> '${trapLog}'\nexit 97\n`); + await chmod(path, 0o755); + } + const env = { + ...process.env, + PATH: `${traps}:${process.env["PATH"] ?? ""}`, + SCRIPTC_CACHE_DIR: join(dir, "cache"), + }; + for (const kind of ["asm", "obj"] as const) { + await expect(cli(["build", entry, `--emit=${kind}`, "-o", join(dir, `hello.${kind}`)], env)) + .resolves.toMatchObject({ stderr: "" }); + } + await expect(readFile(trapLog)).rejects.toMatchObject({ code: "ENOENT" }); + }); + + test("identical object inputs have deterministic hashes", async () => { + const { dir, entry } = await fixture(); + const first = join(dir, "first.o"); + const second = join(dir, "second.o"); + await cli(["build", entry, "--emit=obj", "-o", first], { + ...process.env, + SCRIPTC_NO_CACHE: "1", + }); + await cli(["build", entry, "--emit=obj", "-o", second], { + ...process.env, + SCRIPTC_NO_CACHE: "1", + }); + const digest = (path: string) => readFile(path).then((bytes) => + createHash("sha256").update(bytes).digest("hex")); + expect(await digest(first)).toBe(await digest(second)); + }); + + test("sanitize refuses by name without publishing an object", async () => { + const { dir, entry } = await fixture(); + const output = join(dir, "san.o"); + await expect(cli(["build", entry, "--emit=obj", "--sanitize", "-o", output])) + .rejects.toMatchObject({ + code: 1, + stderr: expect.stringMatching(/SC3002[\s\S]*AddressSanitizer/), + }); + await expect(readFile(output)).rejects.toMatchObject({ code: "ENOENT" }); + }); +}); diff --git a/packages/cli/test/output-options.test.ts b/packages/cli/test/output-options.test.ts index c4d5a6cc2..ae1b7349a 100644 --- a/packages/cli/test/output-options.test.ts +++ b/packages/cli/test/output-options.test.ts @@ -17,6 +17,8 @@ describe("output option compatibility", () => { ["c", "c", "c", "c"], ["llvm", undefined, "llvm", "llvm"], ["llvm", "llvm", "llvm", "llvm"], + ["asm", undefined, "asm", "llvm"], + ["obj", "llvm", "obj", "llvm"], ] as const)("accepts --emit=%s --backend=%s", (emit, backend, outputKind, expectedBackend) => { const result = resolveOutputOptions("build", { ...BASE, @@ -32,8 +34,8 @@ describe("output option compatibility", () => { test.each([ [{ emit: "wat" }, /unknown emit kind/], - [{ emit: "asm" }, /native helper/], - [{ emit: "obj" }, /native helper/], + [{ emit: "asm", backend: "c" }, /cannot be combined/], + [{ emit: "obj", backend: "c" }, /cannot be combined/], [{ emit: "c", backend: "llvm" }, /cannot be combined/], [{ emit: "llvm", backend: "c" }, /cannot be combined/], [{ emit: "ir", keepC: false }, /no-keep-c/], @@ -56,6 +58,22 @@ describe("output option compatibility", () => { }); }); + test.each(["asm", "obj"])("run rejects --emit=%s", (emit) => { + expect(resolveOutputOptions("run", { ...BASE, emit })).toEqual({ + ok: false, + message: "scriptc run requires --emit=exe", + }); + }); + + test.each(["asm", "obj"])("native outputs accept optimization and sanitizer for compiler-level validation", (emit) => { + expect(resolveOutputOptions("build", { + ...BASE, + emit, + optimization: "dev", + sanitize: true, + })).toMatchObject({ ok: true, outputKind: emit, backend: "llvm" }); + }); + test("the deprecated alias remains additive for executable builds", () => { expect(resolveOutputOptions("build", { ...BASE, emitIr: true })).toMatchObject({ ok: true, diff --git a/packages/cli/test/source-output.test.ts b/packages/cli/test/source-output.test.ts index 0be5596eb..9179a9ae1 100644 --- a/packages/cli/test/source-output.test.ts +++ b/packages/cli/test/source-output.test.ts @@ -70,7 +70,7 @@ test("module-flavored TypeScript extensions retain the plain entry stem", async test("an explicit default-shaped output path preserves caller-owned siblings", async () => { const { dir, entry } = await fixture(); - const siblings = ["hello", "hello.exe", "hello.wasm", "hello.c", "hello.ll"]; + const siblings = ["hello", "hello.exe", "hello.wasm", "hello.c", "hello.ll", "hello.s", "hello.o"]; await Promise.all(siblings.map((name) => writeFile(join(dir, name), `caller-owned ${name}\n`))); const path = join(dir, "hello.ir.json"); await cli(["build", entry, "--emit=ir", "-o", path]); diff --git a/packages/compiler/package.json b/packages/compiler/package.json index 39491761f..789fda57b 100644 --- a/packages/compiler/package.json +++ b/packages/compiler/package.json @@ -35,5 +35,8 @@ "@scriptc/runtime": "workspace:*", "typescript": "7.0.2", "typescript5": "npm:typescript@5.9.3" + }, + "optionalDependencies": { + "@scriptc/llvm-darwin-arm64": "workspace:*" } } diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 0ca908fee..4b802cb07 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -173,6 +173,9 @@ export interface LlvmTargetOptions { /** Library archive assembly may move the volatile identity getters into a * separate translation unit. Public/direct emission keeps them by default. */ emitLibraryIdentity?: boolean; + /** Program objects carry a strong reference to the matching runtime ABI + * marker so manual links against an incompatible runtime fail loudly. */ + runtimeAbiMarker?: boolean; } export function emitLlvmModule(mod: IrModule, options: LlvmTargetOptions = {}): string { @@ -201,6 +204,7 @@ class LlEmitter { readonly cycleColorOffset: number; private readonly wasi: boolean; private readonly emitLibraryIdentity: boolean; + private readonly runtimeAbiMarker: boolean; /** Interned string literals: UTF-8 text → { symbol, byte length } — * first-use order, the C emitter's determinism discipline. */ private readonly literals = new Map(); @@ -369,6 +373,7 @@ class LlEmitter { this.sizeType = options.pointerBits === 32 ? "i32" : "i64"; this.wasi = options.wasi === true; this.emitLibraryIdentity = options.emitLibraryIdentity !== false; + this.runtimeAbiMarker = options.runtimeAbiMarker === true; // ScrCycHdr is { ptr trace; ptr free; i32 color; i16 buffered; // i16 gen; size_t buf_index }. The object follows it, so color is 12 // bytes behind a wasm32 object and 16 bytes behind a 64-bit object. @@ -1095,6 +1100,9 @@ class LlEmitter { `@scr_error_vts = external ${tl}global [5 x %ScrVt]`, `declare void @scr_init()`, `declare void @scr_lib_init(i32, ptr)`, + ...(this.runtimeAbiMarker && this.mod.lib === undefined + ? [`declare void @scr_runtime_abi_v1()`] + : []), ); for (const d of this.decls) out.push(d); out.push(``); @@ -1272,6 +1280,7 @@ class LlEmitter { out.push( `define i32 @${this.wasi ? "__main_argc_argv" : "main"}(i32 %argc, ptr %argv) ${FN_ATTRS} {`, `entry:`, + ...(this.runtimeAbiMarker ? [` call void @scr_runtime_abi_v1()`] : []), ` call void @scr_init()`, ...stamps, // Event-surface programs (signal/exit listeners) fill the loop's diff --git a/packages/compiler/src/backend/native-codegen.test.ts b/packages/compiler/src/backend/native-codegen.test.ts new file mode 100644 index 000000000..b16d758a3 --- /dev/null +++ b/packages/compiler/src/backend/native-codegen.test.ts @@ -0,0 +1,200 @@ +import { chmod, mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, expect, test } from "vitest"; +import { emitNativeArtifact, NativeCodegenError } from "./native-codegen.js"; +import { MACOS_ARM64_TARGET } from "./targets.js"; +import { compilerReleaseVersion } from "../library/sidecar.js"; + +const dirs: string[] = []; +afterEach(async () => { + await Promise.all(dirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); +}); + +async function fakePackage(options: { + protocol?: string; + packageVersion?: string; + emitFailure?: boolean; + emptyOutput?: boolean; + missingOutput?: boolean; +} = {}) { + const root = await mkdtemp(join(tmpdir(), "scriptc-native-helper-test-")); + dirs.push(root); + const packageJson = join(root, "package.json"); + const bin = join(root, "bin", "scriptc-llvm-codegen"); + const log = join(root, "calls.log"); + await mkdir(join(root, "bin")); + await writeFile(packageJson, JSON.stringify({ name: MACOS_ARM64_TARGET.helperPackage })); + const version = JSON.stringify({ + ok: true, + protocol_version: options.protocol ?? "1", + scriptc_package_version: options.packageVersion ?? compilerReleaseVersion(), + llvm_version: "22.1.8", + host_triple: "arm64-apple-darwin24.0.0", + targets: ["AArch64"], + default_target: MACOS_ARM64_TARGET.llvmTriple, + data_layout: MACOS_ARM64_TARGET.dataLayout, + }); + await writeFile(bin, `#!/bin/sh +if [ "$1" = version ]; then + printf '%s\\n' '${version}' + exit 0 +fi +printf '%s\\n' "$*" >> '${log}' +output='' +input='' +while [ "$#" -gt 0 ]; do + if [ "$1" = --output ]; then output="$2"; shift 2; continue; fi + if [ "$1" = --input ]; then input="$2"; shift 2; continue; fi + shift +done +${options.emitFailure === true + ? "printf '%s\\n' '{\"ok\":false,\"code\":\"verification_failed\",\"message\":\"bad module\"}' >&2; exit 1" + : options.emptyOutput === true + ? ': > "$output"' + : options.missingOutput === true + ? ":" + : 'cp "$input" "$output"'} +`); + await chmod(bin, 0o755); + return { packageJson, bin, log, root }; +} + +function request(root: string, packageJson: string, output = join(root, "program.o")) { + return { + outputPath: output, + llvm: "define i32 @answer() { ret i32 42 }\n", + outputKind: "obj" as const, + sourcePath: "/source/app.ts", + target: MACOS_ARM64_TARGET, + resolvePackageJson: () => packageJson, + cacheRoot: join(root, "cache"), + }; +} + +test("resolves a package helper, emits atomically, and caches by all native inputs", async () => { + const pkg = await fakePackage(); + const first = join(pkg.root, "first.o"); + const second = join(pkg.root, "second.o"); + await emitNativeArtifact(request(pkg.root, pkg.packageJson, first)); + await emitNativeArtifact(request(pkg.root, pkg.packageJson, second)); + expect(await readFile(first, "utf8")).toContain("define i32 @answer"); + expect(await readFile(second)).toEqual(await readFile(first)); + const expectedMode = 0o666 & ~process.umask(); + expect((await stat(first)).mode & 0o777).toBe(expectedMode); + expect((await stat(second)).mode & 0o777).toBe(expectedMode); + expect((await readFile(pkg.log, "utf8")).trim().split("\n")).toHaveLength(1); +}); + +test("cache publication failures do not discard a valid requested artifact", async () => { + const pkg = await fakePackage(); + const cacheRoot = join(pkg.root, "cache"); + const output = join(pkg.root, "uncached-success.o"); + await mkdir(cacheRoot, { mode: 0o700 }); + // Block creation of the cache family below an otherwise valid cache root. + await writeFile(join(cacheRoot, "native-codegen-v1"), "not a directory\n"); + + await emitNativeArtifact({ + ...request(pkg.root, pkg.packageJson, output), + cacheRoot, + }); + + expect(await readFile(output, "utf8")).toContain("define i32 @answer"); + expect((await readFile(pkg.log, "utf8")).trim().split("\n")).toHaveLength(1); +}); + +test("reports a missing platform package as an actionable installation diagnostic", async () => { + const root = await mkdtemp(join(tmpdir(), "scriptc-native-missing-test-")); + dirs.push(root); + await expect(emitNativeArtifact({ + ...request(root, join(root, "missing.json")), + resolvePackageJson: () => { throw new Error("missing"); }, + })).rejects.toMatchObject({ + diagnosticCode: "SC3003", + detailCode: "missing_package", + message: expect.stringContaining("optional dependencies"), + }); +}); + +test.skipIf(process.platform === "win32")( + "reports unreadable or non-executable helper binaries as installation failures", + async () => { + for (const mode of [0o111, 0o644]) { + const pkg = await fakePackage(); + await chmod(pkg.bin, mode); + await expect(emitNativeArtifact(request(pkg.root, pkg.packageJson))).rejects.toMatchObject({ + diagnosticCode: "SC3003", + detailCode: "unusable_binary", + message: expect.stringContaining("reinstall scriptc"), + }); + } + }, +); + +test.skipIf(process.platform === "win32")( + "reports helper identity execution failures as installation failures", + async () => { + const pkg = await fakePackage(); + await writeFile(pkg.bin, "#!/definitely/missing/scriptc-interpreter\n"); + await chmod(pkg.bin, 0o755); + await expect(emitNativeArtifact(request(pkg.root, pkg.packageJson))).rejects.toMatchObject({ + diagnosticCode: "SC3003", + detailCode: "identity_probe_failed", + message: expect.stringContaining("identity check"), + }); + }, +); + +test("rejects protocol and package-version mismatches before emission", async () => { + for (const options of [{ protocol: "99" }, { packageVersion: "9.9.9" }]) { + const pkg = await fakePackage(options); + await expect(emitNativeArtifact(request(pkg.root, pkg.packageJson))).rejects.toMatchObject({ + diagnosticCode: "SC3003", + detailCode: "version_mismatch", + }); + } +}); + +test("translates structured helper failures and preserves an existing output", async () => { + const pkg = await fakePackage({ emitFailure: true }); + const output = join(pkg.root, "existing.o"); + await writeFile(output, "caller artifact\n"); + await expect(emitNativeArtifact(request(pkg.root, pkg.packageJson, output))).rejects.toMatchObject({ + diagnosticCode: "SC3004", + detailCode: "verification_failed", + message: expect.stringContaining("bad module"), + }); + expect(await readFile(output, "utf8")).toBe("caller artifact\n"); +}); + +test("rejects a successful helper that leaves an empty staged output", async () => { + const pkg = await fakePackage({ emptyOutput: true }); + await expect(emitNativeArtifact(request(pkg.root, pkg.packageJson))).rejects.toMatchObject({ + diagnosticCode: "SC3004", + detailCode: "empty_output", + }); +}); + +test("rejects a successful helper that creates no staged output", async () => { + const pkg = await fakePackage({ missingOutput: true }); + await expect(emitNativeArtifact(request(pkg.root, pkg.packageJson))).rejects.toMatchObject({ + diagnosticCode: "SC3004", + detailCode: "empty_output", + message: expect.stringContaining("non-empty regular artifact"), + }); +}); + +test("sanitized native artifacts fail before helper resolution", async () => { + const root = await mkdtemp(join(tmpdir(), "scriptc-native-sanitize-test-")); + dirs.push(root); + await expect(emitNativeArtifact({ + ...request(root, join(root, "unused.json")), + sanitize: true, + resolvePackageJson: () => { throw new Error("must not resolve"); }, + })).rejects.toBeInstanceOf(NativeCodegenError); + await expect(emitNativeArtifact({ + ...request(root, join(root, "unused.json")), + sanitize: true, + resolvePackageJson: () => { throw new Error("must not resolve"); }, + })).rejects.toMatchObject({ diagnosticCode: "SC3002", detailCode: "sanitize_unsupported" }); +}); diff --git a/packages/compiler/src/backend/native-codegen.ts b/packages/compiler/src/backend/native-codegen.ts new file mode 100644 index 000000000..0f72dbe1e --- /dev/null +++ b/packages/compiler/src/backend/native-codegen.ts @@ -0,0 +1,329 @@ +import { execFile } from "node:child_process"; +import { createHash } from "node:crypto"; +import { constants } from "node:fs"; +import { createRequire } from "node:module"; +import { access, chmod, mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { promisify } from "node:util"; +import { buildCacheRoot, prepareBuildCacheRoot, pruneBuildCache } from "./native-toolchain.js"; +import { + copyValidCachedFile, + privateSiblingPath, + publishCachedFile, + validCachedFile, +} from "./build-cache.js"; +import { nativeCodegenTarget, nativeCodegenTargetRefusal, type NativeTargetSpec } from "./targets.js"; +import { compilerReleaseVersion } from "../library/sidecar.js"; + +const execFileAsync = promisify(execFile); +export const NATIVE_CODEGEN_PROTOCOL_VERSION = "1"; +export const NATIVE_CODEGEN_LLVM_VERSION = "22.1.8"; + +export type NativeCodegenOutputKind = "asm" | "obj"; + +export class NativeCodegenError extends Error { + constructor( + readonly diagnosticCode: "SC3002" | "SC3003" | "SC3004", + message: string, + readonly detailCode?: string, + ) { + super(message); + this.name = "NativeCodegenError"; + } +} + +export interface NativeCodegenVersion { + ok: true; + protocol_version: string; + scriptc_package_version: string; + llvm_version: string; + host_triple: string; + targets: string[]; + default_target: string; + data_layout: string; +} + +interface HelperIdentity { + packageName: string; + binaryDigest: string; + version: NativeCodegenVersion; +} + +interface ResolvedHelper { + binaryPath: string; + identity: HelperIdentity; +} + +const resolvedHelperCache = new Map>(); + +export interface NativeCodegenOptions { + outputPath: string; + llvm: string; + outputKind: NativeCodegenOutputKind; + sourcePath: string; + optimization?: "0" | "1" | "2" | "3" | "s" | "z"; + sanitize?: boolean; + target?: NativeTargetSpec; + /** Test seam: still resolves a package path, never searches PATH. */ + resolvePackageJson?: (specifier: string) => string; + /** Internal/test override; omitted production calls use the shared cache. */ + cacheRoot?: string | null; +} + +function parseJsonObject(text: string): Record | null { + try { + const parsed: unknown = JSON.parse(text); + return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) + ? parsed as Record + : null; + } catch { + return null; + } +} + +function helperFailureMessage(stderr: string, fallback: string): { code?: string; message: string } { + const parsed = parseJsonObject(stderr.trim()); + return { + ...(typeof parsed?.["code"] === "string" ? { code: parsed["code"] } : {}), + message: typeof parsed?.["message"] === "string" ? parsed["message"] : fallback, + }; +} + +async function invoke(binaryPath: string, args: string[]): Promise<{ stdout: string; stderr: string }> { + try { + return await execFileAsync(binaryPath, args, { + encoding: "utf8", + maxBuffer: 16 * 1024 * 1024, + windowsHide: true, + }); + } catch (error) { + const failure = error as NodeJS.ErrnoException & { stderr?: string; stdout?: string }; + const stderr = typeof failure.stderr === "string" ? failure.stderr : ""; + const parsed = helperFailureMessage(stderr, failure.message); + throw new NativeCodegenError( + "SC3004", + `LLVM native helper failed${parsed.code === undefined ? "" : ` (${parsed.code})`}: ${parsed.message}`, + parsed.code, + ); + } +} + +function validateVersion(value: Record, target: NativeTargetSpec): NativeCodegenVersion { + const expectedPackageVersion = compilerReleaseVersion(); + const mismatch = (field: string, expected: string): never => { + throw new NativeCodegenError( + "SC3003", + `LLVM native helper is incompatible: ${field} is ${JSON.stringify(value[field])}, expected ${JSON.stringify(expected)}; reinstall scriptc so its compiler and ${target.helperPackage} packages have matching versions`, + "version_mismatch", + ); + }; + if (value["ok"] !== true) mismatch("ok", "true"); + if (value["protocol_version"] !== NATIVE_CODEGEN_PROTOCOL_VERSION) { + mismatch("protocol_version", NATIVE_CODEGEN_PROTOCOL_VERSION); + } + if (value["scriptc_package_version"] !== expectedPackageVersion) { + mismatch("scriptc_package_version", expectedPackageVersion); + } + if (value["llvm_version"] !== NATIVE_CODEGEN_LLVM_VERSION) { + mismatch("llvm_version", NATIVE_CODEGEN_LLVM_VERSION); + } + if (value["default_target"] !== target.llvmTriple) { + mismatch("default_target", target.llvmTriple); + } + if (value["data_layout"] !== target.dataLayout) { + mismatch("data_layout", target.dataLayout); + } + if (!Array.isArray(value["targets"]) || !value["targets"].includes("AArch64")) { + mismatch("targets", "an array containing AArch64"); + } + if (typeof value["host_triple"] !== "string") mismatch("host_triple", "a string"); + return value as unknown as NativeCodegenVersion; +} + +async function resolveHelper( + target: NativeTargetSpec, + resolver?: (specifier: string) => string, +): Promise { + const resolvePackageJson = resolver ?? ((specifier: string) => + createRequire(import.meta.url).resolve(specifier)); + let packageJsonPath: string; + try { + packageJsonPath = resolvePackageJson(`${target.helperPackage}/package.json`); + } catch { + throw new NativeCodegenError( + "SC3003", + `LLVM native helper package ${target.helperPackage} is not installed; reinstall scriptc with optional dependencies enabled for macOS arm64`, + "missing_package", + ); + } + const binaryPath = join(dirname(packageJsonPath), "bin", "scriptc-llvm-codegen"); + let binaryStat; + try { + binaryStat = await stat(binaryPath); + if (!binaryStat.isFile()) throw new Error("not a file"); + } catch { + throw new NativeCodegenError( + "SC3003", + `LLVM native helper package ${target.helperPackage} is incomplete: ${binaryPath} is missing; reinstall scriptc`, + "missing_binary", + ); + } + try { + if ( + process.platform !== "win32" && + ((binaryStat.mode & 0o444) === 0 || (binaryStat.mode & 0o111) === 0) + ) throw new Error("missing read or execute mode bits"); + await access(binaryPath, constants.R_OK | constants.X_OK); + } catch { + throw new NativeCodegenError( + "SC3003", + `LLVM native helper package ${target.helperPackage} is incomplete: ${binaryPath} is not readable and executable; reinstall scriptc`, + "unusable_binary", + ); + } + const cacheKey = `${binaryPath}\0${binaryStat.size}\0${binaryStat.mtimeMs}`; + const load = async (): Promise => { + let stdout: string; + let binary: Buffer; + try { + [{ stdout }, binary] = await Promise.all([ + invoke(binaryPath, ["version", "--format=json"]), + readFile(binaryPath), + ]); + } catch (error) { + throw new NativeCodegenError( + "SC3003", + `LLVM native helper package ${target.helperPackage} could not be read and executed for its identity check: ${error instanceof Error ? error.message : String(error)}; reinstall scriptc`, + "identity_probe_failed", + ); + } + const rawVersion = parseJsonObject(stdout.trim()); + if (rawVersion === null) { + throw new NativeCodegenError( + "SC3003", + `LLVM native helper returned an invalid version response; reinstall scriptc and ${target.helperPackage}`, + "invalid_version_response", + ); + } + return { + binaryPath, + identity: { + packageName: target.helperPackage, + binaryDigest: createHash("sha256").update(binary).digest("hex"), + version: validateVersion(rawVersion, target), + }, + }; + }; + if (resolver !== undefined) return load(); + const cached = resolvedHelperCache.get(cacheKey); + if (cached !== undefined) return cached; + const pending = load(); + resolvedHelperCache.set(cacheKey, pending); + void pending.catch(() => { + if (resolvedHelperCache.get(cacheKey) === pending) resolvedHelperCache.delete(cacheKey); + }); + return pending; +} + +function cacheKey(options: NativeCodegenOptions, target: NativeTargetSpec, helper: HelperIdentity): string { + return createHash("sha256") + .update("scriptc-native-codegen-v1\0") + .update(options.llvm) + .update("\0") + .update(JSON.stringify(target)) + .update("\0") + .update(JSON.stringify(helper)) + .update("\0") + .update(options.optimization ?? "2") + .update("\0") + .update(options.sanitize === true ? "sanitize" : "plain") + .update("\0") + .update(options.outputKind) + .update("\0") + .update(options.sourcePath) + .digest("hex"); +} + +function artifactMode(): number { + return 0o666 & ~process.umask(); +} + +async function installVerifiedCache(source: string, destination: string): Promise { + const temporary = privateSiblingPath(destination, "native-cache-hit"); + try { + if (!(await copyValidCachedFile(source, temporary))) return false; + // Cache entries are private (0600), but caller artifacts follow the + // process umask exactly like a freshly emitted object/assembly file. + await chmod(temporary, artifactMode()); + await rename(temporary, destination); + return true; + } finally { + await rm(temporary, { force: true }).catch(() => undefined); + } +} + +export async function emitNativeArtifact(options: NativeCodegenOptions): Promise { + const target = options.target ?? nativeCodegenTarget(); + if (target === null) { + throw new NativeCodegenError( + "SC3002", + nativeCodegenTargetRefusal() ?? "native assembly/object emission is unsupported for this target", + "unsupported_target", + ); + } + if (options.sanitize === true) { + throw new NativeCodegenError( + "SC3002", + `--sanitize is not supported with --emit=${options.outputKind}; AddressSanitizer instrumentation parity is not available in the LLVM native helper yet`, + "sanitize_unsupported", + ); + } + const helper = await resolveHelper(target, options.resolvePackageJson); + const root = await prepareBuildCacheRoot( + options.cacheRoot === undefined ? buildCacheRoot() : options.cacheRoot, + ); + const key = cacheKey(options, target, helper.identity); + const cached = root === null + ? null + : join(root, "native-codegen-v1", key.slice(0, 2), `${key}.${options.outputKind === "obj" ? "o" : "s"}`); + await mkdir(dirname(options.outputPath), { recursive: true }); + if (cached !== null && await validCachedFile(cached) && + await installVerifiedCache(cached, options.outputPath)) return; + + const stage = privateSiblingPath(options.outputPath, `native-${options.outputKind}`); + const input = privateSiblingPath(options.outputPath, "native-input"); + try { + await writeFile(input, options.llvm, { mode: 0o600 }); + await invoke(helper.binaryPath, [ + "emit", + "--input", input, + "--output", stage, + "--filetype", options.outputKind, + "--target", target.llvmTriple, + "--opt-level", options.optimization ?? "2", + "--relocation-model", target.relocationModel, + "--diagnostic-format", "json", + "--source-path", options.sourcePath, + ]); + const emitted = await stat(stage).catch(() => null); + if (emitted === null || !emitted.isFile() || emitted.size === 0) { + throw new NativeCodegenError( + "SC3004", + "LLVM native helper completed without producing a non-empty regular artifact", + "empty_output", + ); + } + await chmod(stage, artifactMode()); + // Cache publication is an optimization boundary. The helper has already + // produced a valid caller artifact, so a read-only/full cache must not + // discard it or turn an otherwise successful build into an exception. + if (cached !== null) await publishCachedFile(stage, cached).catch(() => undefined); + await rename(stage, options.outputPath); + await pruneBuildCache(root); + } finally { + await Promise.all([ + rm(stage, { force: true }).catch(() => undefined), + rm(input, { force: true }).catch(() => undefined), + ]); + } +} diff --git a/packages/compiler/src/backend/native-toolchain.ts b/packages/compiler/src/backend/native-toolchain.ts index d5b0d133c..235cb6b43 100644 --- a/packages/compiler/src/backend/native-toolchain.ts +++ b/packages/compiler/src/backend/native-toolchain.ts @@ -4954,7 +4954,7 @@ async function compileCInternal( // between hashing and clang and publish one invocation's code under the // other's key. The prefix map preserves the original __FILE__/debug-file // spelling while clang reads this invocation-private snapshot. - const programPath = join(buildDir, `program${opts.cPath.endsWith(".ll") ? ".ll" : ".c"}`); + const programPath = join(buildDir, `program${programSourceExtension}`); // Preserve the caller-visible basename while keeping Darwin builds on a // private inode: ld uses this spelling as the embedded ad-hoc signing // identifier. Other targets retain the basename-independent cache key. diff --git a/packages/compiler/src/backend/targets.test.ts b/packages/compiler/src/backend/targets.test.ts new file mode 100644 index 000000000..351dea009 --- /dev/null +++ b/packages/compiler/src/backend/targets.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, test } from "vitest"; +import { MACOS_ARM64_TARGET, nativeCodegenTarget, nativeCodegenTargetRefusal } from "./targets.js"; + +describe("native code-generation targets", () => { + test("admits only host-native macOS arm64", () => { + expect(nativeCodegenTarget({}, "darwin", "arm64", "24.0.0")).toEqual(MACOS_ARM64_TARGET); + expect(nativeCodegenTarget({}, "darwin", "arm64", "23.6.0")).toBeNull(); + expect(nativeCodegenTarget({}, "darwin", "x64", "24.0.0")).toBeNull(); + expect(nativeCodegenTarget({}, "linux", "arm64", "6.8.0")).toBeNull(); + expect(nativeCodegenTarget( + { SCRIPTC_TARGET: "aarch64-apple-ios" }, "darwin", "arm64", "24.0.0", + )) + .toBeNull(); + }); + + test("refusals name the unsupported host or cross target", () => { + expect(nativeCodegenTargetRefusal({}, "linux", "x64", "6.8.0")).toContain("linux x64"); + expect(nativeCodegenTargetRefusal({}, "darwin", "arm64", "23.6.0")) + .toContain("requires macOS 15.0 or newer"); + expect(nativeCodegenTargetRefusal( + { SCRIPTC_TARGET: "x86_64-linux-gnu.2.36" }, + "darwin", + "arm64", + "24.0.0", + )).toContain("SCRIPTC_TARGET=x86_64-linux-gnu.2.36"); + }); +}); diff --git a/packages/compiler/src/backend/targets.ts b/packages/compiler/src/backend/targets.ts new file mode 100644 index 000000000..fbcbfbac7 --- /dev/null +++ b/packages/compiler/src/backend/targets.ts @@ -0,0 +1,75 @@ +import { release } from "node:os"; + +export interface NativeTargetSpec { + /** Stable scriptc-facing identity used in cache keys and diagnostics. */ + name: "macos-arm64"; + llvmTriple: "arm64-apple-macosx14.0.0"; + dataLayout: "e-m:o-p270:32:32-p271:32:32-p272:64:64-i64:64-i128:128-n32:64-S128-Fn32"; + cpu: "generic"; + features: ""; + pointerBits: 64; + endianness: "little"; + objectFormat: "macho"; + relocationModel: "pic"; + codeModel: "small"; + minimumOs: "14.0"; + helperMinimumOs: "15.0"; + outputSuffixes: { asm: ".s"; obj: ".o"; exe: "" }; + supports: { asm: true; obj: true; exe: true; library: false }; + helperPackage: "@scriptc/llvm-darwin-arm64"; +} + +export const MACOS_ARM64_TARGET: NativeTargetSpec = { + name: "macos-arm64", + llvmTriple: "arm64-apple-macosx14.0.0", + dataLayout: "e-m:o-p270:32:32-p271:32:32-p272:64:64-i64:64-i128:128-n32:64-S128-Fn32", + cpu: "generic", + features: "", + pointerBits: 64, + endianness: "little", + objectFormat: "macho", + relocationModel: "pic", + codeModel: "small", + minimumOs: "14.0", + helperMinimumOs: "15.0", + outputSuffixes: { asm: ".s", obj: ".o", exe: "" }, + supports: { asm: true, obj: true, exe: true, library: false }, + helperPackage: "@scriptc/llvm-darwin-arm64", +}; + +export function nativeCodegenTarget( + env: NodeJS.ProcessEnv = process.env, + hostPlatform: NodeJS.Platform = process.platform, + hostArch: string = process.arch, + hostRelease: string = release(), +): NativeTargetSpec | null { + // The first helper is host-native only. In particular, do not reinterpret + // an existing SCRIPTC_TARGET cross-build as macOS merely because its OS + // family is Darwin. + if ((env["SCRIPTC_TARGET"] ?? "") !== "") return null; + const darwinMajor = Number.parseInt(hostRelease.split(".", 1)[0] ?? "", 10); + return hostPlatform === "darwin" && hostArch === "arm64" && darwinMajor >= 24 + ? MACOS_ARM64_TARGET + : null; +} + +export function nativeCodegenTargetRefusal( + env: NodeJS.ProcessEnv = process.env, + hostPlatform: NodeJS.Platform = process.platform, + hostArch: string = process.arch, + hostRelease: string = release(), +): string | null { + if (nativeCodegenTarget(env, hostPlatform, hostArch, hostRelease) !== null) return null; + const crossTarget = env["SCRIPTC_TARGET"] ?? ""; + if (crossTarget !== "") { + return `native assembly/object emission does not support SCRIPTC_TARGET=${crossTarget}; the first supported target is host-native macOS arm64`; + } + if (hostPlatform === "darwin" && hostArch === "arm64") { + const darwinMajor = Number.parseInt(hostRelease.split(".", 1)[0] ?? "", 10); + const macosMajor = Number.isFinite(darwinMajor) && darwinMajor >= 20 + ? String(darwinMajor - 9) + : "unknown"; + return `native assembly/object emission requires macOS ${MACOS_ARM64_TARGET.helperMinimumOs} or newer; this host is macOS ${macosMajor} (Darwin ${hostRelease})`; + } + return `native assembly/object emission is supported on macOS arm64 hosts; this host is ${hostPlatform} ${hostArch}`; +} diff --git a/packages/compiler/src/diagnostics/diagnostic.ts b/packages/compiler/src/diagnostics/diagnostic.ts index 4ee29cfcb..a06ce3ead 100644 --- a/packages/compiler/src/diagnostics/diagnostic.ts +++ b/packages/compiler/src/diagnostics/diagnostic.ts @@ -12,8 +12,9 @@ * SC2xxx scriptc type rules (types we cannot compile yet) * SC3xxx backend/target coverage: the program is valid, but the selected * alternate backend or execution target does not include it - * (SC3001 — LLVM IR tier refusal; SC3002 — target refusal, both - * minted in index.ts) + * (SC3001 — LLVM IR tier refusal; SC3002 — target/capability + * refusal; SC3003 — helper installation/version failure; SC3004 + * — helper code-generation failure) * SC4xxx library-mode/profile refusals (the library-emission mode): profile * malformed (SC4001), export unresolved (SC4002), unmappable * signature (SC4003), async/generator export (SC4004), the @@ -72,6 +73,24 @@ export interface ScrDiagnostic { note?: string; } +/** SC3002–SC3004 — native-helper target, installation, and execution + * failures. These are ordinary build diagnostics: a missing optional package + * or malformed LLVM input must never surface as an internal compiler error. */ +export function nativeCodegenDiag( + code: "SC3002" | "SC3003" | "SC3004", + message: string, + file: string, +): ScrDiagnostic { + return { + code, + message, + loc: { file, start: 0, end: 0 }, + ...(code === "SC3003" + ? { hint: "reinstall scriptc with optional dependencies enabled for this host" } + : {}), + }; +} + /* ── SC5xxx: native FFI ───────────────────────────────────────────────── */ /** SC5001 — the outbound native-FFI manifest is malformed or unreadable. */ diff --git a/packages/compiler/src/index.ts b/packages/compiler/src/index.ts index 4b767a2d5..558404dd5 100644 --- a/packages/compiler/src/index.ts +++ b/packages/compiler/src/index.ts @@ -1,12 +1,16 @@ import { InternalCompilerError } from "./errors.js"; -import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, rename, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; import { basename, dirname, join, resolve } from "node:path"; import { buildCacheRoot, CcCompileError, clearCcCaches, compileC, compileLibArchive, configuredTargetPlatform, executableNativeEnvironmentFingerprint, mobileLibraryTarget, mobileTargetRefusal, prepareBuildCacheRoot, pruneBuildCache, resolveCc, targetPlatform } from "./backend/native-toolchain.js"; import { emitCModule } from "./backend/c/c-emitter.js"; import { emitLlvmModule, LlvmUnsupportedError } from "./backend/llvm/emitter.js"; +import { emitNativeArtifact, NativeCodegenError } from "./backend/native-codegen.js"; +import { privateSiblingPath } from "./backend/build-cache.js"; +import { nativeCodegenTargetRefusal } from "./backend/targets.js"; import { splitLlvmLibraryProgram, splitLlvmProgram } from "./backend/llvm/split.js"; import { rebaseLibrarySourceComments, replaceLibraryIdentity, stripLibraryIdentity, stripLibrarySourceComments } from "./backend/library-identity-markers.js"; -import { checkerPanicDiag, ffiNativeBuildDiag, 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 { checkerPanicDiag, ffiNativeBuildDiag, libAsyncExportDiag, libAsyncSurfaceDiag, libExportUnresolvedDiag, libGenericExportDiag, libIntBoundaryDiag, libNpmIneligibleDiag, libSidecarDiag, libUnmappableSignatureDiag, iceDiag, isCheckerPanic, LIB_INBOUND_BYTES_TRAP_CODE, LIB_RUNTIME_TRAP_CODES, nativeCodegenDiag, type ScrDiagnostic } from "./diagnostics/diagnostic.js"; import { checkLibraryIntegerSlots, classSeed, hasIntSlots, numberCarrierKind, type FnIntSlots, type IntSlotConfig } from "./library/int-infer.js"; import { loadLibraryProfile, profileRemediation, profileTeaching, type LibraryProfile } from "./library/library-profile.js"; import { clearFenceEvalCaches, decorateLibraryRefusals, evaluateLibraryFences } from "./library/fence-eval.js"; @@ -151,7 +155,7 @@ export { } from "./frontend/provenance-registry.js"; export * as ir from "./ir/ir.js"; -export type CompileOutputKind = "ir" | "c" | "llvm" | "exe"; +export type CompileOutputKind = "ir" | "c" | "llvm" | "asm" | "obj" | "exe"; export interface CompileBaseOptions { /** Primary artifact path. The CLI supplies the output-kind default. */ @@ -207,6 +211,12 @@ export interface CompileBaseOptions { * historical compile() API, whose omitted output kind means executable. */ export interface CompileOptions extends CompileBaseOptions { outputKind?: "exe"; + /** Internal validation lane: executable builds still use the existing + * linker/runtime recipe, but the program LLVM TU is compiled to an object + * by the bundled helper first. Not a CLI contract; Phase 2 corpus tests use + * it to compare helper and clang objects under identical link inputs. + * Requires backend explicitly set to llvm. */ + nativeProgramObject?: boolean; } /** Source-artifact compile options, discriminated by the required kind. */ @@ -218,12 +228,16 @@ export interface CompileSourceOptions extends CompileBaseOptions { * 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: "c"; path: string } | { kind: "llvm"; path: string } + | { kind: "asm"; path: string } + | { kind: "obj"; path: string } | { kind: "exe"; path: string; @@ -239,7 +253,7 @@ export type CompileFailure = { }; export type CompileSourceResult = - | { ok: true; artifact: Extract } + | { ok: true; artifact: Extract } | CompileFailure; /** Historical executable result shape retained for source compatibility. */ @@ -1028,57 +1042,104 @@ async function compileExecutableNative( programSplit: ReturnType = null, onArtifactReady?: NonNullable[0]["onArtifactReady"]>, ): Promise { + const programIsObject = /\.(?:o|obj)$/.test(cPath); const effectiveProgramSplit = programSplit ?? - (features.optimization === "dev" && features.backend === "llvm" && !sanitize + (!programIsObject && features.optimization === "dev" && features.backend === "llvm" && !sanitize ? splitLlvmProgram(await readFile(cPath, "utf8")) : null); - await compileC({ - cPath, - outPath, - cacheIdentity: "scriptc-generated-v1", - ...(features.optimization === "dev" ? { optimization: "dev" as const } : {}), - ...(effectiveProgramSplit === null - ? {} - : { - programShards: effectiveProgramSplit.shards, - programPublicSymbols: effectiveProgramSplit.publicSymbols, - }), - sanitize, - dynamic: features.dynamic, - regex: features.regex, - copying: features.copying, - textDecoderLegacy: features.textDecoderLegacy, - fileHandle: features.fileHandle, - fetch: features.fetch, - netIsland: features.netIsland, - zlib: features.zlib, - assert: features.assert, - inspect: features.inspect, - dynInvoke: features.dynInvoke, - dc: features.dc, - dynAsync: features.dynAsync, - events: features.events, - emitter: features.emitter, - symbol: features.symbol, - searchParams: features.searchParams, - qs: features.qs, - parseArgs: features.parseArgs, - stream: features.stream, - net: features.net, - http: features.http, - http2: features.http2, - dgram: features.dgram, - watch: features.watch, - foreignFfi: features.foreignFfi, - nodeTest: features.nodeTest, - tls: features.tls, - tlsCa: features.tlsCa, - ...(onArtifactReady === undefined ? {} : { onArtifactReady }), - ...(ffi === null - ? {} - : { linkInputs: ffi.libraries, systemLibraries: ffi.systemLibraries }), - }); + const objectLinkDir = programIsObject + ? await mkdtemp(join(tmpdir(), "scriptc-object-link-")) + : null; + const linkDriverSource = objectLinkDir === null + ? cPath + : join(objectLinkDir, "driver.c"); + if (objectLinkDir !== null) await writeFile(linkDriverSource, "/* scriptc object link driver */\n"); + try { + await compileC({ + cPath: linkDriverSource, + outPath, + cacheIdentity: "scriptc-generated-v1", + ...(features.optimization === "dev" ? { optimization: "dev" as const } : {}), + ...(effectiveProgramSplit === null + ? {} + : { + programShards: effectiveProgramSplit.shards, + programPublicSymbols: effectiveProgramSplit.publicSymbols, + }), + sanitize, + dynamic: features.dynamic, + regex: features.regex, + copying: features.copying, + textDecoderLegacy: features.textDecoderLegacy, + fileHandle: features.fileHandle, + fetch: features.fetch, + netIsland: features.netIsland, + zlib: features.zlib, + assert: features.assert, + inspect: features.inspect, + dynInvoke: features.dynInvoke, + dc: features.dc, + dynAsync: features.dynAsync, + events: features.events, + emitter: features.emitter, + symbol: features.symbol, + searchParams: features.searchParams, + qs: features.qs, + parseArgs: features.parseArgs, + stream: features.stream, + net: features.net, + http: features.http, + http2: features.http2, + dgram: features.dgram, + watch: features.watch, + foreignFfi: features.foreignFfi, + nodeTest: features.nodeTest, + tls: features.tls, + tlsCa: features.tlsCa, + ...(onArtifactReady === undefined ? {} : { onArtifactReady }), + ...(ffi === null && !programIsObject + ? {} + : { + linkInputs: [ + ...(programIsObject ? [cPath] : []), + ...(ffi?.libraries ?? []), + ], + ...(ffi === null ? {} : { systemLibraries: ffi.systemLibraries }), + }), + }); + } finally { + if (objectLinkDir !== null) { + await rm(objectLinkDir, { recursive: true, force: true }).catch(() => undefined); + } + } +} + +async function emitNativeProgramObject( + entryPath: string, + opts: CompileRequestOptions, + llvm: string, +): Promise<{ linkPath: string; artifactPath: string }> { + const stem = basename(entryPath).replace(/\.(ts|mts|cts|js|mjs|cjs)$/, ""); + const artifactPath = join(opts.outDir, `${stem}.helper.o`); + // compileExecutableNative recognizes object inputs by suffix. The random + // private name isolates concurrent builds; retain .o so the driver links + // it rather than attempting to compile it as source. + const linkPath = `${privateSiblingPath(artifactPath, "native-program-object")}.o`; + try { + await emitNativeArtifact({ + outputPath: linkPath, + llvm, + outputKind: "obj", + sourcePath: entryPath, + optimization: opts.optimization === "dev" ? "0" : "2", + ...(opts.sanitize === undefined ? {} : { sanitize: opts.sanitize }), + }); + return { linkPath, artifactPath }; + } catch (error) { + await rm(linkPath, { force: true }).catch(() => undefined); + throw error; + } } async function compileTracked( @@ -1088,6 +1149,18 @@ async function compileTracked( ): Promise { entryPath = resolve(entryPath); const outputKind = opts.outputKind ?? "exe"; + if (opts.nativeProgramObject === true && + (outputKind !== "exe" || opts.backend !== "llvm")) { + return { + ok: false, + diagnostics: [nativeCodegenDiag( + "SC3002", + "native program-object validation requires an executable build with backend explicitly set to llvm", + entryPath, + )], + sourceTexts: new Map(), + }; + } let ffi: FfiProfile | null = null; let ffiProfileBytes: Uint8Array | null = null; if (opts.ffiProfilePath !== undefined) { @@ -1116,6 +1189,27 @@ async function compileTracked( sourceTexts: new Map(), }; } + if (outputKind === "asm" || outputKind === "obj") { + const refusal = nativeCodegenTargetRefusal(); + if (refusal !== null) { + return { + ok: false, + diagnostics: [nativeCodegenDiag("SC3002", refusal, entryPath)], + sourceTexts: new Map(), + }; + } + if (opts.sanitize === true) { + return { + ok: false, + diagnostics: [nativeCodegenDiag( + "SC3002", + `--sanitize is not supported with --emit=${outputKind}; AddressSanitizer instrumentation parity is not available in the LLVM native helper yet`, + entryPath, + )], + sourceTexts: new Map(), + }; + } + } } // Mobile triples are library-mode targets: the archive an embedding app // links is the artifact, and only the library-admissible runtime surface @@ -1167,7 +1261,7 @@ async function compileTracked( opts.ffiProfilePath === undefined || ffiProfileBytes === null ? null : { path: opts.ffiProfilePath, bytes: ffiProfileBytes }, - target: `${process.env["SCRIPTC_TARGET"] ?? "native"}:${buildPlatform}:${process.arch}`, + target: `${process.env["SCRIPTC_TARGET"] ?? "native"}:${buildPlatform}:${process.arch}:${opts.nativeProgramObject === true ? "helper-object" : "driver-tu"}`, compiler: [process.env["SCRIPTC_CC"] ?? "clang"], nativeEnvironment: await executableNativeEnvironmentFingerprint(), nodeVersion: process.version, @@ -1213,10 +1307,34 @@ async function compileTracked( : { llvmRefusal: earlyHit.native.llvmRefusal }), }; } + let nativeInputPath = earlyHit.cPath; + let nativeProgramObject: { linkPath: string; artifactPath: string } | null = null; + if (opts.nativeProgramObject === true) { + if (earlyHit.native.backend !== "llvm") { + throw new InternalCompilerError( + "native program-object cache hit restored a non-LLVM translation unit", + ); + } + try { + nativeProgramObject = await emitNativeProgramObject( + entryPath, + opts, + await readFile(earlyHit.cPath, "utf8"), + ); + nativeInputPath = nativeProgramObject.linkPath; + } catch (err) { + if (!(err instanceof NativeCodegenError)) throw err; + return { + ok: false, + diagnostics: [nativeCodegenDiag(err.diagnosticCode, err.message, entryPath)], + sourceTexts: new Map(), + }; + } + } try { await compileExecutableNative( earlyHit.native, - earlyHit.cPath, + nativeInputPath, opts.outPath, opts.sanitize ?? false, ffi, @@ -1230,6 +1348,9 @@ async function compileTracked( }); }, ); + if (nativeProgramObject !== null) { + await rename(nativeProgramObject.linkPath, nativeProgramObject.artifactPath); + } } catch (err) { if (ffi !== null && err instanceof CcCompileError) { return { @@ -1242,6 +1363,10 @@ async function compileTracked( }; } throw err; + } finally { + if (nativeProgramObject !== null) { + await rm(nativeProgramObject.linkPath, { force: true }).catch(() => undefined); + } } await pruneBuildCache(cacheRoot); return { @@ -1332,6 +1457,8 @@ async function compileTracked( ir: join(opts.outDir, `${stem}.ir.json`), c: join(opts.outDir, `${stem}.c`), llvm: join(opts.outDir, `${stem}.ll`), + asm: join(opts.outDir, `${stem}.s`), + obj: join(opts.outDir, `${stem}.o`), } as const; const defaultExecutablePaths = [ join(opts.outDir, stem), @@ -1341,7 +1468,10 @@ async function compileTracked( const removeStaleSourceArtifacts = async (keep: readonly string[]): Promise => { const kept = new Set(keep.map((path) => resolve(path))); const candidates = outputKind === "exe" - ? Object.values(defaultSourcePaths) + // Executable builds can generate only these compatibility/translation + // unit siblings. Assembly and object outputs are independent primary + // artifacts, so an executable build must never claim or delete them. + ? [defaultSourcePaths.ir, defaultSourcePaths.c, defaultSourcePaths.llvm] : opts.defaultOutputPath === true ? [...Object.values(defaultSourcePaths), ...defaultExecutablePaths] : []; @@ -1366,21 +1496,42 @@ async function compileTracked( return { ok: true, artifact: { kind: "c", path: opts.outPath } }; } - if (outputKind === "llvm") { + if (outputKind === "llvm" || outputKind === "asm" || outputKind === "obj") { let llvm: string; try { llvm = emitLlvmModule(lowered.module, { pointerBits: buildPlatform === "wasi" ? 32 : 64, wasi: buildPlatform === "wasi", + runtimeAbiMarker: outputKind === "obj", }); } catch (err) { if (!(err instanceof LlvmUnsupportedError)) throw err; return { ok: false, diagnostics: [llvmRefusalDiag(err, entryPath)], sourceTexts }; } - await mkdir(dirname(opts.outPath), { recursive: true }); - await writeFile(opts.outPath, llvm); + if (outputKind === "llvm") { + await mkdir(dirname(opts.outPath), { recursive: true }); + await writeFile(opts.outPath, llvm); + } else { + try { + await emitNativeArtifact({ + outputPath: opts.outPath, + llvm, + outputKind, + sourcePath: entryPath, + optimization: opts.optimization === "dev" ? "0" : "2", + ...(opts.sanitize === undefined ? {} : { sanitize: opts.sanitize }), + }); + } catch (err) { + if (!(err instanceof NativeCodegenError)) throw err; + return { + ok: false, + diagnostics: [nativeCodegenDiag(err.diagnosticCode, err.message, entryPath)], + sourceTexts, + }; + } + } await removeStaleSourceArtifacts([opts.outPath]); - return { ok: true, artifact: { kind: "llvm", path: opts.outPath } }; + return { ok: true, artifact: { kind: outputKind, path: opts.outPath } }; } await mkdir(opts.outDir, { recursive: true }); @@ -1398,6 +1549,7 @@ async function compileTracked( const ll = emitLlvmModule(lowered.module!, { pointerBits: buildPlatform === "wasi" ? 32 : 64, wasi: buildPlatform === "wasi", + runtimeAbiMarker: opts.nativeProgramObject === true, }); cPath = defaultSourcePaths.llvm; await writeFile(cPath, ll); @@ -1448,10 +1600,26 @@ async function compileTracked( } const executableCacheOptions = earlyCacheOptions; let publishedExecutable = false; + let nativeProgramObject: { linkPath: string; artifactPath: string } | null = null; try { + if (opts.nativeProgramObject === true) { + if (backend !== "llvm" || llvmSource === null) { + throw new InternalCompilerError("native program-object validation requires the LLVM backend"); + } + try { + nativeProgramObject = await emitNativeProgramObject(entryPath, opts, llvmSource); + } catch (err) { + if (!(err instanceof NativeCodegenError)) throw err; + return { + ok: false, + diagnostics: [nativeCodegenDiag(err.diagnosticCode, err.message, entryPath)], + sourceTexts, + }; + } + } await compileExecutableNative( nativeFeatures, - cPath, + nativeProgramObject?.linkPath ?? cPath, opts.outPath, opts.sanitize ?? false, ffi, @@ -1468,6 +1636,9 @@ async function compileTracked( publishedExecutable = true; }, ); + if (nativeProgramObject !== null) { + await rename(nativeProgramObject.linkPath, nativeProgramObject.artifactPath); + } } catch (err) { if (ffi !== null && err instanceof CcCompileError) { return { @@ -1482,6 +1653,10 @@ async function compileTracked( }; } throw err; + } finally { + if (nativeProgramObject !== null) { + await rm(nativeProgramObject.linkPath, { force: true }).catch(() => undefined); + } } if (!publishedExecutable) { await publishEarlyExecutableCache(cacheRoot, executableCacheOptions, { diff --git a/packages/compiler/test/llvm-runtime-abi.test.ts b/packages/compiler/test/llvm-runtime-abi.test.ts index 952d76807..21a92a218 100644 --- a/packages/compiler/test/llvm-runtime-abi.test.ts +++ b/packages/compiler/test/llvm-runtime-abi.test.ts @@ -34,7 +34,9 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { promisify } from "node:util"; import { describe, expect, test } from "vitest"; +import { emitLlvmModule } from "../src/backend/llvm/emitter.js"; import { resolveCc, runtimeSrcDir } from "../src/backend/native-toolchain.js"; +import type { IrModule } from "../src/ir/ir.js"; import { compile } from "../src/index.js"; const execFileAsync = promisify(execFile); @@ -224,6 +226,29 @@ function checkDeclare(d: LlDeclare, protos: Map): string | undef } describe("LLVM backend declares match scr_runtime.h prototypes", () => { + test("externally linkable objects reference the versioned runtime marker", async () => { + const loc = { file: "/source/abi-marker.ts", start: 0, end: 0 }; + const mod: IrModule = { + irVersion: 1, + sourceFile: loc.file, + entry: "%main", + functions: [{ + id: "%main", + name: "main", + params: [], + locals: [], + returnType: { kind: "void" }, + body: [{ kind: "return", value: null, loc }], + loc, + }], + }; + const llvm = emitLlvmModule(mod, { runtimeAbiMarker: true }); + expect(llvm).toContain("declare void @scr_runtime_abi_v1()"); + expect(llvm).toContain("call void @scr_runtime_abi_v1()"); + expect(await readFile(headerPath, "utf8")).toContain("void scr_runtime_abi_v1(void);"); + expect(await readFile(join(repoRoot, "packages/runtime/src/scr_console.c"), "utf8")) + .toContain("void scr_runtime_abi_v1(void) {}"); + }); test("ScrBytes structural type matches the C runtime layout", async () => { const outDir = await mkdtemp(join(tmpdir(), "scriptc-llvm-layout-")); const cPath = join(outDir, "scr-bytes-layout.c"); diff --git a/packages/compiler/test/native-codegen-integration.test.ts b/packages/compiler/test/native-codegen-integration.test.ts new file mode 100644 index 000000000..0971c44d9 --- /dev/null +++ b/packages/compiler/test/native-codegen-integration.test.ts @@ -0,0 +1,271 @@ +import { execFile, spawn } from "node:child_process"; +import { createRequire } from "node:module"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { release as osRelease, tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { promisify } from "node:util"; +import { afterEach, describe, expect, test } from "vitest"; +import { compile, compileC } from "../src/index.js"; +import { MACOS_ARM64_TARGET } from "../src/backend/targets.js"; + +const execFileAsync = promisify(execFile); +const supported = process.platform === "darwin" && process.arch === "arm64" && + Number.parseInt(osRelease().split(".", 1)[0] ?? "", 10) >= 24; +const repoRoot = join(import.meta.dirname, "../../.."); +const require = createRequire(import.meta.url); +const helperPackage = supported + ? require.resolve("@scriptc/llvm-darwin-arm64/package.json") + : ""; +const helper = supported + ? join(dirname(helperPackage), "bin", "scriptc-llvm-codegen") + : ""; +const dirs: string[] = []; + +afterEach(async () => { + await Promise.all(dirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); +}); + +function helperArgs(input: string, output: string, target = MACOS_ARM64_TARGET.llvmTriple) { + return [ + "emit", "--input", input, "--output", output, + "--filetype", "obj", "--target", target, + "--opt-level", "2", "--relocation-model", "pic", + "--diagnostic-format", "json", "--source-path", "/src/original.ts", + ]; +} + +async function run(command: string, args: string[]) { + try { + const result = await execFileAsync(command, args, { encoding: "buffer" }); + return { stdout: result.stdout, stderr: result.stderr, exitCode: 0 }; + } catch (error) { + const failure = error as { stdout?: Buffer; stderr?: Buffer; code?: number }; + return { + stdout: failure.stdout ?? Buffer.alloc(0), + stderr: failure.stderr ?? Buffer.alloc(0), + exitCode: typeof failure.code === "number" ? failure.code : 1, + }; + } +} + +describe.runIf(supported)("LLVM native helper integration", () => { + test("malformed IR and unsupported targets are structured and preserve caller output", async () => { + const dir = await mkdtemp(join(tmpdir(), "scriptc-helper-errors-")); + dirs.push(dir); + const input = join(dir, "bad.ll"); + const output = join(dir, "out.o"); + await writeFile(input, "this is not LLVM IR\n"); + await writeFile(output, "existing\n"); + const malformed = await run(helper, helperArgs(input, output)); + expect(malformed.exitCode).toBe(1); + expect(JSON.parse(malformed.stderr.toString("utf8"))).toMatchObject({ + ok: false, + code: "invalid_ir", + }); + expect(await readFile(output, "utf8")).toBe("existing\n"); + + await writeFile(input, "define i32 @answer() { ret i32 42 }\n"); + const unsupported = await run(helper, helperArgs(input, output, "x86_64-apple-macosx14.0.0")); + expect(JSON.parse(unsupported.stderr.toString("utf8"))).toMatchObject({ + ok: false, + code: "unsupported_target", + }); + expect(await readFile(output, "utf8")).toBe("existing\n"); + }); + + test("LLVM fatal diagnostics remain process-isolated JSON", async () => { + const result = await execFileAsync(helper, ["version", "--format=json"], { + encoding: "utf8", + env: { ...process.env, SCRIPTC_LLVM_TEST_FATAL: "1" }, + }).then( + () => null, + (error: { stderr?: string; code?: number }) => error, + ); + expect(result?.code).toBe(70); + expect(JSON.parse(result?.stderr ?? "{}")).toMatchObject({ + ok: false, + code: "llvm_fatal", + }); + }); + + test("an interrupted helper cannot truncate an existing caller artifact", async () => { + const dir = await mkdtemp(join(tmpdir(), "scriptc-helper-interrupt-")); + dirs.push(dir); + const input = join(dir, "large.ll"); + const output = join(dir, "out.o"); + const functions = Array.from( + { length: 100_000 }, + (_, i) => `define i32 @f${i}() { ret i32 ${i} }`, + ).join("\n"); + await writeFile(input, `${functions}\n`); + await writeFile(output, "existing\n"); + await new Promise((resolve, reject) => { + const child = spawn(helper, helperArgs(input, output), { stdio: "ignore" }); + child.once("error", reject); + child.once("spawn", () => child.kill("SIGKILL")); + child.once("close", () => resolve()); + }); + expect(await readFile(output, "utf8")).toBe("existing\n"); + }); + + test("helper and clang objects have matching metadata and identical runtime behavior", async () => { + const dir = await mkdtemp(join(tmpdir(), "scriptc-helper-parity-")); + dirs.push(dir); + const entry = join(repoRoot, "tests/corpus/001-hello.ts"); + const helperExe = join(dir, "helper-program"); + const result = await compile(entry, { + outDir: dir, + outPath: helperExe, + backend: "llvm", + nativeProgramObject: true, + }); + if (!result.ok) throw new Error(result.diagnostics.map((d) => d.message).join("\n")); + const llvm = join(dir, "001-hello.ll"); + const helperObject = join(dir, "001-hello.helper.o"); + const clangObject = join(dir, "001-hello.clang.o"); + await execFileAsync("clang", [ + "-O2", "-Wno-override-module", "-target", MACOS_ARM64_TARGET.llvmTriple, + "-c", llvm, "-o", clangObject, + ]); + + for (const object of [helperObject, clangObject]) { + await expect(execFileAsync("file", [object], { encoding: "utf8" })) + .resolves.toMatchObject({ stdout: expect.stringContaining("Mach-O 64-bit object arm64") }); + const loadCommands = (await execFileAsync("otool", ["-l", object], { encoding: "utf8" })).stdout; + expect(loadCommands).toMatch(/LC_BUILD_VERSION[\s\S]*minos 14\.0/); + } + const sections = async (object: string) => + [...(await execFileAsync("otool", ["-l", object], { encoding: "utf8" })).stdout + .matchAll(/sectname (\S+)[\s\S]*?segname (\S+)/g)] + .map((match) => `${match[2]},${match[1]}`).sort(); + for (const object of [helperObject, clangObject]) { + expect(await sections(object)).toEqual(expect.arrayContaining([ + "__TEXT,__text", + "__TEXT,__eh_frame", + "__LD,__compact_unwind", + ])); + } + const relocations = async (object: string) => + (await execFileAsync("otool", ["-rv", object], { encoding: "utf8" })).stdout + .split("\n") + .filter((line) => /\b(?:BR26|PAGE21|PAGOF12|GOTLDP|GOTLDPOF|SUB|UNSIGND)\b/.test(line)) + .map((line) => line.replace(/^\S+\s+/, "").trim()) + .sort(); + const [helperRelocations, clangRelocations] = await Promise.all([ + relocations(helperObject), relocations(clangObject), + ]); + for (const kind of ["BR26", "PAGE21", "PAGOF12", "GOTLDP", "GOTLDPOF", "SUB", "UNSIGND"]) { + expect(helperRelocations.some((line) => line.includes(kind)), `helper lacks ${kind}`) + .toBe(clangRelocations.some((line) => line.includes(kind))); + } + const symbols = async (object: string, args: string[]) => + (await execFileAsync("nm", [...args, object], { encoding: "utf8" })).stdout + .trim().split("\n").filter(Boolean).sort(); + expect(await symbols(helperObject, ["-u"])) + .toEqual(await symbols(clangObject, ["-u"])); + expect(await symbols(helperObject, ["-gU"])) + .toEqual(await symbols(clangObject, ["-gU"])); + expect(await symbols(helperObject, ["-u"])).toContain("_scr_runtime_abi_v1"); + + const clangExe = join(dir, "clang-program"); + const linkDriver = join(dir, "clang-driver.c"); + await writeFile(linkDriver, "/* clang object link driver */\n"); + await compileC({ cPath: linkDriver, outPath: clangExe, linkInputs: [clangObject] }); + const [helperRun, clangRun, nodeRun] = await Promise.all([ + run(helperExe, []), + run(clangExe, []), + run(process.execPath, [entry]), + ]); + expect(helperRun).toEqual(nodeRun); + expect(clangRun).toEqual(nodeRun); + }); + + test("helper-object validation isolates concurrent link inputs", async () => { + const dir = await mkdtemp(join(tmpdir(), "scriptc-helper-concurrent-")); + dirs.push(dir); + const firstEntry = join(dir, "first", "main.ts"); + const secondEntry = join(dir, "second", "main.ts"); + await Promise.all([mkdir(dirname(firstEntry)), mkdir(dirname(secondEntry))]); + await Promise.all([ + writeFile(firstEntry, 'console.log("first concurrent helper");\n'), + writeFile(secondEntry, 'console.log("second concurrent helper");\n'), + ]); + const build = (entry: string, output: string) => compile(entry, { + outDir: dir, + outPath: output, + backend: "llvm", + nativeProgramObject: true, + }); + const firstExe = join(dir, "first-program"); + const secondExe = join(dir, "second-program"); + const [first, second] = await Promise.all([ + build(firstEntry, firstExe), + build(secondEntry, secondExe), + ]); + if (!first.ok || !second.ok) throw new Error("concurrent helper builds failed"); + const [firstRun, secondRun] = await Promise.all([ + run(firstExe, []), + run(secondExe, []), + ]); + expect(firstRun).toMatchObject({ stdout: Buffer.from("first concurrent helper\n"), exitCode: 0 }); + expect(secondRun).toMatchObject({ stdout: Buffer.from("second concurrent helper\n"), exitCode: 0 }); + }); + + test("partial executable-cache hits still emit the program object through the helper", async () => { + const dir = await mkdtemp(join(tmpdir(), "scriptc-helper-cache-hit-")); + dirs.push(dir); + const oldCacheDir = process.env["SCRIPTC_CACHE_DIR"]; + const oldNoCache = process.env["SCRIPTC_NO_CACHE"]; + const oldFatal = process.env["SCRIPTC_LLVM_TEST_FATAL"]; + try { + process.env["SCRIPTC_CACHE_DIR"] = join(dir, "cache"); + delete process.env["SCRIPTC_NO_CACHE"]; + delete process.env["SCRIPTC_LLVM_TEST_FATAL"]; + const options = { + outDir: dir, + outPath: join(dir, "program"), + backend: "llvm" as const, + nativeProgramObject: true, + }; + const first = await compile(join(repoRoot, "tests/corpus/001-hello.ts"), options); + if (!first.ok) throw new Error(first.diagnostics.map((d) => d.message).join("\n")); + await rm(join(dir, "cache", "native-codegen-v1"), { recursive: true, force: true }); + + // Helper-object links deliberately retain a partial early-cache entry + // because the caller-owned object disables final-binary caching. Evict + // the independent helper-object cache so the hit must regenerate that + // object rather than compiling the restored .ll directly through clang. + process.env["SCRIPTC_LLVM_TEST_FATAL"] = "1"; + const second = await compile(join(repoRoot, "tests/corpus/001-hello.ts"), options); + expect(second).toMatchObject({ + ok: false, + diagnostics: [{ code: "SC3004", message: expect.stringContaining("LLVM fatal") }], + }); + } finally { + if (oldCacheDir === undefined) delete process.env["SCRIPTC_CACHE_DIR"]; + else process.env["SCRIPTC_CACHE_DIR"] = oldCacheDir; + if (oldNoCache === undefined) delete process.env["SCRIPTC_NO_CACHE"]; + else process.env["SCRIPTC_NO_CACHE"] = oldNoCache; + if (oldFatal === undefined) delete process.env["SCRIPTC_LLVM_TEST_FATAL"]; + else process.env["SCRIPTC_LLVM_TEST_FATAL"] = oldFatal; + } + }); + + test("object emission preserves outbound FFI declarations as native C ABI references", async () => { + const dir = await mkdtemp(join(tmpdir(), "scriptc-helper-ffi-")); + dirs.push(dir); + const object = join(dir, "ffi.o"); + const result = await compile(join(repoRoot, "tests/ffi/main.ts"), { + outDir: dir, + outPath: object, + outputKind: "obj", + ffiProfilePath: join(repoRoot, "tests/ffi/profile.json"), + }); + if (!result.ok) throw new Error(result.diagnostics.map((d) => d.message).join("\n")); + const undefinedSymbols = (await execFileAsync("nm", ["-u", object], { encoding: "utf8" })) + .stdout.trim().split("\n"); + expect(undefinedSymbols).toContain("_sf_scale"); + expect(undefinedSymbols).toContain("_sf_callback_mix"); + expect(undefinedSymbols).toContain("_scr_runtime_abi_v1"); + }); +}); diff --git a/packages/compiler/test/source-output.test.ts b/packages/compiler/test/source-output.test.ts index 028aeaf1d..f86c15e6e 100644 --- a/packages/compiler/test/source-output.test.ts +++ b/packages/compiler/test/source-output.test.ts @@ -73,6 +73,8 @@ test("an explicit source path never deletes same-stem sibling files", async () = join(outDir, "main.wasm"), join(outDir, "main.c"), join(outDir, "main.ll"), + join(outDir, "main.s"), + join(outDir, "main.o"), ]; await Promise.all(siblings.map((path) => writeFile(path, `caller-owned ${path}\n`))); const outPath = join(outDir, "main.ir.json"); @@ -83,6 +85,40 @@ test("an explicit source path never deletes same-stem sibling files", async () = } }); +test("an executable build never deletes same-stem assembly or object artifacts", async () => { + const { entry, outDir } = await fixture(); + await mkdir(outDir, { recursive: true }); + const siblings = [join(outDir, "main.s"), join(outDir, "main.o")]; + await Promise.all(siblings.map((path) => writeFile(path, `caller-owned ${path}\n`))); + const result = await compile(entry, { + outDir, + outPath: join(outDir, "custom-executable"), + backend: "c", + }); + if (!result.ok) throw new Error("executable build failed"); + for (const path of siblings) { + await expect(readFile(path, "utf8")).resolves.toBe(`caller-owned ${path}\n`); + } +}); + +test.each([undefined, "c"] as const)( + "native program-object validation rejects backend %s as a diagnostic", + async (backend) => { + const { entry, outDir } = await fixture(); + const result = await compile(entry, { + outDir, + outPath: join(outDir, "program"), + ...(backend === undefined ? {} : { backend }), + nativeProgramObject: true, + }); + expect(result).toMatchObject({ + ok: false, + diagnostics: [{ code: "SC3002", message: expect.stringContaining("backend explicitly set to llvm") }], + }); + await expect(readdir(outDir)).rejects.toMatchObject({ code: "ENOENT" }); + }, +); + test("explicit LLVM output refuses unsupported programs with SC3001", async () => { const dir = join(import.meta.dirname, "../../../tests/fixtures/server/cases/tls-connect-basic"); const entry = join(dir, "main.ts"); diff --git a/packages/llvm-darwin-arm64/LICENSE b/packages/llvm-darwin-arm64/LICENSE new file mode 100644 index 000000000..571517657 --- /dev/null +++ b/packages/llvm-darwin-arm64/LICENSE @@ -0,0 +1,278 @@ +============================================================================== +The LLVM Project is under the Apache License v2.0 with LLVM Exceptions: +============================================================================== + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + +---- LLVM Exceptions to the Apache 2.0 License ---- + +As an exception, if, as a result of your compiling your source code, portions +of this Software are embedded into an Object form of such source code, you +may redistribute such embedded portions in such Object form without complying +with the conditions of Sections 4(a), 4(b) and 4(d) of the License. + +In addition, if you combine or link compiled forms of this Software with +software that is licensed under the GPLv2 ("Combined Software") and if a +court of competent jurisdiction determines that the patent provision (Section +3), the indemnity provision (Section 9) or other Section of the License +conflicts with the conditions of the GPLv2, you may retroactively and +prospectively choose to deem waived or otherwise exclude such Section(s) of +the License, but only in their entirety and only with respect to the Combined +Software. + +============================================================================== +Software from third parties included in the LLVM Project: +============================================================================== +The LLVM Project contains third party software which is under different license +terms. All such code will be identified clearly using at least one of two +mechanisms: +1) It will be in a separate directory tree with its own `LICENSE.txt` or + `LICENSE` file at the top containing the specific license and restrictions + which apply to that software, or +2) It will contain specific license and restriction terms at the top of every + file. + +============================================================================== +Legacy LLVM License (https://llvm.org/docs/DeveloperPolicy.html#legacy): +============================================================================== +University of Illinois/NCSA +Open Source License + +Copyright (c) 2003-2019 University of Illinois at Urbana-Champaign. +All rights reserved. + +Developed by: + + LLVM Team + + University of Illinois at Urbana-Champaign + + http://llvm.org + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal with +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + + * Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimers. + + * Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimers in the + documentation and/or other materials provided with the distribution. + + * Neither the names of the LLVM Team, University of Illinois at + Urbana-Champaign, nor the names of its contributors may be used to + endorse or promote products derived from this Software without specific + prior written permission. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +CONTRIBUTORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS WITH THE +SOFTWARE. diff --git a/packages/llvm-darwin-arm64/SCRIPTC_LICENSE b/packages/llvm-darwin-arm64/SCRIPTC_LICENSE new file mode 100644 index 000000000..d64569567 --- /dev/null +++ b/packages/llvm-darwin-arm64/SCRIPTC_LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/llvm-darwin-arm64/THIRD_PARTY_NOTICES b/packages/llvm-darwin-arm64/THIRD_PARTY_NOTICES new file mode 100644 index 000000000..25adaaadd --- /dev/null +++ b/packages/llvm-darwin-arm64/THIRD_PARTY_NOTICES @@ -0,0 +1,40 @@ +scriptc LLVM helper third-party notices +====================================== + +LLVM 22.1.8 +----------- +Copyright LLVM contributors. Licensed under the Apache License v2.0 with LLVM +Exceptions. SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception. +Complete license: https://llvm.org/LICENSE.txt +Source: https://github.com/llvm/llvm-project/tree/llvmorg-22.1.8 + +The LLVM exception permits compiling and statically linking LLVM into this +helper without causing the resulting executable to be covered by the Apache +License. The Apache License's notice and source-offer conditions still apply. + +zstd 1.5.7 +---------- +Copyright (c) Meta Platforms, Inc. and affiliates. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. + +Source: https://github.com/facebook/zstd/releases/tag/v1.5.7 diff --git a/packages/llvm-darwin-arm64/package.json b/packages/llvm-darwin-arm64/package.json new file mode 100644 index 000000000..6eb12270a --- /dev/null +++ b/packages/llvm-darwin-arm64/package.json @@ -0,0 +1,31 @@ +{ + "name": "@scriptc/llvm-darwin-arm64", + "version": "0.0.35", + "description": "Pinned LLVM code-generation helper for scriptc on macOS arm64", + "license": "Apache-2.0 WITH LLVM-exception", + "homepage": "https://scriptc.dev", + "repository": { + "type": "git", + "url": "git+https://github.com/vercel-labs/scriptc.git", + "directory": "packages/llvm-darwin-arm64" + }, + "os": ["darwin"], + "cpu": ["arm64"], + "bin": { + "scriptc-llvm-codegen": "bin/scriptc-llvm-codegen" + }, + "files": [ + "bin", + "LICENSE", + "SCRIPTC_LICENSE", + "THIRD_PARTY_NOTICES" + ], + "scripts": { + "build": "true", + "build:native": "node scripts/build.mjs", + "prepack": "test -x bin/scriptc-llvm-codegen" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/llvm-darwin-arm64/scripts/build.mjs b/packages/llvm-darwin-arm64/scripts/build.mjs new file mode 100644 index 000000000..e6c7e7cd6 --- /dev/null +++ b/packages/llvm-darwin-arm64/scripts/build.mjs @@ -0,0 +1,44 @@ +#!/usr/bin/env node +import { execFileSync } from "node:child_process"; +import { chmodSync, copyFileSync, mkdirSync, readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const packageRoot = fileURLToPath(new URL("..", import.meta.url)); +const repoRoot = fileURLToPath(new URL("../../..", import.meta.url)); +if (process.platform !== "darwin" || process.arch !== "arm64") { + process.stdout.write("@scriptc/llvm-darwin-arm64: skipped on this host\n"); + process.exit(0); +} + +const manifest = JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8")); +const buildDir = join(repoRoot, "node_modules/.cache/scriptc-llvm-darwin-arm64"); +const llvmDir = process.env.LLVM_DIR ?? "/opt/homebrew/opt/llvm@22/lib/cmake/llvm"; +execFileSync("cmake", [ + "-S", join(repoRoot, "native/llvm-codegen"), + "-B", buildDir, + "-G", "Ninja", + `-DLLVM_DIR=${llvmDir}`, + `-DSCRIPTC_PACKAGE_VERSION=${manifest.version}`, + "-DCMAKE_BUILD_TYPE=Release", +], { stdio: "inherit" }); +execFileSync("cmake", ["--build", buildDir, "--target", "scriptc-llvm-codegen"], { + stdio: "inherit", +}); + +const binDir = join(packageRoot, "bin"); +mkdirSync(binDir, { recursive: true }); +const output = join(binDir, "scriptc-llvm-codegen"); +copyFileSync(join(buildDir, "scriptc-llvm-codegen"), output); +execFileSync("strip", ["-x", output], { stdio: "inherit" }); +chmodSync(output, 0o755); +const version = JSON.parse(execFileSync(output, ["version", "--format=json"], { + encoding: "utf8", +})); +if ( + version.protocol_version !== "1" || + version.scriptc_package_version !== manifest.version || + version.llvm_version !== "22.1.8" +) { + throw new Error(`built helper reported an incompatible identity: ${JSON.stringify(version)}`); +} diff --git a/packages/runtime/src/scr_console.c b/packages/runtime/src/scr_console.c index dc83a7b49..f05d1832a 100644 --- a/packages/runtime/src/scr_console.c +++ b/packages/runtime/src/scr_console.c @@ -71,6 +71,8 @@ static void scr_flush_at_exit(void) { fflush(stdout); } static void scr_collect_cycles_at_exit(void) { scr_collect_cycles(); } +void scr_runtime_abi_v1(void) {} + void scr_init(void) { #ifdef _WIN32 /* The CRT opens std streams in TEXT mode, which writes \n as \r\n. Node diff --git a/packages/runtime/src/scr_runtime.h b/packages/runtime/src/scr_runtime.h index 30cad49b9..61f627e2b 100644 --- a/packages/runtime/src/scr_runtime.h +++ b/packages/runtime/src/scr_runtime.h @@ -53,6 +53,10 @@ void arc4random_buf(void *buf, size_t n); * flush-at-exit, RC audit registration (when built with -DSCR_RC_AUDIT). * JavaScript-visible writes flush before returning. */ void scr_init(void); +/* Program objects emitted by the bundled LLVM helper reference this symbol. + * Its versioned spelling makes a mismatched manual runtime link fail before + * the program can start. */ +void scr_runtime_abi_v1(void); /* ── the trap funnel (scr_console.c; scr_library.c under -DSCR_LIB) ────── * Every unrecoverable runtime trap — OOM, semantic range traps, internal- diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fc0566257..a374bcb96 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -50,6 +50,12 @@ importers: typescript5: specifier: npm:typescript@5.9.3 version: typescript@5.9.3 + optionalDependencies: + '@scriptc/llvm-darwin-arm64': + specifier: workspace:* + version: link:../llvm-darwin-arm64 + + packages/llvm-darwin-arm64: {} packages/runtime: {} diff --git a/scripts/llvm-package-symbols.mjs b/scripts/llvm-package-symbols.mjs new file mode 100644 index 000000000..1fd2b0394 --- /dev/null +++ b/scripts/llvm-package-symbols.mjs @@ -0,0 +1,11 @@ +const MACHO_LINKER_GLOBALS = new Set(["__mh_execute_header"]); + +/** Executables may expose Mach-O's linker-defined image-header symbol even + * when their exported-symbol list contains only the process entry point. */ +export function validateLlvmHelperExports(symbols) { + return { + hasMain: symbols.includes("_main"), + unexpected: symbols.filter((symbol) => + symbol !== "_main" && !MACHO_LINKER_GLOBALS.has(symbol)), + }; +} diff --git a/scripts/surface-manifest.mjs b/scripts/surface-manifest.mjs index 086e07734..12af45687 100644 --- a/scripts/surface-manifest.mjs +++ b/scripts/surface-manifest.mjs @@ -9,9 +9,9 @@ // The version spine: compilerVersion is the EXACT published release version // — the version release.yml keys on (packages/cli/package.json, the // `scriptc` package) — so an external version pin matches the manifest's -// string verbatim. All three workspace packages must agree (the same check -// the release workflow runs); a drifted tree fails generation instead of -// stamping an ambiguous version. +// string verbatim. All four published workspace packages must agree (the same +// check the release workflow runs); a drifted tree fails generation instead +// of stamping an ambiguous version. // // Usage: // pnpm manifest regenerate packages/compiler/surface-manifest.json @@ -28,7 +28,7 @@ const root = fileURLToPath(new URL("..", import.meta.url)); const readJson = (path) => JSON.parse(readFileSync(root + path, "utf8")); const version = readJson("packages/cli/package.json").version; -for (const pkg of ["runtime", "compiler"]) { +for (const pkg of ["runtime", "llvm-darwin-arm64", "compiler"]) { const v = readJson(`packages/${pkg}/package.json`).version; if (v !== version) { console.error( diff --git a/scripts/sync-versions.mjs b/scripts/sync-versions.mjs index 2d9d0765e..2d9dcfa29 100644 --- a/scripts/sync-versions.mjs +++ b/scripts/sync-versions.mjs @@ -1,6 +1,7 @@ #!/usr/bin/env node // Stamps the version from packages/cli/package.json into packages/runtime -// and packages/compiler, so the three published packages move in lockstep. +// packages/compiler, runtime, and platform helper packages so every component +// participating in the helper protocol moves in lockstep. // Usage: node scripts/sync-versions.mjs import { readFileSync, writeFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; @@ -15,7 +16,7 @@ if (typeof version !== "string" || version.length === 0) { process.exit(1); } -for (const pkg of ["runtime", "compiler"]) { +for (const pkg of ["runtime", "compiler", "llvm-darwin-arm64"]) { const path = manifest(pkg); const json = read(path); if (json.version === version) { diff --git a/scripts/verify-llvm-package.mjs b/scripts/verify-llvm-package.mjs new file mode 100644 index 000000000..ce47ade23 --- /dev/null +++ b/scripts/verify-llvm-package.mjs @@ -0,0 +1,101 @@ +#!/usr/bin/env node +import { execFileSync } from "node:child_process"; +import { accessSync, constants, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { validateLlvmHelperExports } from "./llvm-package-symbols.mjs"; + +const tarball = process.argv[2]; +if (tarball === undefined) throw new Error("usage: verify-llvm-package.mjs "); +// Homebrew's arm64 LLVM 22 bottles are not byte-identical across supported +// macOS runner images: clean builds have produced stripped helpers ranging +// from roughly 26 MiB to 53 MiB despite identical protocol, LLVM version, +// target set, and dynamic dependencies. Keep a hard package-size regression +// fence with enough room for both observed bottle layouts. +const installedBudget = 64 * 1024 * 1024; +const packedBudget = 32 * 1024 * 1024; +const work = mkdtempSync(join(tmpdir(), "scriptc-llvm-pack-")); +try { + execFileSync("tar", ["-xzf", tarball, "-C", work]); + const root = join(work, "package"); + const manifest = JSON.parse(readFileSync(join(root, "package.json"), "utf8")); + for (const notice of ["LICENSE", "SCRIPTC_LICENSE", "THIRD_PARTY_NOTICES"]) { + if (statSync(join(root, notice)).size === 0) throw new Error(`${notice} is missing or empty`); + } + if (manifest.os?.join(",") !== "darwin" || manifest.cpu?.join(",") !== "arm64") { + throw new Error("helper manifest must constrain os=darwin and cpu=arm64"); + } + if (manifest.bin?.["scriptc-llvm-codegen"] !== "bin/scriptc-llvm-codegen") { + throw new Error("helper manifest must expose the executable as an npm bin"); + } + const binary = join(root, "bin", "scriptc-llvm-codegen"); + accessSync(binary, constants.X_OK); + const installedSize = statSync(binary).size; + const packedSize = statSync(tarball).size; + const sizeFailures = [ + ...(installedSize > installedBudget + ? [`stripped helper is ${installedSize} bytes, exceeding the ${installedBudget}-byte installed-size budget`] + : []), + ...(packedSize > packedBudget + ? [`helper tarball is ${packedSize} bytes, exceeding the ${packedBudget}-byte compressed-size budget`] + : []), + ]; + if (sizeFailures.length > 0) throw new Error(sizeFailures.join("; ")); + const version = JSON.parse(execFileSync(binary, ["version", "--format=json"], { + encoding: "utf8", + })); + if ( + version.protocol_version !== "1" || + version.scriptc_package_version !== manifest.version || + version.llvm_version !== "22.1.8" || + version.default_target !== "arm64-apple-macosx14.0.0" + ) throw new Error(`packed helper identity mismatch: ${JSON.stringify(version)}`); + const dependencies = execFileSync("otool", ["-L", binary], { encoding: "utf8" }); + const nonSystemDependencies = dependencies.trim().split("\n").slice(1) + .map((line) => line.trim().split(" (compatibility version", 1)[0]) + .filter((path) => path !== undefined && + !path.startsWith("/usr/lib/") && !path.startsWith("/System/Library/")); + if (nonSystemDependencies.length > 0) { + throw new Error( + `packed helper has non-system runtime dependencies: ${nonSystemDependencies.join(", ")}\n` + + dependencies, + ); + } + const exportedSymbols = execFileSync("nm", ["-gU", binary], { encoding: "utf8" }) + .trim().split("\n").filter(Boolean) + .map((line) => line.trim().split(/\s+/).at(-1)); + const exportValidation = validateLlvmHelperExports(exportedSymbols); + if (!exportValidation.hasMain || exportValidation.unexpected.length > 0) { + throw new Error( + `packed helper must export _main without LLVM globals, found: ${exportedSymbols.join(", ")}`, + ); + } + const loadCommands = execFileSync("otool", ["-l", binary], { encoding: "utf8" }); + if (!/LC_BUILD_VERSION[\s\S]*?platform 1[\s\S]*?minos 15\.0(?:\s|$)/.test(loadCommands)) { + throw new Error("packed helper must declare its macOS 15.0 minimum host version"); + } + const probeInput = join(work, "probe.ll"); + const probeObject = join(work, "probe.o"); + writeFileSync(probeInput, "define i32 @answer() { ret i32 42 }\n"); + execFileSync(binary, [ + "emit", "--input", probeInput, "--output", probeObject, + "--filetype", "obj", "--target", version.default_target, + "--opt-level", "2", "--relocation-model", "pic", + "--diagnostic-format", "json", "--source-path", "/src/probe.ts", + ]); + const objectLoadCommands = execFileSync("otool", ["-l", probeObject], { encoding: "utf8" }); + if (!/LC_BUILD_VERSION[\s\S]*?platform 1[\s\S]*?minos 14\.0(?:\s|$)/.test(objectLoadCommands)) { + throw new Error("packed helper must emit objects with the macOS 14.0 deployment target"); + } + const attributes = execFileSync("xattr", [binary], { encoding: "utf8" }); + if (attributes.split("\n").includes("com.apple.quarantine")) { + throw new Error("packed helper carries a quarantine attribute"); + } + execFileSync("codesign", ["--verify", "--strict", binary], { stdio: "inherit" }); + process.stdout.write( + `verified ${manifest.name}@${manifest.version}: ${installedSize} bytes installed, ` + + `${packedSize} bytes packed\n`, + ); +} finally { + rmSync(work, { recursive: true, force: true }); +} diff --git a/tests/harness/llvm-differential.test.ts b/tests/harness/llvm-differential.test.ts index d244abaf4..739dbe3e9 100644 --- a/tests/harness/llvm-differential.test.ts +++ b/tests/harness/llvm-differential.test.ts @@ -19,6 +19,7 @@ import { execFile } from "node:child_process"; import { createHash } from "node:crypto"; import { globSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; import { join } from "node:path"; +import { release as osRelease } from "node:os"; import { pathToFileURL } from "node:url"; import { promisify } from "node:util"; import { afterAll, describe, expect, test } from "vitest"; @@ -42,6 +43,7 @@ const files = shardSelect( (f) => f.slice(corpusDir.length + 1), ); const sanitize = process.env["SCRIPTC_SAN"] === "1"; +const helperOnly = process.env["SCRIPTC_LLVM_HELPER_ONLY"] === "1"; // Same known-env contract as the main differential suite. process.env["SCRIPTC_TEST_ENV"] = "from-harness"; @@ -63,6 +65,20 @@ const TIER_REGRESSIONS = [ "2672-http-request-response-callback.ts", ]; +/** Phase 2 object-emission floor: these claimed corpus cases pin every + * required helper-validation family. TLS server creation remains one of the + * six explicit SC3001 refusals outside this floor. */ +const HELPER_TIER_FLOOR = [ + "1020-async-basics.ts", + "2010-generators-basics.ts", + "984-exceptions-finally.ts", + "1100-island-eval-basics.ts", + "1200-regex-test-basics.ts", + "2672-http-request-response-callback.ts", + "1404-zlib-crypto-bytes.ts", + "2557-tls-ca-store.ts", +]; + interface RunResult { stdout: Buffer; stderr: Buffer; @@ -186,7 +202,7 @@ function programInputs(file: string): string[] { ].sort(); } -async function build(file: string, backend: "c" | "llvm" | "default") { +async function build(file: string, backend: "c" | "llvm" | "helper" | "default") { const hash = createHash("sha256"); for (const f of programInputs(file)) hash.update(f).update(readFileSync(f)); // "llvm-c" (not the bare key differential.test.ts computes) keeps this @@ -197,7 +213,7 @@ async function build(file: string, backend: "c" | "llvm" | "default") { const key = hash .update(sanitize ? "san" : "plain") .update(wantsDynamic(file) ? "dyn" : "") - .update(backend === "llvm" ? "llvm" : backend === "c" ? "llvm-c" : "llvm-def") + .update(backend === "llvm" ? "llvm" : backend === "helper" ? "llvm-helper" : backend === "c" ? "llvm-c" : "llvm-def") .digest("hex") .slice(0, 16); const outDir = join(cacheDir, key); @@ -213,7 +229,7 @@ async function build(file: string, backend: "c" | "llvm" | "default") { // CI runner as the seven fs/path llvm-differential failures of run // 29965245855 — empty or interleaved stdout on whichever lane lost the // race, reproducible locally by racing the two same-named binaries. - const lane = backend === "llvm" ? "program-llvm" : backend === "c" ? "program-llvmc" : "program-llvmdef"; + const lane = backend === "llvm" ? "program-llvm" : backend === "helper" ? "program-llvmhelper" : backend === "c" ? "program-llvmc" : "program-llvmdef"; return compile(file, { outPath: join(outDir, `${lane}${sanitize ? "-san" : ""}`), outDir, @@ -221,7 +237,8 @@ async function build(file: string, backend: "c" | "llvm" | "default") { dynamic: wantsDynamic(file), // "default" leaves the option unset — the release default's // LLVM-with-transparent-C-fallback lane, exercised on refusals below. - ...(backend === "default" ? {} : { backend }), + ...(backend === "default" ? {} : { backend: backend === "helper" ? "llvm" as const : backend }), + ...(backend === "helper" ? { nativeProgramObject: true } : {}), }); } @@ -262,6 +279,32 @@ describe(`llvm differential corpus (${files.length} programs${sanitize ? ", sani expect(llvmRes.llvmRefusal).toBeUndefined(); expect(llvmRes.cPath.endsWith(".ll")).toBe(true); + if (helperOnly) { + if (sanitize) throw new Error("the helper object lane does not support sanitizer mode"); + if (process.platform !== "darwin" || process.arch !== "arm64" || + Number.parseInt(osRelease().split(".", 1)[0] ?? "", 10) < 24) { + throw new Error("the helper object lane requires macOS 15+ arm64"); + } + const helperRes = await build(file, "helper"); + if (!helperRes.ok) { + throw new Error( + `LLVM helper object emission failed on a program the LLVM tier claims: ${rel}: ` + + helperRes.diagnostics.map((d) => `${d.code} ${d.message}`).join("; "), + ); + } + const [llvm, helper] = await Promise.all([ + runBinary(llvmRes.binaryPath, []), + runBinary(helperRes.binaryPath, []), + ]); + expect(helper.stdout, `helper object stdout differed for ${rel}`).toEqual(llvm.stdout); + if (helper.exitCode === 0) { + expect(comparableStderr(helper.stderr), `helper object stderr differed for ${rel}`) + .toEqual(comparableStderr(llvm.stderr)); + } + expect(helper.exitCode, `helper object exit code differed for ${rel}`).toBe(llvm.exitCode); + return; + } + const cRes = await build(file, "c"); if (!cRes.ok) throw new Error(`C backend failed on a program the LLVM tier claims: ${rel}`); expect(cRes.backend).toBe("c"); @@ -312,6 +355,12 @@ describe(`llvm differential corpus (${files.length} programs${sanitize ? ", sani } }); + test.skipIf(!helperOnly)("helper object feature floor stays claimed", () => { + for (const name of shardSelect(HELPER_TIER_FLOOR, (n) => n)) { + expect(claimed, `${name} regressed out of helper object emission`).toContain(name); + } + }); + afterAll(() => { const hist = [...refusalKinds].sort((a, b) => b[1] - a[1]); // eslint-disable-next-line no-console diff --git a/tests/harness/llvm-package-symbols.test.ts b/tests/harness/llvm-package-symbols.test.ts new file mode 100644 index 000000000..f7eebabed --- /dev/null +++ b/tests/harness/llvm-package-symbols.test.ts @@ -0,0 +1,16 @@ +import { expect, test } from "vitest"; +import { validateLlvmHelperExports } from "../../scripts/llvm-package-symbols.mjs"; + +test("LLVM helper export validation permits Mach-O's linker-defined image header", () => { + expect(validateLlvmHelperExports(["__mh_execute_header", "_main"])).toEqual({ + hasMain: true, + unexpected: [], + }); +}); + +test("LLVM helper export validation requires main and rejects LLVM globals", () => { + expect(validateLlvmHelperExports(["__mh_execute_header", "_ZN4llvm4errsEv"])).toEqual({ + hasMain: false, + unexpected: ["_ZN4llvm4errsEv"], + }); +}); diff --git a/tests/harness/surface-manifest.test.ts b/tests/harness/surface-manifest.test.ts index e226f6a70..6866e6933 100644 --- a/tests/harness/surface-manifest.test.ts +++ b/tests/harness/surface-manifest.test.ts @@ -39,7 +39,7 @@ const readJson = (p: string): { version: string } => // The version spine: the release workflow keys on packages/cli (the // published `scriptc` package); sync-versions.mjs stamps the same string -// into runtime and compiler. +// into runtime, the native LLVM helper, and compiler. const releaseVersion = readJson("packages/cli/package.json").version; const committed = readFileSync(manifestPath, "utf8"); @@ -63,10 +63,11 @@ describe("surface manifest generation", () => { test("the version spine is the exact published version string", () => { const parsed = JSON.parse(committed) as SurfaceManifest; expect(parsed.compilerVersion).toBe(releaseVersion); - // The three packages publish in lockstep; a drifted stamp would make + // The four packages publish in lockstep; a drifted stamp would make // the spine ambiguous for a version pin. expect(readJson("packages/compiler/package.json").version).toBe(releaseVersion); expect(readJson("packages/runtime/package.json").version).toBe(releaseVersion); + expect(readJson("packages/llvm-darwin-arm64/package.json").version).toBe(releaseVersion); }); test("schema: ids unique and sorted, enums valid, codes on every non-static entry", () => {