From 46ebcb7af1384f03e720209d4ea064229c2849ec Mon Sep 17 00:00:00 2001 From: Keys <70819367+keys-i@users.noreply.github.com> Date: Thu, 13 Aug 2026 14:00:40 +1000 Subject: [PATCH] Build the Rust workspace and Wasm artifacts. --- .gitignore | 4 +- Cargo.lock | 27 ++ Cargo.toml | 21 ++ README.md | 60 +++-- dist/krad-clang.mjs | 1 + dist/krad-clang.wasm | Bin 0 -> 88 bytes dist/krad-libs.wasm | Bin 0 -> 3707 bytes dist/krad-musl.wasm | Bin 0 -> 3529 bytes dist/krad-utils.wasm | Bin 0 -> 115 bytes examples/add.cpp | 5 + package.json | 18 -- packages/krad-clang/Cargo.toml | 9 + packages/krad-clang/index.js | 13 - packages/krad-clang/package.json | 7 - packages/krad-clang/src/lib.rs | 100 ++++++++ packages/krad-libs/Cargo.toml | 13 + packages/krad-libs/index.js | 14 -- packages/krad-libs/package.json | 7 - packages/krad-libs/src/lib.rs | 59 +++++ packages/krad-musl/Cargo.toml | 10 + packages/krad-musl/index.js | 14 -- packages/krad-musl/package.json | 7 - packages/krad-musl/src/lib.rs | 104 ++++++++ packages/krad-utils/Cargo.toml | 19 ++ packages/krad-utils/index.js | 76 ------ packages/krad-utils/package.json | 7 - packages/krad-utils/src/lib.rs | 36 +++ packages/krad-utils/src/main.rs | 415 +++++++++++++++++++++++++++++++ packages/krad-utils/tests/cli.rs | 23 ++ rust-toolchain.toml | 5 + test/krad.test.js | 70 ------ tools/krad.js | 83 ------- 32 files changed, 890 insertions(+), 337 deletions(-) create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 dist/krad-clang.mjs create mode 100644 dist/krad-clang.wasm create mode 100644 dist/krad-libs.wasm create mode 100644 dist/krad-musl.wasm create mode 100644 dist/krad-utils.wasm create mode 100644 examples/add.cpp delete mode 100644 package.json create mode 100644 packages/krad-clang/Cargo.toml delete mode 100644 packages/krad-clang/index.js delete mode 100644 packages/krad-clang/package.json create mode 100644 packages/krad-clang/src/lib.rs create mode 100644 packages/krad-libs/Cargo.toml delete mode 100644 packages/krad-libs/index.js delete mode 100644 packages/krad-libs/package.json create mode 100644 packages/krad-libs/src/lib.rs create mode 100644 packages/krad-musl/Cargo.toml delete mode 100644 packages/krad-musl/index.js delete mode 100644 packages/krad-musl/package.json create mode 100644 packages/krad-musl/src/lib.rs create mode 100644 packages/krad-utils/Cargo.toml delete mode 100644 packages/krad-utils/index.js delete mode 100644 packages/krad-utils/package.json create mode 100644 packages/krad-utils/src/lib.rs create mode 100644 packages/krad-utils/src/main.rs create mode 100644 packages/krad-utils/tests/cli.rs create mode 100644 rust-toolchain.toml delete mode 100644 test/krad.test.js delete mode 100755 tools/krad.js diff --git a/.gitignore b/.gitignore index 69c575d..b83d222 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1 @@ -build/ -dist/ -node_modules/ +/target/ diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..27b0307 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,27 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "krad-clang" +version = "0.1.0" + +[[package]] +name = "krad-libs" +version = "0.1.0" +dependencies = [ + "krad-musl", +] + +[[package]] +name = "krad-musl" +version = "0.1.0" + +[[package]] +name = "krad-utils" +version = "0.1.0" +dependencies = [ + "krad-clang", + "krad-libs", + "krad-musl", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..3de3d88 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,21 @@ +[workspace] +members = [ + "packages/krad-clang", + "packages/krad-libs", + "packages/krad-musl", + "packages/krad-utils", +] +resolver = "3" + +[workspace.package] +edition = "2024" +publish = false +rust-version = "1.85" +version = "0.1.0" + +[profile.release] +codegen-units = 1 +lto = true +opt-level = "s" +panic = "abort" +strip = "symbols" diff --git a/README.md b/README.md index efacbb6..1d6a0f7 100644 --- a/README.md +++ b/README.md @@ -1,28 +1,52 @@ # krad -Krad is the source-pinned component graph for a compact client-side C/C++ -toolchain. It keeps the compiler, libc, platform glue, and support libraries as -four independently replaceable packages instead of copying multi-gigabyte -source trees into this repository: +Krad is a Rust workspace that turns pinned local Cheerp research inputs into +real WebAssembly artifacts. It does not download a compiler or pretend to be a +new LLVM implementation. + +The four packages have concrete jobs: + +- `krad-clang`: a Rust driver that invokes one verified Cheerp `clang++` with a + fixed `-O3 -target cheerp-wasm` ES-module build. +- `krad-musl`: an allocator-free `no_std` memory/string ABI subset. +- `krad-libs`: small Linux/FreeBSD errno, page, and guest-copy helpers over + `krad-musl`. +- `krad-utils`: a `no_std` Wasm contract plus the Rust `krad` verification/build + CLI. + +Build from this checkout with the sibling `../research/cheerp-*` repositories: + +```sh +cargo run -p krad-utils -- verify ../research +cargo run -p krad-utils -- build ../research dist +``` + +The second command verifies every source remote/revision/license, verifies the +local Cheerp 3.0 archive SHA-256, extracts it with `brotli` and `tar`, builds the +Rust workspace for `wasm32v1-none`, and compiles `examples/add.cpp` with the +real pinned compiler. It creates: ```text -krad-clang - └── krad-utils - └── krad-musl - └── krad-libs +dist/krad-clang.mjs +dist/krad-clang.wasm +dist/krad-libs.wasm +dist/krad-musl.wasm +dist/krad-utils.wasm ``` -The first boundary is reproducible source identity. Verify the local research -inputs without downloading or changing them: +The generated Cheerp module is directly executable: ```sh -node tools/krad.js plan -node tools/krad.js verify ../research -npm test +node --input-type=module -e \ + "import init from './dist/krad-clang.mjs'; const api=await init({absPath:'./dist/krad-clang.wasm'}); if(api.krad_add(20,22)!==42) process.exit(1)" ``` -Use `--json` with `plan` or `verify` for machine-readable output. The four -packages are private until compiler/sysroot artifacts and their browser runtime -contract are built and tested; they must not be published as empty toolchains. -No upstream source is vendored here, and each component records its own source -license and exact researched revision. +Run `cargo fmt --all -- --check`, `cargo clippy --workspace --all-targets -- -D warnings`, +and `cargo test --workspace` for the Rust boundary. The checked-in `dist/` +files are build products, not source-package placeholders. + +Krad currently wraps Cheerp 3.0 rc.5 (embedded compiler revision +`29e4600563ed2153faf0221362ce4f0a5b93e1ce`). It does not yet build the full +Cheerp compiler from `cheerp-compiler`, provide a complete musl/sysroot, compile +inside a browser, boot Fedora/FreeBSD/Debian, or establish a performance or size +advantage over Cheerp. Those claims require separate comparable evidence. diff --git a/dist/krad-clang.mjs b/dist/krad-clang.mjs new file mode 100644 index 0000000..959025f --- /dev/null +++ b/dist/krad-clang.mjs @@ -0,0 +1 @@ +"use strict";/*Compiled using Cheerp (R) by Leaning Technologies Ltd*/var m=Math.imul;var n=Math.fround;var oSlot=0;var nullArray=[null];var nullObj={d:nullArray,o:0};function M(p){return (typeof self==='object')?fetch(p).then(r=>r.arrayBuffer()):new Promise((y,n)=>{import('fs').then(r=>r.readFile(p,(e,d)=>{if(e)n(e);else y(d);}));});}function g(){}var __asm=null,__heap=null;function J(){throw new Error('this should be unreachable');};var i=null;var krad_add=J;export default function(j){return (j&&j.buffer)?Promise.resolve(j.buffer):M((j&&j.absPath)?j.absPath:new URL('krad-clang.wasm', import.meta.url)).then(j=>WebAssembly.instantiate(j,{i:{}})).then(j=>{__asm=j.instance.exports;__heap=__asm.N.buffer;i=__asm.i;krad_add=i;g();return{krad_add:krad_add,};});}function I(j){} \ No newline at end of file diff --git a/dist/krad-clang.wasm b/dist/krad-clang.wasm new file mode 100644 index 0000000000000000000000000000000000000000..70a7cc8c6d38544e1363b0479cb2c20cef36303c GIT binary patch literal 88 zcmWN{!3}^Q6a>+ok4S)cumf7L0c*gcCMG5p0Hs-)T<49B_eK)Qs#L8tnsX*5&jWZ5 fYiEu$w%$MG5RH8dr-%V<$|x=7G)RS_E06L6>`w`7 literal 0 HcmV?d00001 diff --git a/dist/krad-libs.wasm b/dist/krad-libs.wasm new file mode 100644 index 0000000000000000000000000000000000000000..dcfd77a99787925a25be85b1f4ce271c3d5d0bcd GIT binary patch literal 3707 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 literal 0 HcmV?d00001 diff --git a/dist/krad-utils.wasm b/dist/krad-utils.wasm new file mode 100644 index 0000000000000000000000000000000000000000..e3478bf7d1c9832117bac8e7ca8e104f098d09d9 GIT binary patch literal 115 zcmZQbEY4+QU|?Y6VoG4FXRJ?PV610mW@2DuWoBd$VB-XeIW{yLVBlsqVP?xs&CM^W zWMUA`E=o*^&n%8F2kMT`NKH&hEn;901dD=&%2JDpGxPI+rV24}Gbk`PHXJ(pn$3-y Jg~5@L8vwEC99RGV literal 0 HcmV?d00001 diff --git a/examples/add.cpp b/examples/add.cpp new file mode 100644 index 0000000..cfc45f4 --- /dev/null +++ b/examples/add.cpp @@ -0,0 +1,5 @@ +extern "C" { +[[cheerp::jsexport, cheerp::wasm]] int krad_add(int left, int right) { + return left + right; +} +} diff --git a/package.json b/package.json deleted file mode 100644 index af912ea..0000000 --- a/package.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "name": "krad", - "version": "0.0.0", - "private": true, - "type": "module", - "workspaces": [ - "packages/*" - ], - "bin": { - "krad": "./tools/krad.js" - }, - "scripts": { - "test": "node --test" - }, - "engines": { - "node": ">=22" - } -} diff --git a/packages/krad-clang/Cargo.toml b/packages/krad-clang/Cargo.toml new file mode 100644 index 0000000..2b2d045 --- /dev/null +++ b/packages/krad-clang/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "krad-clang" +version.workspace = true +edition.workspace = true +publish.workspace = true +rust-version.workspace = true + +[lib] +path = "src/lib.rs" diff --git a/packages/krad-clang/index.js b/packages/krad-clang/index.js deleted file mode 100644 index 3a35ae6..0000000 --- a/packages/krad-clang/index.js +++ /dev/null @@ -1,13 +0,0 @@ -import { defineComponent } from "../krad-utils/index.js"; - -export const component = defineComponent({ - name: "krad-clang", - source: { - directory: "cheerp-compiler", - focus: ["llvm/include/llvm/Cheerp", "llvm/lib/CheerpUtils", "llvm/lib/CheerpWriter", "clang/test/Cheerp"], - license: "Apache-2.0 WITH LLVM-exception", - licenseFile: "LICENSE.TXT", - repository: "https://github.com/leaningtech/cheerp-compiler", - revision: "0596bd128750d43569d17d56e443a95066d3461f", - }, -}); diff --git a/packages/krad-clang/package.json b/packages/krad-clang/package.json deleted file mode 100644 index dae9875..0000000 --- a/packages/krad-clang/package.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "krad-clang", - "version": "0.0.0", - "private": true, - "type": "module", - "exports": "./index.js" -} diff --git a/packages/krad-clang/src/lib.rs b/packages/krad-clang/src/lib.rs new file mode 100644 index 0000000..f55a605 --- /dev/null +++ b/packages/krad-clang/src/lib.rs @@ -0,0 +1,100 @@ +use std::ffi::OsString; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +pub const SOURCE_DIRECTORY: &str = "cheerp-compiler"; +pub const SOURCE_REPOSITORY: &str = "https://github.com/leaningtech/cheerp-compiler"; +pub const SOURCE_REVISION: &str = "0596bd128750d43569d17d56e443a95066d3461f"; +pub const SOURCE_LICENSE: &str = "Apache-2.0 WITH LLVM-exception"; +pub const SOURCE_LICENSE_FILE: &str = "LICENSE.TXT"; +pub const SOURCE_FOCUS: &[&str] = &[ + "llvm/include/llvm/Cheerp", + "llvm/lib/CheerpUtils", + "llvm/lib/CheerpWriter", + "clang/test/Cheerp", +]; +pub const BINARY_REVISION: &str = "29e4600563ed2153faf0221362ce4f0a5b93e1ce"; + +#[derive(Debug, Clone)] +pub struct Compiler { + executable: PathBuf, +} + +impl Compiler { + pub fn new(executable: impl Into) -> Result { + let executable = executable.into(); + if !executable.is_file() { + return Err(format!("compiler is not a file: {}", executable.display())); + } + Ok(Self { executable }) + } + + pub fn version(&self) -> Result { + let output = Command::new(&self.executable) + .arg("--version") + .output() + .map_err(|error| format!("could not execute {}: {error}", self.executable.display()))?; + if !output.status.success() { + return Err(format!("{} --version failed", self.executable.display())); + } + String::from_utf8(output.stdout).map_err(|_| "compiler version is not UTF-8".to_owned()) + } + + pub fn compile(&self, source: &Path, javascript: &Path, wasm: &Path) -> Result<(), String> { + if !source.is_file() { + return Err(format!("source is not a file: {}", source.display())); + } + if javascript == wasm { + return Err("JavaScript and WebAssembly outputs must differ".to_owned()); + } + for output in [javascript, wasm] { + if let Some(parent) = output.parent() { + fs::create_dir_all(parent) + .map_err(|error| format!("could not create {}: {error}", parent.display()))?; + } + if output.exists() { + fs::remove_file(output) + .map_err(|error| format!("could not replace {}: {error}", output.display()))?; + } + } + let mut secondary = OsString::from("-cheerp-secondary-output-file="); + secondary.push(wasm); + let status = Command::new(&self.executable) + .args(["-O3", "-target", "cheerp-wasm", "-cheerp-make-module=es6"]) + .arg(secondary) + .arg(source) + .arg("-o") + .arg(javascript) + .status() + .map_err(|error| format!("could not execute {}: {error}", self.executable.display()))?; + if !status.success() { + return Err(format!("Cheerp compilation failed with {status}")); + } + if !javascript.is_file() { + return Err("Cheerp did not create the JavaScript output".to_owned()); + } + let header = fs::read(wasm) + .map_err(|error| format!("could not read {}: {error}", wasm.display()))?; + if !valid_wasm(&header) { + return Err("Cheerp did not create a valid WebAssembly module".to_owned()); + } + Ok(()) + } +} + +pub fn valid_wasm(bytes: &[u8]) -> bool { + bytes.starts_with(b"\0asm\x01\0\0\0") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn validates_wasm_headers_and_compiler_paths() { + assert!(valid_wasm(b"\0asm\x01\0\0\0")); + assert!(!valid_wasm(b"not wasm")); + assert!(Compiler::new("/definitely/missing/krad-clang").is_err()); + } +} diff --git a/packages/krad-libs/Cargo.toml b/packages/krad-libs/Cargo.toml new file mode 100644 index 0000000..f693a7e --- /dev/null +++ b/packages/krad-libs/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "krad-libs" +version.workspace = true +edition.workspace = true +publish.workspace = true +rust-version.workspace = true + +[lib] +crate-type = ["cdylib", "rlib"] +path = "src/lib.rs" + +[dependencies] +krad-musl = { path = "../krad-musl" } diff --git a/packages/krad-libs/index.js b/packages/krad-libs/index.js deleted file mode 100644 index 8699c38..0000000 --- a/packages/krad-libs/index.js +++ /dev/null @@ -1,14 +0,0 @@ -import { defineComponent } from "../krad-utils/index.js"; - -export const component = defineComponent({ - name: "krad-libs", - dependencies: ["krad-clang", "krad-utils", "krad-musl"], - source: { - directory: "cheerp-libs", - focus: ["system", "stdlibs", "wasm"], - license: "Apache-2.0 WITH LLVM-exception", - licenseFile: "LICENSE.TXT", - repository: "https://github.com/leaningtech/cheerp-libs", - revision: "a578cfaa28bde51f1ae8b17980e6bf7727333be7", - }, -}); diff --git a/packages/krad-libs/package.json b/packages/krad-libs/package.json deleted file mode 100644 index 462ddd1..0000000 --- a/packages/krad-libs/package.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "krad-libs", - "version": "0.0.0", - "private": true, - "type": "module", - "exports": "./index.js" -} diff --git a/packages/krad-libs/src/lib.rs b/packages/krad-libs/src/lib.rs new file mode 100644 index 0000000..c94ea4f --- /dev/null +++ b/packages/krad-libs/src/lib.rs @@ -0,0 +1,59 @@ +#![cfg_attr(target_arch = "wasm32", no_std)] + +pub const SOURCE_DIRECTORY: &str = "cheerp-libs"; +pub const SOURCE_REPOSITORY: &str = "https://github.com/leaningtech/cheerp-libs"; +pub const SOURCE_REVISION: &str = "a578cfaa28bde51f1ae8b17980e6bf7727333be7"; +pub const SOURCE_LICENSE: &str = "Apache-2.0 WITH LLVM-exception"; +pub const SOURCE_LICENSE_FILE: &str = "LICENSE.TXT"; +pub const SOURCE_FOCUS: &[&str] = &["system", "stdlibs", "wasm"]; + +pub const ABI_LINUX: u32 = 0; +pub const ABI_FREEBSD: u32 = 1; +pub const ERROR_NO_ENTRY: u32 = 1; +pub const ERROR_NO_MEMORY: u32 = 2; +pub const ERROR_INVALID: u32 = 3; +pub const ERROR_NOT_IMPLEMENTED: u32 = 4; + +#[unsafe(no_mangle)] +pub extern "C" fn krad_errno(abi: u32, error: u32) -> u32 { + match (abi, error) { + (ABI_LINUX | ABI_FREEBSD, ERROR_NO_ENTRY) => 2, + (ABI_LINUX | ABI_FREEBSD, ERROR_NO_MEMORY) => 12, + (ABI_LINUX | ABI_FREEBSD, ERROR_INVALID) => 22, + (ABI_LINUX, ERROR_NOT_IMPLEMENTED) => 38, + (ABI_FREEBSD, ERROR_NOT_IMPLEMENTED) => 78, + _ => 0, + } +} + +#[unsafe(no_mangle)] +pub extern "C" fn krad_page_align(value: u32) -> u32 { + value.checked_add(4095).map_or(0, |value| value & !4095) +} + +/// Copies guest bytes through the `krad-musl` memory primitive. +/// +/// # Safety +/// `destination` and `source` must be valid for `length` non-overlapping bytes. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn krad_copy( + destination: *mut u8, + source: *const u8, + length: usize, +) -> *mut u8 { + unsafe { krad_musl::krad_memcpy(destination, source, length) } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn maps_abi_errors_and_pages() { + assert_eq!(krad_errno(ABI_LINUX, ERROR_NOT_IMPLEMENTED), 38); + assert_eq!(krad_errno(ABI_FREEBSD, ERROR_NOT_IMPLEMENTED), 78); + assert_eq!(krad_errno(99, ERROR_INVALID), 0); + assert_eq!(krad_page_align(4097), 8192); + assert_eq!(krad_page_align(u32::MAX), 0); + } +} diff --git a/packages/krad-musl/Cargo.toml b/packages/krad-musl/Cargo.toml new file mode 100644 index 0000000..5768acb --- /dev/null +++ b/packages/krad-musl/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "krad-musl" +version.workspace = true +edition.workspace = true +publish.workspace = true +rust-version.workspace = true + +[lib] +crate-type = ["cdylib", "rlib"] +path = "src/lib.rs" diff --git a/packages/krad-musl/index.js b/packages/krad-musl/index.js deleted file mode 100644 index f0a562b..0000000 --- a/packages/krad-musl/index.js +++ /dev/null @@ -1,14 +0,0 @@ -import { defineComponent } from "../krad-utils/index.js"; - -export const component = defineComponent({ - name: "krad-musl", - dependencies: ["krad-clang", "krad-utils"], - source: { - directory: "cheerp-musl", - focus: ["arch", "include", "src", "tools"], - license: "MIT", - licenseFile: "COPYRIGHT", - repository: "https://github.com/leaningtech/cheerp-musl", - revision: "dd07ec8e7c52f8dc4351bf9b329df3b1d190470a", - }, -}); diff --git a/packages/krad-musl/package.json b/packages/krad-musl/package.json deleted file mode 100644 index 81448ed..0000000 --- a/packages/krad-musl/package.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "krad-musl", - "version": "0.0.0", - "private": true, - "type": "module", - "exports": "./index.js" -} diff --git a/packages/krad-musl/src/lib.rs b/packages/krad-musl/src/lib.rs new file mode 100644 index 0000000..a936005 --- /dev/null +++ b/packages/krad-musl/src/lib.rs @@ -0,0 +1,104 @@ +#![cfg_attr(target_arch = "wasm32", no_std)] + +pub const SOURCE_DIRECTORY: &str = "cheerp-musl"; +pub const SOURCE_REPOSITORY: &str = "https://github.com/leaningtech/cheerp-musl"; +pub const SOURCE_REVISION: &str = "dd07ec8e7c52f8dc4351bf9b329df3b1d190470a"; +pub const SOURCE_LICENSE: &str = "MIT"; +pub const SOURCE_LICENSE_FILE: &str = "COPYRIGHT"; +pub const SOURCE_FOCUS: &[&str] = &["arch", "include", "src", "tools"]; + +/// Copies `length` non-overlapping bytes. +/// +/// # Safety +/// `destination` and `source` must be valid for `length` bytes and must not overlap. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn krad_memcpy( + destination: *mut u8, + source: *const u8, + length: usize, +) -> *mut u8 { + unsafe { core::ptr::copy_nonoverlapping(source, destination, length) }; + destination +} + +/// Copies `length` bytes, including overlapping ranges. +/// +/// # Safety +/// `destination` and `source` must be valid for `length` bytes. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn krad_memmove( + destination: *mut u8, + source: *const u8, + length: usize, +) -> *mut u8 { + unsafe { core::ptr::copy(source, destination, length) }; + destination +} + +/// Fills `length` bytes with `value`. +/// +/// # Safety +/// `destination` must be valid for writes of `length` bytes. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn krad_memset(destination: *mut u8, value: u8, length: usize) -> *mut u8 { + unsafe { core::ptr::write_bytes(destination, value, length) }; + destination +} + +/// Compares two byte ranges. +/// +/// # Safety +/// `left` and `right` must be valid for reads of `length` bytes. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn krad_memcmp(left: *const u8, right: *const u8, length: usize) -> i32 { + for index in 0..length { + let left = unsafe { *left.add(index) }; + let right = unsafe { *right.add(index) }; + if left != right { + return i32::from(left) - i32::from(right); + } + } + 0 +} + +/// Returns the length of a NUL-terminated byte string. +/// +/// # Safety +/// `value` must point to a readable NUL-terminated byte string. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn krad_strlen(value: *const u8) -> usize { + let mut length = 0; + while unsafe { *value.add(length) } != 0 { + length += 1; + } + length +} + +#[cfg(all(target_arch = "wasm32", not(test)))] +#[panic_handler] +fn panic(_: &core::panic::PanicInfo<'_>) -> ! { + loop {} +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn implements_the_bounded_memory_subset() { + let source = *b"krad\0"; + let mut output = [0_u8; 5]; + unsafe { + krad_memcpy(output.as_mut_ptr(), source.as_ptr(), source.len()); + assert_eq!(krad_strlen(output.as_ptr()), 4); + assert_eq!( + krad_memcmp(output.as_ptr(), source.as_ptr(), source.len()), + 0 + ); + krad_memmove(output.as_mut_ptr().add(1), output.as_ptr(), 4); + assert_eq!(&output, b"kkrad"); + krad_memset(output.as_mut_ptr(), b'x', 2); + } + assert_eq!(&output, b"xxrad"); + } +} diff --git a/packages/krad-utils/Cargo.toml b/packages/krad-utils/Cargo.toml new file mode 100644 index 0000000..bc3aecc --- /dev/null +++ b/packages/krad-utils/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "krad-utils" +version.workspace = true +edition.workspace = true +publish.workspace = true +rust-version.workspace = true + +[lib] +crate-type = ["cdylib", "rlib"] +path = "src/lib.rs" + +[[bin]] +name = "krad" +path = "src/main.rs" + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +krad-clang = { path = "../krad-clang" } +krad-libs = { path = "../krad-libs" } +krad-musl = { path = "../krad-musl" } diff --git a/packages/krad-utils/index.js b/packages/krad-utils/index.js deleted file mode 100644 index d6e49b6..0000000 --- a/packages/krad-utils/index.js +++ /dev/null @@ -1,76 +0,0 @@ -const namePattern = /^krad-[a-z][a-z0-9-]*$/; -const revisionPattern = /^[a-f0-9]{40}$/; -const pathPattern = /^[A-Za-z0-9._+-]+(?:\/[A-Za-z0-9._+-]+)*$/; - -const path = (value, field) => { - if (typeof value !== "string" || !pathPattern.test(value) || value.split("/").some((part) => part === "." || part === "..")) { - throw new TypeError(`${field} must be a relative path`); - } - return value; -}; - -export const defineComponent = ({ name, dependencies = [], source }) => { - if (!namePattern.test(name)) throw new TypeError("invalid component name"); - if (!Array.isArray(dependencies) || dependencies.some((dependency) => !namePattern.test(dependency))) { - throw new TypeError(`${name} has invalid dependencies`); - } - if (new Set(dependencies).size !== dependencies.length || dependencies.includes(name)) { - throw new TypeError(`${name} has duplicate or self dependencies`); - } - if (!source || typeof source !== "object") throw new TypeError(`${name} requires a source`); - const repository = new URL(source.repository); - if (repository.protocol !== "https:") throw new TypeError(`${name} repository must use HTTPS`); - if (!revisionPattern.test(source.revision)) throw new TypeError(`${name} requires a full Git revision`); - if (typeof source.license !== "string" || !source.license) throw new TypeError(`${name} requires a source license`); - if (!Array.isArray(source.focus ?? [])) throw new TypeError(`${name} focus must be an array`); - return Object.freeze({ - name, - dependencies: Object.freeze([...dependencies]), - source: Object.freeze({ - directory: path(source.directory, `${name} source directory`), - focus: Object.freeze((source.focus ?? []).map((entry) => path(entry, `${name} focus`))), - license: source.license, - licenseFile: path(source.licenseFile, `${name} license file`), - repository: repository.href.replace(/\/$/, ""), - revision: source.revision, - }), - }); -}; - -export const orderComponents = (components) => { - if (!Array.isArray(components)) throw new TypeError("components must be an array"); - const byName = new Map(); - for (const component of components) { - if (!component?.name || byName.has(component.name)) throw new TypeError("component names must be unique"); - byName.set(component.name, component); - } - const state = new Map(); - const ordered = []; - const visit = (component, chain = []) => { - if (state.get(component.name) === 2) return; - if (state.get(component.name) === 1) throw new Error(`component cycle: ${[...chain, component.name].join(" -> ")}`); - state.set(component.name, 1); - for (const dependency of component.dependencies) { - const required = byName.get(dependency); - if (!required) throw new Error(`${component.name} requires missing ${dependency}`); - visit(required, [...chain, component.name]); - } - state.set(component.name, 2); - ordered.push(component); - }; - for (const component of components) visit(component); - return Object.freeze(ordered); -}; - -export const component = defineComponent({ - name: "krad-utils", - dependencies: ["krad-clang"], - source: { - directory: "cheerp-utils", - focus: ["scripts", "tools"], - license: "Apache-2.0 WITH LLVM-exception", - licenseFile: "LICENSE.TXT", - repository: "https://github.com/leaningtech/cheerp-utils", - revision: "4b95822b860d215d669374be98c1bc4425040e08", - }, -}); diff --git a/packages/krad-utils/package.json b/packages/krad-utils/package.json deleted file mode 100644 index d94fd4c..0000000 --- a/packages/krad-utils/package.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "krad-utils", - "version": "0.0.0", - "private": true, - "type": "module", - "exports": "./index.js" -} diff --git a/packages/krad-utils/src/lib.rs b/packages/krad-utils/src/lib.rs new file mode 100644 index 0000000..487e8fb --- /dev/null +++ b/packages/krad-utils/src/lib.rs @@ -0,0 +1,36 @@ +#![cfg_attr(target_arch = "wasm32", no_std)] + +pub const SOURCE_DIRECTORY: &str = "cheerp-utils"; +pub const SOURCE_REPOSITORY: &str = "https://github.com/leaningtech/cheerp-utils"; +pub const SOURCE_REVISION: &str = "4b95822b860d215d669374be98c1bc4425040e08"; +pub const SOURCE_LICENSE: &str = "Apache-2.0 WITH LLVM-exception"; +pub const SOURCE_LICENSE_FILE: &str = "LICENSE.TXT"; +pub const SOURCE_FOCUS: &[&str] = &["scripts", "tools"]; + +#[unsafe(no_mangle)] +pub extern "C" fn krad_wasm_version() -> u32 { + 1 +} + +#[unsafe(no_mangle)] +pub extern "C" fn krad_is_wasm_header(word: u32) -> u32 { + u32::from(word == 0x6d73_6100) +} + +#[cfg(all(target_arch = "wasm32", not(test)))] +#[panic_handler] +fn panic(_: &core::panic::PanicInfo<'_>) -> ! { + loop {} +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn identifies_the_binary_contract() { + assert_eq!(krad_wasm_version(), 1); + assert_eq!(krad_is_wasm_header(0x6d73_6100), 1); + assert_eq!(krad_is_wasm_header(0), 0); + } +} diff --git a/packages/krad-utils/src/main.rs b/packages/krad-utils/src/main.rs new file mode 100644 index 0000000..b1088fd --- /dev/null +++ b/packages/krad-utils/src/main.rs @@ -0,0 +1,415 @@ +use krad_clang::{BINARY_REVISION, Compiler}; +use std::env; +use std::ffi::{OsStr, OsString}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +const META_REVISION: &str = "cb562107a4429c8f55e1f35be25ad4debd5c4385"; +const DARWIN_ARCHIVE_SHA256: &str = + "9a98ebf6edfdc9c14d6b74af57eff35a938fbf1f220bb11c8f3cc2fa0677e7f8"; +const LINUX_ARCHIVE_SHA256: &str = + "a2cbead539dbb9f0a02c87508d5aea97a7056f0094b8182cfdddba8369be1ec8"; +const USAGE: &str = "Usage: krad [paths] + +Commands: + verify [research] Verify pinned source repositories and focused paths + build [research] [out] [clang++] Build Rust Wasm libraries and a Cheerp C++ module + help Show this help + +Defaults: research=../research, out=dist. Without clang++, build extracts the +pinned local Cheerp 3.0 archive with brotli and tar; it never downloads inputs."; + +#[derive(Clone, Copy)] +struct Source { + name: &'static str, + directory: &'static str, + repository: &'static str, + revision: &'static str, + license: &'static str, + license_file: &'static str, + focus: &'static [&'static str], +} + +const SOURCES: &[Source] = &[ + Source { + name: "krad-clang", + directory: krad_clang::SOURCE_DIRECTORY, + repository: krad_clang::SOURCE_REPOSITORY, + revision: krad_clang::SOURCE_REVISION, + license: krad_clang::SOURCE_LICENSE, + license_file: krad_clang::SOURCE_LICENSE_FILE, + focus: krad_clang::SOURCE_FOCUS, + }, + Source { + name: "krad-utils", + directory: krad_utils::SOURCE_DIRECTORY, + repository: krad_utils::SOURCE_REPOSITORY, + revision: krad_utils::SOURCE_REVISION, + license: krad_utils::SOURCE_LICENSE, + license_file: krad_utils::SOURCE_LICENSE_FILE, + focus: krad_utils::SOURCE_FOCUS, + }, + Source { + name: "krad-musl", + directory: krad_musl::SOURCE_DIRECTORY, + repository: krad_musl::SOURCE_REPOSITORY, + revision: krad_musl::SOURCE_REVISION, + license: krad_musl::SOURCE_LICENSE, + license_file: krad_musl::SOURCE_LICENSE_FILE, + focus: krad_musl::SOURCE_FOCUS, + }, + Source { + name: "krad-libs", + directory: krad_libs::SOURCE_DIRECTORY, + repository: krad_libs::SOURCE_REPOSITORY, + revision: krad_libs::SOURCE_REVISION, + license: krad_libs::SOURCE_LICENSE, + license_file: krad_libs::SOURCE_LICENSE_FILE, + focus: krad_libs::SOURCE_FOCUS, + }, + Source { + name: "cheerp-meta", + directory: "cheerp-meta", + repository: "https://github.com/leaningtech/cheerp-meta", + revision: META_REVISION, + license: "Apache-2.0 WITH LLVM-exception", + license_file: "LICENSE", + focus: &[ + "packages/cheerp-darwin-x64/cheerp.tar.br", + "packages/cheerp-linux-x64/cheerp.tar.br", + "packages/cheerp-win32-x64/cheerp.tar.br", + ], + }, +]; + +fn git(directory: &Path, arguments: &[&str]) -> Result { + let output = Command::new("git") + .arg("-C") + .arg(directory) + .args(arguments) + .output() + .map_err(|error| format!("could not run git: {error}"))?; + if !output.status.success() { + return Err(format!( + "{}: git {} failed", + directory.display(), + arguments.join(" ") + )); + } + String::from_utf8(output.stdout) + .map(|value| value.trim().to_owned()) + .map_err(|_| "git output is not UTF-8".to_owned()) +} + +fn canonical(repository: &str) -> &str { + repository + .trim() + .trim_end_matches('/') + .trim_end_matches(".git") +} + +fn verify(root: &Path) -> Result<(), String> { + for source in SOURCES { + let directory = root.join(source.directory); + if !directory.is_dir() { + return Err(format!("{}: missing {}", source.name, directory.display())); + } + let revision = git(&directory, &["rev-parse", "HEAD"])?; + if revision != source.revision { + return Err(format!( + "{}: expected {}, found {revision}", + source.name, source.revision + )); + } + let repository = git(&directory, &["remote", "get-url", "origin"])?; + if canonical(&repository) != canonical(source.repository) { + return Err(format!( + "{}: expected {}, found {repository}", + source.name, source.repository + )); + } + for required in [source.license_file] + .into_iter() + .chain(source.focus.iter().copied()) + { + if !directory.join(required).exists() { + return Err(format!("{}: missing {required}", source.name)); + } + } + println!("ok {} {} {}", source.name, source.revision, source.license); + } + Ok(()) +} + +fn run(command: &mut Command, description: &str) -> Result<(), String> { + let status = command + .status() + .map_err(|error| format!("could not run {description}: {error}"))?; + if status.success() { + Ok(()) + } else { + Err(format!("{description} failed with {status}")) + } +} + +fn sha256(path: &Path) -> Result { + for (program, arguments) in [("shasum", &["-a", "256"][..]), ("sha256sum", &[][..])] { + let output = match Command::new(program).args(arguments).arg(path).output() { + Ok(output) => output, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, + Err(error) => return Err(format!("could not run {program}: {error}")), + }; + if !output.status.success() { + continue; + } + let output = String::from_utf8(output.stdout) + .map_err(|_| format!("{program} output is not UTF-8"))?; + let digest = output.split_whitespace().next().unwrap_or_default(); + if digest.len() == 64 && digest.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Ok(digest.to_ascii_lowercase()); + } + } + Err("sha256 requires shasum or sha256sum".to_owned()) +} + +fn archive(root: &Path) -> Result<(PathBuf, &'static str), String> { + let (package, digest) = match (env::consts::OS, env::consts::ARCH) { + ("macos", "aarch64" | "x86_64") => ("cheerp-darwin-x64", DARWIN_ARCHIVE_SHA256), + ("linux", "x86_64") => ("cheerp-linux-x64", LINUX_ARCHIVE_SHA256), + (os, arch) => { + return Err(format!( + "no pinned Cheerp binary for {os}/{arch}; pass clang++ explicitly" + )); + } + }; + Ok(( + root.join("cheerp-meta/packages") + .join(package) + .join("cheerp.tar.br"), + digest, + )) +} + +fn extract_compiler(root: &Path, workspace: &Path) -> Result { + let (archive, expected) = archive(root)?; + let actual = sha256(&archive)?; + if actual != expected { + return Err(format!( + "{}: expected SHA-256 {expected}, found {actual}", + archive.display() + )); + } + let installation = workspace.join("target/krad-cheerp-3.0.0-rc.5"); + let compiler = installation.join("cheerp/bin/clang++"); + if compiler.is_file() { + return Ok(compiler); + } + if installation.exists() { + return Err(format!( + "incomplete compiler directory: {}; remove it and retry", + installation.display() + )); + } + let staging = workspace + .join("target") + .join(format!(".krad-cheerp-{}", std::process::id())); + fs::create_dir_all(&staging) + .map_err(|error| format!("could not create {}: {error}", staging.display()))?; + let result = (|| { + let mut decompressor = Command::new("brotli") + .args(["-d", "-c"]) + .arg(&archive) + .stdout(Stdio::piped()) + .spawn() + .map_err(|error| format!("could not run brotli: {error}"))?; + let input = decompressor + .stdout + .take() + .ok_or_else(|| "could not read brotli output".to_owned())?; + let unpacked = Command::new("tar") + .args(["-xf", "-", "-C"]) + .arg(&staging) + .stdin(input) + .status() + .map_err(|error| format!("could not run tar: {error}"))?; + let decompressed = decompressor + .wait() + .map_err(|error| format!("could not wait for brotli: {error}"))?; + if !decompressed.success() || !unpacked.success() { + return Err("Cheerp archive extraction failed".to_owned()); + } + if !staging.join("cheerp/bin/clang++").is_file() { + return Err("Cheerp archive does not contain bin/clang++".to_owned()); + } + fs::rename(&staging, &installation) + .map_err(|error| format!("could not install {}: {error}", installation.display())) + })(); + if result.is_err() { + let _ = fs::remove_dir_all(&staging); + } + result?; + Ok(compiler) +} + +fn cargo(workspace: &Path, arguments: &[&str], description: &str) -> Result<(), String> { + run( + Command::new("cargo").current_dir(workspace).args(arguments), + description, + ) +} + +fn copy_artifact(source: &Path, destination: &Path) -> Result { + let bytes = fs::read(source) + .map_err(|error| format!("could not read {}: {error}", source.display()))?; + if !krad_clang::valid_wasm(&bytes) { + return Err(format!("not a WebAssembly module: {}", source.display())); + } + fs::write(destination, &bytes) + .map_err(|error| format!("could not write {}: {error}", destination.display()))?; + Ok(bytes.len() as u64) +} + +fn build(root: &Path, out: &Path, explicit_compiler: Option<&OsStr>) -> Result<(), String> { + verify(root)?; + let workspace = Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .and_then(Path::parent) + .ok_or_else(|| "could not find workspace root".to_owned())?; + cargo( + workspace, + &["build", "--workspace", "--release"], + "host release build", + )?; + cargo( + workspace, + &[ + "build", + "--release", + "--target", + "wasm32v1-none", + "-p", + "krad-musl", + "-p", + "krad-libs", + "-p", + "krad-utils", + "--lib", + ], + "WebAssembly release build", + )?; + let compiler_path = explicit_compiler + .map(PathBuf::from) + .map_or_else(|| extract_compiler(root, workspace), Ok)?; + let compiler = Compiler::new(&compiler_path)?; + let version = compiler.version()?; + if !version.contains("Cheerp 3.0") || !version.contains(BINARY_REVISION) { + return Err(format!( + "unexpected compiler version: {}", + version.lines().next().unwrap_or_default() + )); + } + fs::create_dir_all(out) + .map_err(|error| format!("could not create {}: {error}", out.display()))?; + let staging = workspace + .join("target") + .join(format!("krad-output-{}", std::process::id())); + fs::create_dir_all(&staging) + .map_err(|error| format!("could not create {}: {error}", staging.display()))?; + let result = (|| { + compiler.compile( + &workspace.join("examples/add.cpp"), + &staging.join("krad-clang.mjs"), + &staging.join("krad-clang.wasm"), + )?; + let wasm_root = workspace.join("target/wasm32v1-none/release"); + for (source, name) in [ + (wasm_root.join("krad_musl.wasm"), "krad-musl.wasm"), + (wasm_root.join("krad_libs.wasm"), "krad-libs.wasm"), + (wasm_root.join("krad_utils.wasm"), "krad-utils.wasm"), + (staging.join("krad-clang.wasm"), "krad-clang.wasm"), + ] { + let size = copy_artifact(&source, &out.join(name))?; + println!("built {name} {size} bytes"); + } + let javascript = fs::read(staging.join("krad-clang.mjs")) + .map_err(|error| format!("could not read compiler output: {error}"))?; + fs::write(out.join("krad-clang.mjs"), &javascript) + .map_err(|error| format!("could not write compiler output: {error}"))?; + println!("built krad-clang.mjs {} bytes", javascript.len()); + Ok(()) + })(); + let _ = fs::remove_dir_all(staging); + result +} + +fn paths(values: Vec, maximum: usize, command: &str) -> Result, String> { + if values.len() > maximum { + return Err(format!( + "{command} accepts at most {maximum} path arguments" + )); + } + Ok(values.into_iter().map(PathBuf::from).collect()) +} + +fn execute() -> Result<(), String> { + let mut arguments = env::args_os().skip(1); + let command = arguments.next().unwrap_or_else(|| OsString::from("help")); + let command = command + .to_str() + .ok_or_else(|| "command must be UTF-8".to_owned())?; + let values = arguments.collect::>(); + match command { + "help" | "-h" | "--help" => { + if !values.is_empty() { + return Err("help takes no arguments".to_owned()); + } + println!("{USAGE}"); + Ok(()) + } + "verify" => { + let paths = paths(values, 1, "verify")?; + verify( + paths + .first() + .map_or_else(|| Path::new("../research"), PathBuf::as_path), + ) + } + "build" => { + let paths = paths(values, 3, "build")?; + let root = paths + .first() + .map_or_else(|| Path::new("../research"), PathBuf::as_path); + let out = paths + .get(1) + .map_or_else(|| Path::new("dist"), PathBuf::as_path); + build(root, out, paths.get(2).map(|path| path.as_os_str())) + } + _ => Err(format!("unknown command: {command}")), + } +} + +fn main() { + if let Err(error) = execute() { + eprintln!("krad: {error}"); + std::process::exit(1); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalizes_repository_urls() { + assert_eq!( + canonical("https://example.test/repo.git/"), + "https://example.test/repo" + ); + } + + #[test] + fn rejects_excess_paths() { + assert!(paths(vec!["a".into(), "b".into()], 1, "verify").is_err()); + } +} diff --git a/packages/krad-utils/tests/cli.rs b/packages/krad-utils/tests/cli.rs new file mode 100644 index 0000000..db612cb --- /dev/null +++ b/packages/krad-utils/tests/cli.rs @@ -0,0 +1,23 @@ +use std::process::Command; + +#[test] +fn reports_help_and_rejects_invalid_commands() { + let binary = env!("CARGO_BIN_EXE_krad"); + let help = Command::new(binary).arg("--help").output().unwrap(); + assert!(help.status.success()); + assert!(String::from_utf8_lossy(&help.stdout).contains("Usage: krad")); + + let invalid = Command::new(binary).arg("unknown").output().unwrap(); + assert!(!invalid.status.success()); + assert_eq!( + String::from_utf8_lossy(&invalid.stderr), + "krad: unknown command: unknown\n" + ); + + let missing = Command::new(binary) + .args(["verify", "/definitely/missing/krad-research"]) + .output() + .unwrap(); + assert!(!missing.status.success()); + assert!(String::from_utf8_lossy(&missing.stderr).contains("krad-clang: missing")); +} diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..3f9105e --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,5 @@ +[toolchain] +channel = "1.97.1" +components = ["clippy", "rustfmt"] +profile = "minimal" +targets = ["wasm32v1-none"] diff --git a/test/krad.test.js b/test/krad.test.js deleted file mode 100644 index f254275..0000000 --- a/test/krad.test.js +++ /dev/null @@ -1,70 +0,0 @@ -import assert from "node:assert/strict"; -import { spawnSync } from "node:child_process"; -import { test } from "node:test"; -import { fileURLToPath } from "node:url"; -import { component as clang } from "../packages/krad-clang/index.js"; -import { component as libs } from "../packages/krad-libs/index.js"; -import { component as musl } from "../packages/krad-musl/index.js"; -import { component as utils, defineComponent, orderComponents } from "../packages/krad-utils/index.js"; - -const root = fileURLToPath(new URL("../", import.meta.url)); - -test("orders the pinned package graph", () => { - assert.deepEqual( - orderComponents([libs, musl, utils, clang]).map(({ name }) => name), - ["krad-clang", "krad-utils", "krad-musl", "krad-libs"], - ); - assert.throws( - () => - defineComponent({ - name: "krad-bad", - source: { - directory: "bad", - license: "MIT", - licenseFile: "LICENSE", - repository: "https://example.com/bad", - revision: "short", - }, - }), - /full Git revision/, - ); - assert.throws(() => orderComponents([{ name: "krad-a", dependencies: ["krad-missing"] }]), /missing/); - assert.throws( - () => - orderComponents([ - { name: "krad-a", dependencies: ["krad-b"] }, - { name: "krad-b", dependencies: ["krad-a"] }, - ]), - /component cycle/, - ); -}); - -test("prints a machine-readable plan and actionable verification failure", () => { - const plan = spawnSync(process.execPath, ["tools/krad.js", "plan", "--json"], { cwd: root, encoding: "utf8" }); - assert.equal(plan.status, 0, plan.stderr); - assert.deepEqual(JSON.parse(plan.stdout).map(({ name }) => name), [ - "krad-clang", - "krad-utils", - "krad-musl", - "krad-libs", - ]); - const failure = spawnSync(process.execPath, ["tools/krad.js", "verify", "/missing/krad-sources"], { - cwd: root, - encoding: "utf8", - }); - assert.equal(failure.status, 1); - assert.match(failure.stderr, /krad-clang: missing/); -}); - -test("rejects invalid CLI input", () => { - for (const [args, message] of [ - [["unknown"], /unknown command/], - [["plan", "extra"], /plan takes no arguments/], - [["verify", "one", "two"], /at most one source root/], - [["plan", "--unknown"], /unknown option/], - ]) { - const result = spawnSync(process.execPath, ["tools/krad.js", ...args], { cwd: root, encoding: "utf8" }); - assert.equal(result.status, 1); - assert.match(result.stderr, message); - } -}); diff --git a/tools/krad.js b/tools/krad.js deleted file mode 100755 index 2890f80..0000000 --- a/tools/krad.js +++ /dev/null @@ -1,83 +0,0 @@ -#!/usr/bin/env node -import { execFileSync } from "node:child_process"; -import { existsSync, statSync } from "node:fs"; -import { resolve } from "node:path"; -import { component as clang } from "../packages/krad-clang/index.js"; -import { component as libs } from "../packages/krad-libs/index.js"; -import { component as musl } from "../packages/krad-musl/index.js"; -import { component as utils, orderComponents } from "../packages/krad-utils/index.js"; - -const components = orderComponents([libs, musl, utils, clang]); -const canonical = (value) => value.trim().replace(/\/+$/, "").replace(/\.git$/, ""); -const git = (directory, ...args) => { - try { - return execFileSync("git", ["-C", directory, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim(); - } catch (error) { - const detail = error.stderr?.toString().trim(); - throw new Error(`${directory}: git ${args.join(" ")} failed${detail ? `: ${detail}` : ""}`); - } -}; - -const verify = (root) => - components.map((component) => { - const directory = resolve(root, component.source.directory); - if (!existsSync(directory) || !statSync(directory).isDirectory()) throw new Error(`${component.name}: missing ${directory}`); - const revision = git(directory, "rev-parse", "HEAD"); - if (revision !== component.source.revision) { - throw new Error(`${component.name}: expected ${component.source.revision}, found ${revision}`); - } - const repository = canonical(git(directory, "remote", "get-url", "origin")); - if (repository !== canonical(component.source.repository)) { - throw new Error(`${component.name}: expected ${component.source.repository}, found ${repository}`); - } - for (const required of [component.source.licenseFile, ...component.source.focus]) { - if (!existsSync(resolve(directory, required))) throw new Error(`${component.name}: missing ${required}`); - } - return { - name: component.name, - directory, - license: component.source.license, - repository: component.source.repository, - revision, - }; - }); - -const usage = `Usage: krad [options] - -Commands: - plan [--json] Print the component dependency order - verify [root] [--json] Verify source origins, revisions, licenses, and focused paths - help Show this help - -The default verify root is ../research relative to the current directory.`; - -const args = process.argv.slice(2); -const command = args.shift() ?? "help"; -const json = args.includes("--json"); -const values = args.filter((argument) => argument !== "--json"); - -try { - if (args.some((argument) => argument.startsWith("-") && argument !== "--json")) throw new Error("unknown option"); - if (command === "help" || command === "--help" || command === "-h") { - if (values.length) throw new Error("help takes no arguments"); - console.log(usage); - } else if (command === "plan") { - if (values.length) throw new Error("plan takes no arguments"); - const plan = components.map(({ name, dependencies, source }) => ({ - name, - dependencies, - source: source.directory, - revision: source.revision, - })); - console.log(json ? JSON.stringify(plan, null, 2) : plan.map((entry, index) => `${index + 1}. ${entry.name}`).join("\n")); - } else if (command === "verify") { - if (values.length > 1) throw new Error("verify accepts at most one source root"); - const result = verify(resolve(values[0] ?? "../research")); - console.log(json ? JSON.stringify(result, null, 2) : result.map((entry) => `ok ${entry.name} ${entry.revision}`).join("\n")); - } else { - throw new Error(`unknown command: ${command}`); - } -} catch (error) { - console.error(`krad: ${error.message}`); - process.exitCode = 1; -}