diff --git a/.github/scripts/check-language-parity.sh b/.github/scripts/check-language-parity.sh index e2db50a4..88f1e0a9 100755 --- a/.github/scripts/check-language-parity.sh +++ b/.github/scripts/check-language-parity.sh @@ -2,11 +2,10 @@ # # Language parity check. # -# command-stream ships two implementations that must stay in lock-step: the -# JavaScript library under js/src/** and the Rust library under rust/src/**. -# This script fails when a pull request changes one language's source without -# touching the other's, so that behavioral changes are always made in both -# languages (see issue #155 review feedback). +# command-stream ships two implementations that must stay in lock-step. Source +# changes and benchmark changes are checked independently, so a token benchmark +# edit cannot satisfy a behavioral source change (or vice versa). This keeps +# both the implementation and its measured claims available in both languages. # # Escape hatch: add the `parity-exempt` label to the PR for changes that are # legitimately single-language (the workflow skips this check when the label is @@ -41,35 +40,62 @@ echo "Comparing against ${BASE} (merge-base ${MERGE_BASE})" echo "Changed files:" echo "${CHANGED}" | sed 's/^/ /' -js_changed=false -rust_changed=false +js_source_changed=false +rust_source_changed=false +js_benchmarks_changed=false +rust_benchmarks_changed=false while IFS= read -r f; do [ -z "${f}" ] && continue case "${f}" in - js/src/*) js_changed=true ;; - rust/src/*) rust_changed=true ;; + js/src/*) js_source_changed=true ;; + rust/src/*) rust_source_changed=true ;; + js/benchmarks/* | js/tests/benchmark-*) js_benchmarks_changed=true ;; + rust/benchmarks/*) rust_benchmarks_changed=true ;; esac done <> "$GITHUB_OUTPUT" + echo 'The base branch predates the benchmark suite; no comparison is available yet.' + exit 0 + fi + + base_directory="$(mktemp -d)" + cleanup() { + git worktree remove --force "$base_directory" || true + } + trap cleanup EXIT + git worktree add --detach "$base_directory" "origin/$BASE_REF" + ( + cd "$base_directory/js" + bun install --frozen-lockfile + bun benchmarks/cli.mjs --smoke --output "$GITHUB_WORKSPACE/js/benchmarks/baseline" + ) + echo 'available=true' >> "$GITHUB_OUTPUT" + + - name: Run benchmark profile + working-directory: js + env: + BENCHMARK_PROFILE: ${{ github.event_name == 'pull_request' && 'smoke' || inputs.profile || 'full' }} + run: | + if [[ "$BENCHMARK_PROFILE" == 'smoke' ]]; then + bun run benchmark:smoke + else + bun run benchmark + fi + + - name: Compare base and pull request measurements + if: steps.baseline.outputs.available == 'true' + working-directory: js + run: | + bun benchmarks/compare-results.mjs \ + benchmarks/baseline/benchmark-results.json \ + benchmarks/results/benchmark-results.json \ + benchmarks/results + + - name: Upload JSON and HTML reports + uses: actions/upload-artifact@v7 + with: + name: command-stream-javascript-benchmarks-${{ github.run_id }}-${{ github.run_attempt }} + path: | + js/benchmarks/baseline/benchmark-results.json + js/benchmarks/results/benchmark-results.json + js/benchmarks/results/benchmark-report.html + js/benchmarks/results/benchmark-regressions.json + js/benchmarks/results/benchmark-regressions.md + if-no-files-found: error + retention-days: 30 + + rust: + name: Rust (${{ github.event_name == 'pull_request' && 'smoke' || inputs.profile || 'full' }}) + runs-on: ubuntu-latest + timeout-minutes: 30 + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-rust + cancel-in-progress: true + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Simulate a fresh merge with the base branch + if: github.event_name == 'pull_request' + shell: bash + env: + BASE_REF: ${{ github.base_ref }} + run: bash .github/scripts/simulate-fresh-merge.sh + + - name: Setup Rust + uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable branch @ 2026-09-03 + with: + components: rustfmt, clippy + + - name: Cache Cargo dependencies + uses: actions/cache@v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + rust/benchmarks/target + key: ${{ runner.os }}-rust-benchmarks-${{ hashFiles('rust/benchmarks/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-rust-benchmarks- + + - name: Test and lint benchmark infrastructure + working-directory: rust + run: | + cargo fmt --manifest-path benchmarks/Cargo.toml -- --check + cargo clippy --manifest-path benchmarks/Cargo.toml --locked --all-targets -- -D warnings + cargo test --manifest-path benchmarks/Cargo.toml --locked --all-targets + + - name: Benchmark the pull request base + if: github.event_name == 'pull_request' + id: rust-baseline + shell: bash + env: + BASE_REF: ${{ github.base_ref }} + run: | + set -euo pipefail + if ! git cat-file -e "origin/$BASE_REF:rust/benchmarks/Cargo.toml"; then + echo 'available=false' >> "$GITHUB_OUTPUT" + echo 'The base branch predates the Rust benchmark suite; no comparison is available yet.' + exit 0 + fi + + base_directory="$(mktemp -d)" + cleanup() { + git worktree remove --force "$base_directory" || true + } + trap cleanup EXIT + git worktree add --detach "$base_directory" "origin/$BASE_REF" + ( + cd "$base_directory/rust" + cargo run --release --locked --manifest-path benchmarks/Cargo.toml -- \ + --smoke \ + --output "$GITHUB_WORKSPACE/rust/benchmarks/baseline" + ) + echo 'available=true' >> "$GITHUB_OUTPUT" + + - name: Run benchmark profile + working-directory: rust + env: + BENCHMARK_PROFILE: ${{ github.event_name == 'pull_request' && 'smoke' || inputs.profile || 'full' }} + run: | + if [[ "$BENCHMARK_PROFILE" == 'smoke' ]]; then + cargo run --release --locked --manifest-path benchmarks/Cargo.toml -- \ + --smoke --output benchmarks/results + else + cargo run --release --locked --manifest-path benchmarks/Cargo.toml -- \ + --output benchmarks/results + fi + + - name: Compare base and pull request measurements + if: steps.rust-baseline.outputs.available == 'true' + working-directory: rust + run: | + cargo run --release --locked --manifest-path benchmarks/Cargo.toml --bin compare -- \ + --baseline benchmarks/baseline/benchmark-results.json \ + --current benchmarks/results/benchmark-results.json \ + --output benchmarks/results + + - name: Upload JSON and HTML reports + uses: actions/upload-artifact@v7 + with: + name: command-stream-rust-benchmarks-${{ github.run_id }}-${{ github.run_attempt }} + path: | + rust/benchmarks/baseline/benchmark-results.json + rust/benchmarks/results/benchmark-results.json + rust/benchmarks/results/benchmark-report.html + rust/benchmarks/results/benchmark-comparison.json + rust/benchmarks/results/benchmark-comparison.md + if-no-files-found: error + retention-days: 30 diff --git a/.github/workflows/parity.yml b/.github/workflows/parity.yml index 121cf59a..6bec6cf4 100644 --- a/.github/workflows/parity.yml +++ b/.github/workflows/parity.yml @@ -1,8 +1,8 @@ name: Language parity check -# Ensure behavioral changes are made in both the JavaScript (js/src/**) and the -# Rust (rust/src/**) implementations. A PR that changes one without the other -# fails this check unless it carries the `parity-exempt` label. +# Ensure source and benchmark changes are made in both the JavaScript and Rust +# implementations. Each category is paired independently. A deliberately +# single-language PR must carry the `parity-exempt` label. # # See issue #155 review feedback: "double check that all features that are # supported in JavaScript are fully supported in Rust and we have CI/CD rules, @@ -17,7 +17,7 @@ permissions: jobs: parity: - name: JS/Rust source parity + name: JS/Rust implementation parity runs-on: ubuntu-latest timeout-minutes: 10 # Skip entirely when the PR is explicitly marked as a single-language change. @@ -32,7 +32,7 @@ jobs: # Read-only job: the parity script only diffs the checked-out history. persist-credentials: false - - name: Check JavaScript/Rust source parity + - name: Check JavaScript/Rust implementation parity env: BASE_REF: ${{ github.base_ref }} run: bash .github/scripts/check-language-parity.sh diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 3c8a0fdd..6b7bd2df 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -226,9 +226,11 @@ jobs: with: tool: cargo-audit@0.22.2 - - name: Audit the committed Cargo.lock + - name: Audit the committed Cargo lockfiles working-directory: rust - run: cargo audit --file Cargo.lock + run: | + cargo audit --file Cargo.lock + cargo audit --file benchmarks/Cargo.lock secret-scan: name: Scan for committed secrets diff --git a/.prettierignore b/.prettierignore index fda080c6..4a45054d 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,6 +1,10 @@ node_modules coverage reports +js/benchmarks/results +js/benchmarks/baseline +rust/benchmarks/results +rust/benchmarks/baseline dist *.min.js package-lock.json @@ -8,6 +12,7 @@ package-lock.json CLAUDE.md # Build output. rust/target +rust/benchmarks/target # Generated by changesets / the Rust changelog tooling. js/CHANGELOG.md rust/CHANGELOG.md diff --git a/README.md b/README.md index 798cd2a3..a5b741a1 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,19 @@ compatibility corpora with explicit missing-feature ledgers: Run the focused suites with `bun run test:competitors` in `js/` and `cargo test --test competitor_compatibility` in `rust/`. +The benchmark playgrounds provide measured process, package-footprint, +feature-coverage, and deterministic real-world comparisons for both maintained +implementations: + +- [JavaScript benchmarks](./js/benchmarks/README.md): Execa, cross-spawn, + ShellJS, zx, and Bun Shell. Run `bun run benchmark:smoke` from `js/`. +- [Rust benchmarks](./rust/benchmarks/README.md): `std::process`, Tokio process, + async-process, duct, subprocess, and xshell. Run the documented Cargo smoke + command from `rust/`. + +CI runs both suites and the language parity check prevents benchmark changes in +only one implementation unless maintainers explicitly apply `parity-exempt`. + Run all language-specific checks from the language folders: ```bash diff --git a/js/.changeset/benchmark-suite.md b/js/.changeset/benchmark-suite.md new file mode 100644 index 00000000..866d78f8 --- /dev/null +++ b/js/.changeset/benchmark-suite.md @@ -0,0 +1,7 @@ +--- +'command-stream': patch +--- + +Add a reproducible benchmark playground comparing process performance, package +size, feature coverage, and deterministic real-world workloads with Execa, +cross-spawn, ShellJS, zx, and Bun Shell. diff --git a/js/README.md b/js/README.md index 5bfdd6e6..cc23b9e7 100644 --- a/js/README.md +++ b/js/README.md @@ -44,8 +44,8 @@ A modern $ shell utility library with streaming, async iteration, and EventEmitt | **Bun.$ Compatibility** | ✅ `.text()` method support | ❌ No | ❌ No | ✅ Native API | ❌ No | ❌ No | | **Shell Injection Protection** | ✅ Smart auto-quoting | ✅ Safe by default | ✅ Safe by default | ✅ Built-in | 🟡 Manual escaping | ✅ Safe by default | | **Cross-platform** | ✅ macOS/Linux/Windows | ✅ Yes | ✅ **Specialized** cross-platform | ✅ Yes | ✅ Yes | ✅ Yes | -| **Performance** | ⚡ Fast (Bun optimized) | 🐌 Moderate | ⚡ Fast | ⚡ Very fast | 🐌 Moderate | 🐌 Slow | -| **Memory Efficiency** | ✅ Streaming prevents buildup | 🟡 Buffers in memory | 🟡 Buffers in memory | 🟡 Buffers in memory | 🟡 Buffers in memory | 🟡 Buffers in memory | +| **Performance** | [Measured](benchmarks/README.md) | [Measured](benchmarks/README.md) | [Measured](benchmarks/README.md) | [Measured](benchmarks/README.md) | [Measured](benchmarks/README.md) | [Measured](benchmarks/README.md) | +| **Memory Efficiency** | [Measured](benchmarks/README.md) | [Measured](benchmarks/README.md) | [Measured](benchmarks/README.md) | [Measured](benchmarks/README.md) | [Measured](benchmarks/README.md) | [Measured](benchmarks/README.md) | | **Error Handling** | ✅ Configurable (`set -e`/`set +e`, non-zero OK by default) | ✅ Throws on error | ❌ Basic (exit codes) | ✅ Throws on error | ✅ Configurable | ✅ Throws on error | | **Shell Settings** | ✅ `set -e`/`set +e` equivalent | ❌ No | ❌ No | ❌ No | 🟡 Limited (`set()`) | ❌ No | | **Stdout Support** | ✅ Real-time streaming + events | ✅ Node.js streams + interleaved | ✅ Inherited/buffered | ✅ Shell redirection + buffered | ✅ Direct output | ✅ Readable streams + `.pipe.stdout` | @@ -54,7 +54,7 @@ A modern $ shell utility library with streaming, async iteration, and EventEmitt | **Built-in Commands** | ✅ **18 commands**: cat, ls, mkdir, rm, mv, cp, touch, basename, dirname, seq, yes + all Bun.$ commands | ❌ Uses system | ❌ Uses system | ✅ echo, cd, etc. | ✅ **20+ commands**: cat, ls, mkdir, rm, mv, cp, etc. | ❌ Uses system | | **Virtual Commands Engine** | ✅ **Revolutionary**: Register JavaScript functions as shell commands with full pipeline support | ❌ No custom commands | ❌ No custom commands | ❌ No extensibility | ❌ No custom commands | ❌ No custom commands | | **Pipeline/Piping Support** | ✅ **Advanced**: System + Built-ins + Virtual + Mixed + `.pipe()` method | ✅ Programmatic `.pipe()` + multi-destination | ❌ No piping | ✅ Standard shell piping | ✅ Shell piping + `.to()` method | ✅ Shell piping + `.pipe()` method | -| **Bundle Size** | 📦 **~20KB gzipped** | 📦 ~400KB+ (packagephobia) | 📦 ~2KB gzipped | 🎯 0KB (built-in) | 📦 ~15KB gzipped | 📦 ~50KB+ (estimated) | +| **Bundle Size** | [Measured](benchmarks/README.md) | [Measured](benchmarks/README.md) | [Measured](benchmarks/README.md) | [Measured](benchmarks/README.md) | [Measured](benchmarks/README.md) | [Measured](benchmarks/README.md) | | **Signal Handling** | ✅ **Advanced SIGINT/SIGTERM forwarding** with cleanup | 🟡 Basic | ✅ **Excellent** cross-platform | 🟡 Basic | 🟡 Basic | 🟡 Basic | | **Process Management** | ✅ **Robust child process lifecycle** with proper termination | ✅ Good | ✅ **Excellent** spawn wrapper | ❌ Basic | 🟡 Limited | 🟡 Limited | | **Debug Tracing** | ✅ **Comprehensive VERBOSE logging** for CI/debugging | 🟡 Limited | ❌ No | ❌ No | 🟡 Basic | ❌ No | @@ -64,6 +64,11 @@ A modern $ shell utility library with streaming, async iteration, and EventEmitt | **TypeScript** | 🔄 Coming soon | ✅ Full support | ✅ Built-in | ✅ Built-in | 🟡 Community types | ✅ Full support | | **License** | ✅ **Unlicense (Public Domain)** | 🟡 MIT | 🟡 MIT | 🟡 MIT (+ LGPL dependencies) | 🟡 BSD-3-Clause | 🟡 Apache 2.0 | +Performance, memory, and package-size values depend on the runtime and host. +Use the [reproducible benchmark playground](benchmarks/README.md) for measured +same-host comparisons, raw statistics, and an interactive report rather than +static estimates. + **📊 Popularity & Adoption:** - **⭐ GitHub Stars:** [Bun: 80,169](https://github.com/oven-sh/bun) • [zx: 44,569](https://github.com/google/zx) • [ShellJS: 14,375](https://github.com/shelljs/shelljs) • [execa: 7,264](https://github.com/sindresorhus/execa) • [cross-spawn: 1,149](https://github.com/moxystudio/node-cross-spawn) • [**command-stream: 2 ⭐ us!**](https://github.com/link-foundation/command-stream) diff --git a/js/benchmarks/.gitignore b/js/benchmarks/.gitignore new file mode 100644 index 00000000..0368d7c4 --- /dev/null +++ b/js/benchmarks/.gitignore @@ -0,0 +1,2 @@ +results/ +baseline/ diff --git a/js/benchmarks/README.md b/js/benchmarks/README.md new file mode 100644 index 00000000..97f37542 --- /dev/null +++ b/js/benchmarks/README.md @@ -0,0 +1,115 @@ +# command-stream benchmarks + +This suite measures the JavaScript implementation against the five APIs named +in [issue 29](https://github.com/link-foundation/command-stream/issues/29): +Execa, cross-spawn, ShellJS, zx, and Bun Shell. It uses deterministic fixtures, +checks every result before accepting its timing, and records raw statistics and +environment metadata in JSON. + +The suite is a benchmark playground rather than a static claim about which +library is universally fastest. Results are only comparable within one report: +runtime, operating system, CPU load, package versions, and filesystem state all +affect them. + +## Quick start + +Install the pinned development dependencies and run the smoke profile: + +```bash +cd js +bun install --frozen-lockfile +bun run benchmark:smoke +``` + +Run the complete suite with the default 30 measured and 5 warmup iterations: + +```bash +bun run benchmark +``` + +Use the CLI to focus on a suite or implementation: + +```bash +bun benchmarks/cli.mjs --list +bun benchmarks/cli.mjs --suite performance --adapter command-stream,execa +bun benchmarks/cli.mjs --suite real-world --iterations 50 --warmup 10 +bun benchmarks/cli.mjs --suite bundle-size,features +``` + +Generated `benchmark-results.json` and `benchmark-report.html` files are placed +in `benchmarks/results/`. The HTML report contains expandable comparison tables +and relative-speed charts; CI uploads both files as workflow artifacts. On pull +requests after the suite reaches `main`, CI also runs the same smoke profile on +the base branch and produces `benchmark-regressions.json` and Markdown. + +## What is measured + +| Suite | Measurements | +| ------------ | ---------------------------------------------------------------------------- | +| Performance | Exact-argv spawn latency, stdout throughput, concurrency, and failure paths. | +| Pipelines | `pipe()` throughput versus an equivalent manual two-step command sequence. | +| Output modes | command-stream buffering versus async iteration; built-in versus process. | +| Bundle size | npm pack size, installed production closure, minified bundles, import heap. | +| Features | Ported behavior and known-gap counts from immutable upstream test corpora. | +| Real-world | CI checks, log analysis, file hashing, and a local HTTP health check. | + +All process wrappers execute the same runtime, fixture, arguments, and expected +output in a scenario. The runner rotates adapter order between iterations to +reduce first-position bias and aborts immediately on a thrown error or invalid +result. Median time determines rankings; mean, min, max, standard deviation, +p95, p99, and operations per second remain available in JSON. + +Package size uses `npm pack --dry-run --json` against installed, pinned package +versions. Installed footprint recursively counts production dependencies once. +The tree-shaking probe uses a minified esbuild bundle for both a namespace import +and the smallest primary API import. Bun Shell reports zero package bytes +because it ships with the runtime; that does not imply zero runtime cost. + +Feature counts are not inferred from marketing tables. They come from the +executable mappings in `tests/competitor-compatibility.test.mjs` and the +explicit missing-feature ledger documented in +`docs/COMPETITOR_TEST_AUDIT.md`. Run `bun run test:competitors` to execute that +full compatibility suite. + +## Migration quick reference + +The smallest command-stream API depends on whether the old code needs shell +syntax or an exact argument vector: + +```js +import { $, exec, sh } from 'command-stream'; + +await $`git status --short`; +const result = await exec('git', ['status', '--short'], { + capture: true, + mirror: false, + stdin: 'ignore', +}); +``` + +| Migrating from | Replace the common entry point with | +| -------------- | --------------------------------------------------------------------- | +| Execa | `exec(file, args, options)` for exact arguments | +| cross-spawn | `exec(file, args, options)` for a collected promise result | +| ShellJS | `sh(command, options)` for shell syntax, or `exec()` for exact args | +| zx | `` $`command ${value}` ``; interpolation remains a single safe value | +| Bun Shell | `` $`command ${value}` ``; result objects also expose async `.text()` | + +There are two defaults to review during migration. Output is mirrored unless +`mirror: false` is set, and non-zero exits are returned unless errexit is +enabled. The [main README](../README.md) documents streaming, events, pipelines, +synchronous execution, and error handling in detail. The feature report's +known-gap list is the source of truth for behavior that does not yet have a +direct replacement. + +## CI profiles + +Pull requests run unit tests plus matching base/head smoke profiles. Changes of +at least 15% and 2 ms are classified for review. Pushes to `main`, the weekly +schedule, and manual dispatch run the full profile and retain the JSON/HTML +artifact. Timing classifications are intentionally informational: noisy shared +runners should not reject code based on a single percentage threshold. + +The smoke profile uses smaller suite-specific iteration counts. Every scenario +records its effective measured and warmup counts in JSON; `runnerDefaults` +records the CLI defaults used by scenarios that do not override them. diff --git a/js/benchmarks/cli.mjs b/js/benchmarks/cli.mjs new file mode 100644 index 00000000..2bf9befd --- /dev/null +++ b/js/benchmarks/cli.mjs @@ -0,0 +1,241 @@ +#!/usr/bin/env bun + +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { BenchmarkRunner } from './lib/benchmark-runner.mjs'; +import { + EXPECTED_ADAPTERS, + loadCompetitorAdapters, +} from './lib/competitor-adapters.mjs'; +import { writeReports } from './lib/report.mjs'; +import { runBundleSizeSuite } from './suites/bundle-size.mjs'; +import { runFeatureSuite } from './suites/features.mjs'; +import { runPerformanceSuite } from './suites/performance.mjs'; +import { runRealWorldSuite } from './suites/real-world.mjs'; + +const suiteNames = ['performance', 'bundle-size', 'features', 'real-world']; + +function usage() { + return `command-stream benchmark playground + +Usage: bun benchmarks/cli.mjs [options] + + --suite Select suites (default: all) + --adapter Select process APIs (default: all available) + --iterations Measured iterations per timing scenario (default: 30) + --warmup Warmup iterations per implementation (default: 5) + --output Report directory (default: benchmarks/results) + --smoke Use tiny deterministic workloads for CI + --list List suites and adapters + --help Show this help +`; +} + +function integer(value, flag, minimum) { + const parsed = Number.parseInt(value, 10); + if ( + !Number.isInteger(parsed) || + parsed < minimum || + String(parsed) !== value + ) { + throw new Error(`${flag} expects an integer >= ${minimum}`); + } + return parsed; +} + +function commaList(value) { + return value + .split(',') + .map((entry) => entry.trim()) + .filter(Boolean); +} + +function applyValueOption(options, flag, value) { + if (value === undefined) { + throw new Error(`${flag} expects a value`); + } + if (flag === '--suite') { + options.suites = commaList(value); + } else if (flag === '--adapter') { + options.adapters = commaList(value); + } else if (flag === '--iterations') { + options.iterations = integer(value, flag, 1); + } else if (flag === '--warmup') { + options.warmup = integer(value, flag, 0); + } else if (flag === '--output') { + options.output = resolve(value); + } else { + return false; + } + return true; +} + +export function parseArguments(argv) { + const options = { + adapters: null, + help: false, + iterations: 30, + list: false, + output: resolve('benchmarks/results'), + smoke: false, + suites: [...suiteNames], + warmup: 5, + }; + + for (let index = 0; index < argv.length; index += 1) { + const flag = argv[index]; + if (flag === '--help') { + options.help = true; + } else if (flag === '--list') { + options.list = true; + } else if (flag === '--smoke') { + options.smoke = true; + } else if (!applyValueOption(options, flag, argv[index + 1])) { + throw new Error(`Unknown argument: ${flag}`); + } else { + index += 1; + } + } + + const invalidSuites = options.suites.filter( + (name) => !suiteNames.includes(name) + ); + if (options.suites.length === 0 || invalidSuites.length > 0) { + throw new Error(`Unknown suite: ${invalidSuites[0] ?? '(empty)'}`); + } + const invalidAdapters = (options.adapters ?? []).filter( + (name) => !EXPECTED_ADAPTERS.includes(name) + ); + if (options.adapters?.length === 0 || invalidAdapters.length > 0) { + throw new Error(`Unknown adapter: ${invalidAdapters[0] ?? '(empty)'}`); + } + return options; +} + +function printScenario(scenario) { + console.log(`\n${scenario.name}`); + for (const entry of scenario.ranking) { + console.log( + ` ${entry.rank}. ${entry.name.padEnd(16)} ${entry.medianMs.toFixed(2).padStart(9)} ms ${entry.relativeToFastest.toFixed(2)}x` + ); + } +} + +function printSuite(suite) { + console.log(`\n## ${suite.name}`); + if (suite.scenarios) { + suite.scenarios.forEach(printScenario); + } else if (suite.competitors) { + for (const entry of suite.competitors) { + console.log( + ` ${entry.name.padEnd(16)} ${entry.supported} ported / ${entry.gaps} known gaps (${entry.coveragePercent.toFixed(1)}%)` + ); + } + } else if (suite.packages) { + for (const entry of suite.packages) { + console.log( + ` ${entry.name.padEnd(16)} pack ${String(entry.packedBytes).padStart(9)} B minimal bundle ${String(entry.minimalBundleBytes).padStart(9)} B` + ); + } + } +} + +async function selectedAdapters(names) { + const available = await loadCompetitorAdapters(); + if (!names) { + return available; + } + const selected = available.filter(({ name }) => names.includes(name)); + const unavailable = names.filter( + (name) => !selected.some((item) => item.name === name) + ); + if (unavailable.length > 0) { + throw new Error( + `${unavailable.join(', ')} unavailable in ${typeof globalThis.Bun === 'undefined' ? 'Node.js' : 'Bun'}` + ); + } + return selected; +} + +export async function main(argv = process.argv.slice(2)) { + const options = parseArguments(argv); + if (options.help) { + console.log(usage()); + return null; + } + if (options.list) { + console.log(`Suites: ${suiteNames.join(', ')}`); + console.log(`Adapters: ${EXPECTED_ADAPTERS.join(', ')}`); + return null; + } + + const needsAdapters = options.suites.some((name) => + ['performance', 'real-world'].includes(name) + ); + const adapters = needsAdapters + ? await selectedAdapters(options.adapters) + : []; + const runner = new BenchmarkRunner({ + iterations: options.iterations, + warmup: options.warmup, + }); + const suites = []; + + for (const suite of options.suites) { + console.log(`\nRunning ${suite}...`); + if (suite === 'performance') { + suites.push( + await runPerformanceSuite({ runner, adapters, smoke: options.smoke }) + ); + } else if (suite === 'bundle-size') { + suites.push(await runBundleSizeSuite()); + } else if (suite === 'features') { + suites.push(runFeatureSuite()); + } else if (suite === 'real-world') { + suites.push( + await runRealWorldSuite({ runner, adapters, smoke: options.smoke }) + ); + } + } + + const report = { + schemaVersion: 1, + generatedAt: new Date().toISOString(), + environment: { + arch: process.arch, + bun: process.versions.bun ?? null, + cpus: globalThis.navigator?.hardwareConcurrency ?? null, + node: process.versions.node, + platform: process.platform, + runtime: + typeof globalThis.Bun === 'undefined' + ? `Node.js ${process.version}` + : `Bun ${globalThis.Bun.version}`, + }, + configuration: { + adapters: adapters.map(({ name, version }) => ({ name, version })), + runnerDefaults: { + iterations: options.iterations, + warmup: options.warmup, + }, + smoke: options.smoke, + suites: options.suites, + }, + suites, + }; + suites.forEach(printSuite); + const paths = await writeReports(report, options.output); + console.log(`\nJSON: ${paths.json}`); + console.log(`HTML: ${paths.html}`); + return report; +} + +if ( + process.argv[1] && + fileURLToPath(import.meta.url) === resolve(process.argv[1]) +) { + main().catch((error) => { + console.error(error.stack ?? error.message); + process.exitCode = 1; + }); +} diff --git a/js/benchmarks/compare-results.mjs b/js/benchmarks/compare-results.mjs new file mode 100644 index 00000000..ae2a1f36 --- /dev/null +++ b/js/benchmarks/compare-results.mjs @@ -0,0 +1,43 @@ +#!/usr/bin/env node + +import { readFile, writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + compareBenchmarkReports, + regressionMarkdown, +} from './lib/regression.mjs'; + +export async function main(argv = process.argv.slice(2)) { + const [baselinePath, currentPath, outputDirectory = 'benchmarks/results'] = + argv; + if (!baselinePath || !currentPath) { + throw new Error( + 'Usage: bun benchmarks/compare-results.mjs [output-directory]' + ); + } + const [baseline, current] = await Promise.all( + [baselinePath, currentPath].map(async (filename) => + JSON.parse(await readFile(resolve(filename), 'utf8')) + ) + ); + const comparison = compareBenchmarkReports(baseline, current); + const jsonPath = resolve(outputDirectory, 'benchmark-regressions.json'); + const markdownPath = resolve(outputDirectory, 'benchmark-regressions.md'); + await Promise.all([ + writeFile(jsonPath, `${JSON.stringify(comparison, null, 2)}\n`), + writeFile(markdownPath, regressionMarkdown(comparison)), + ]); + console.log(regressionMarkdown(comparison)); + return comparison; +} + +if ( + process.argv[1] && + fileURLToPath(import.meta.url) === resolve(process.argv[1]) +) { + main().catch((error) => { + console.error(error.stack ?? error.message); + process.exitCode = 1; + }); +} diff --git a/js/benchmarks/fixtures/import-memory.mjs b/js/benchmarks/fixtures/import-memory.mjs new file mode 100644 index 00000000..7d9a813e --- /dev/null +++ b/js/benchmarks/fixtures/import-memory.mjs @@ -0,0 +1,20 @@ +const moduleUrl = process.argv[2]; + +if (typeof globalThis.gc !== 'function') { + throw new Error('Run the memory fixture with --expose-gc'); +} + +globalThis.gc(); +const before = process.memoryUsage(); +await import(moduleUrl); +globalThis.gc(); +await new Promise((resolve) => setImmediate(resolve)); +globalThis.gc(); +const after = process.memoryUsage(); + +process.stdout.write( + JSON.stringify({ + heapUsedBytes: after.heapUsed - before.heapUsed, + rssBytes: after.rss - before.rss, + }) +); diff --git a/js/benchmarks/fixtures/workload.mjs b/js/benchmarks/fixtures/workload.mjs new file mode 100644 index 00000000..ab82c62e --- /dev/null +++ b/js/benchmarks/fixtures/workload.mjs @@ -0,0 +1,92 @@ +import { createHash } from 'node:crypto'; +import { readFile, readdir } from 'node:fs/promises'; +import { join } from 'node:path'; + +async function sourceDigest(directory) { + const names = (await readdir(directory, { withFileTypes: true })) + .filter((entry) => entry.isFile() && entry.name.endsWith('.mjs')) + .map((entry) => entry.name) + .sort(); + const hash = createHash('sha256'); + for (const name of names) { + hash.update(name); + hash.update(await readFile(join(directory, name))); + } + return `${names.length}:${hash.digest('hex')}`; +} + +async function summarizeLog(filename) { + const counts = { INFO: 0, WARN: 0, ERROR: 0 }; + for (const line of (await readFile(filename, 'utf8')).trim().split('\n')) { + const level = line.split(' ')[1]; + if (Object.hasOwn(counts, level)) { + counts[level] += 1; + } + } + return JSON.stringify(counts); +} + +async function digestFiles(directory) { + const names = (await readdir(directory)).sort(); + const hash = createHash('sha256'); + for (const name of names) { + hash.update(name); + hash.update(await readFile(join(directory, name))); + } + return `${names.length}:${hash.digest('hex')}`; +} + +async function countStdin() { + let bytes = 0; + for await (const chunk of process.stdin) { + bytes += chunk.length; + } + return bytes; +} + +async function main([mode, ...args]) { + if (mode === 'echo') { + process.stdout.write(JSON.stringify(args)); + return; + } + if (mode === 'emit') { + const bytes = Number.parseInt(args[0], 10); + process.stdout.write(Buffer.alloc(bytes, 120)); + return; + } + if (mode === 'stdin-count') { + process.stdout.write(String(await countStdin())); + return; + } + if (mode === 'fail') { + process.stderr.write('intentional benchmark failure'); + process.exitCode = Number.parseInt(args[0], 10); + return; + } + if (mode === 'package-version') { + const manifest = JSON.parse(await readFile(args[0], 'utf8')); + process.stdout.write(manifest.version); + return; + } + if (mode === 'source-digest') { + process.stdout.write(await sourceDigest(args[0])); + return; + } + if (mode === 'log-summary') { + process.stdout.write(await summarizeLog(args[0])); + return; + } + if (mode === 'file-digest') { + process.stdout.write(await digestFiles(args[0])); + return; + } + if (mode === 'http-get') { + const response = await fetch(args[0]); + const body = await response.text(); + process.stdout.write(`${response.status}:${body}`); + return; + } + throw new Error(`Unknown workload: ${mode}`); +} + +await main(process.argv.slice(2)); diff --git a/js/benchmarks/lib/benchmark-runner.mjs b/js/benchmarks/lib/benchmark-runner.mjs new file mode 100644 index 00000000..c4611338 --- /dev/null +++ b/js/benchmarks/lib/benchmark-runner.mjs @@ -0,0 +1,163 @@ +import { performance } from 'node:perf_hooks'; + +function percentile(sortedSamples, probability) { + const index = Math.max( + 0, + Math.min( + sortedSamples.length - 1, + Math.ceil(probability * sortedSamples.length) - 1 + ) + ); + return sortedSamples[index]; +} + +export function summarizeSamples(samples) { + if (!Array.isArray(samples) || samples.length === 0) { + throw new TypeError('At least one timing sample is required'); + } + + const sorted = [...samples].sort((left, right) => left - right); + const meanMs = + samples.reduce((sum, sample) => sum + sample, 0) / samples.length; + const middle = Math.floor(sorted.length / 2); + const medianMs = + sorted.length % 2 === 0 + ? (sorted[middle - 1] + sorted[middle]) / 2 + : sorted[middle]; + const variance = + samples.reduce((sum, sample) => sum + (sample - meanMs) ** 2, 0) / + samples.length; + + return { + samples: samples.length, + meanMs, + medianMs, + minMs: sorted[0], + maxMs: sorted.at(-1), + p95Ms: percentile(sorted, 0.95), + p99Ms: percentile(sorted, 0.99), + standardDeviationMs: Math.sqrt(variance), + operationsPerSecond: + meanMs === 0 ? Number.POSITIVE_INFINITY : 1000 / meanMs, + }; +} + +function checkedCount(value, name, minimum) { + if (!Number.isInteger(value) || value < minimum) { + throw new TypeError( + `${name} must be an integer greater than or equal to ${minimum}` + ); + } + return value; +} + +async function executeCase(suiteName, implementationName, phase, entry) { + let value; + try { + value = await entry.run(); + } catch (error) { + throw new Error( + `${suiteName}/${implementationName} ${phase} failed: ${error.message}`, + { cause: error } + ); + } + + return value; +} + +async function validateCase( + suiteName, + implementationName, + phase, + entry, + value +) { + if (entry.validate && !(await entry.validate(value))) { + throw new Error( + `${suiteName}/${implementationName} ${phase} validation failed` + ); + } +} + +export class BenchmarkRunner { + constructor({ iterations = 30, warmup = 5, clock = performance } = {}) { + this.iterations = checkedCount(iterations, 'iterations', 1); + this.warmup = checkedCount(warmup, 'warmup', 0); + this.clock = clock; + } + + async compare(name, implementations, overrides = {}) { + const iterations = checkedCount( + overrides.iterations ?? this.iterations, + 'iterations', + 1 + ); + const warmup = checkedCount(overrides.warmup ?? this.warmup, 'warmup', 0); + const entries = Object.entries(implementations); + if (entries.length === 0) { + throw new TypeError(`${name} must include at least one implementation`); + } + + for (const [implementationName, entry] of entries) { + if (typeof entry.run !== 'function') { + throw new TypeError(`${name}/${implementationName} is missing run()`); + } + for (let index = 0; index < warmup; index += 1) { + const phase = `warmup ${index + 1}`; + const value = await executeCase(name, implementationName, phase, entry); + await validateCase(name, implementationName, phase, entry, value); + } + } + + const samples = Object.fromEntries( + entries.map(([entryName]) => [entryName, []]) + ); + for (let iteration = 0; iteration < iterations; iteration += 1) { + // Rotate the first implementation on each pass. A fixed order otherwise + // gives the same adapter every cold-cache and thermal position. + const offset = iteration % entries.length; + const rotated = [...entries.slice(offset), ...entries.slice(0, offset)]; + for (const [implementationName, entry] of rotated) { + const startedAt = this.clock.now(); + const phase = `iteration ${iteration + 1}`; + const value = await executeCase(name, implementationName, phase, entry); + const elapsed = this.clock.now() - startedAt; + if (!Number.isFinite(elapsed) || elapsed < 0) { + throw new Error( + `${name}/${implementationName} produced an invalid timing` + ); + } + // Validation proves that every API did the same work without adding + // assertion overhead to the measured interval. + await validateCase(name, implementationName, phase, entry, value); + samples[implementationName].push(elapsed); + } + } + + const measured = Object.fromEntries( + entries.map(([implementationName]) => [ + implementationName, + summarizeSamples(samples[implementationName]), + ]) + ); + const ranking = Object.entries(measured) + .sort(([, left], [, right]) => left.medianMs - right.medianMs) + .map(([implementationName, statistics], index, sorted) => ({ + rank: index + 1, + name: implementationName, + medianMs: statistics.medianMs, + relativeToFastest: + sorted[0][1].medianMs === 0 + ? null + : statistics.medianMs / sorted[0][1].medianMs, + })); + + return { + name, + iterations, + warmup, + implementations: measured, + ranking, + }; + } +} diff --git a/js/benchmarks/lib/competitor-adapters.mjs b/js/benchmarks/lib/competitor-adapters.mjs new file mode 100644 index 00000000..9cf14645 --- /dev/null +++ b/js/benchmarks/lib/competitor-adapters.mjs @@ -0,0 +1,178 @@ +import crossSpawn from 'cross-spawn'; +import { execa } from 'execa'; +import shelljs from 'shelljs'; +import { $ as zxShell } from 'zx'; +import { readFileSync } from 'node:fs'; +import { exec as commandStreamExec } from '../../src/$.mjs'; + +const manifest = JSON.parse( + readFileSync(new URL('../../package.json', import.meta.url), 'utf8') +); + +const packageVersion = (name) => + name === 'command-stream' ? manifest.version : manifest.devDependencies[name]; + +export const EXPECTED_ADAPTERS = [ + 'command-stream', + 'execa', + 'cross-spawn', + 'ShellJS', + 'zx', + 'Bun.$', +]; + +const asText = (value) => + value === undefined || value === null + ? '' + : Buffer.isBuffer(value) + ? value.toString('utf8') + : String(value); + +const normalizedResult = ({ stdout, stderr, exitCode, code }) => ({ + stdout: asText(stdout), + stderr: asText(stderr), + exitCode: Number(exitCode ?? code ?? 0), +}); + +export const executableForZx = (file, platform = process.platform) => + // zx 8 uses Bash on Windows; MSYS Bash can execute drive paths with forward + // slashes, while native backslashes are parsed as shell escapes. + platform === 'win32' ? file.replaceAll('\\', '/') : file; + +function spawnWithCrossSpawn(file, args, options) { + return new Promise((resolve, reject) => { + const child = crossSpawn(file, args, { + cwd: options.cwd, + env: options.env, + stdio: [options.input === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'], + }); + const stdout = []; + const stderr = []; + child.stdout.on('data', (chunk) => stdout.push(chunk)); + child.stderr.on('data', (chunk) => stderr.push(chunk)); + child.once('error', reject); + child.once('close', (exitCode) => + resolve({ + stdout: Buffer.concat(stdout).toString('utf8'), + stderr: Buffer.concat(stderr).toString('utf8'), + exitCode: exitCode ?? 1, + }) + ); + if (options.input !== undefined) { + child.stdin.end(options.input); + } + }); +} + +function quoteShellArgument(value) { + const text = String(value); + if (process.platform === 'win32') { + return `"${text.replaceAll('"', '""')}"`; + } + return `'${text.replaceAll("'", "'\\''")}'`; +} + +function runWithShellJs(file, args, options) { + if (options.input !== undefined) { + throw new Error('ShellJS adapter does not support stdin workloads'); + } + const command = [file, ...args].map(quoteShellArgument).join(' '); + return new Promise((resolve) => { + shelljs.exec( + command, + { async: true, cwd: options.cwd, env: options.env, silent: true }, + (exitCode, stdout, stderr) => resolve({ stdout, stderr, exitCode }) + ); + }); +} + +async function createBunAdapter() { + if (typeof globalThis.Bun === 'undefined') { + return null; + } + const { $: bunShell } = await import('bun'); + return { + name: 'Bun.$', + version: globalThis.Bun.version, + async run(file, args, options = {}) { + if (options.input !== undefined) { + throw new Error('Bun.$ adapter does not support stdin workloads'); + } + let command = bunShell`${file} ${args}`.quiet().nothrow(); + if (options.cwd) { + command = command.cwd(options.cwd); + } + if (options.env) { + command = command.env(options.env); + } + return normalizedResult(await command); + }, + }; +} + +export async function loadCompetitorAdapters() { + const adapters = [ + { + name: 'command-stream', + version: packageVersion('command-stream'), + async run(file, args, options = {}) { + return normalizedResult( + await commandStreamExec(file, args, { + capture: true, + mirror: false, + stdin: options.input ?? 'ignore', + cwd: options.cwd, + env: options.env, + }) + ); + }, + }, + { + name: 'execa', + version: packageVersion('execa'), + async run(file, args, options = {}) { + return normalizedResult( + await execa(file, args, { + cwd: options.cwd, + env: options.env, + input: options.input, + reject: false, + }) + ); + }, + }, + { + name: 'cross-spawn', + version: packageVersion('cross-spawn'), + run: (file, args, options = {}) => + spawnWithCrossSpawn(file, args, options), + }, + { + name: 'ShellJS', + version: packageVersion('shelljs'), + run: (file, args, options = {}) => runWithShellJs(file, args, options), + }, + { + name: 'zx', + version: packageVersion('zx'), + async run(file, args, options = {}) { + if (options.input !== undefined) { + throw new Error('zx adapter does not support stdin workloads'); + } + const result = await zxShell({ + cwd: options.cwd, + env: options.env, + nothrow: true, + quiet: true, + verbose: false, + })`${executableForZx(file)} ${args}`; + return normalizedResult(result); + }, + }, + ]; + const bunAdapter = await createBunAdapter(); + if (bunAdapter) { + adapters.push(bunAdapter); + } + return adapters; +} diff --git a/js/benchmarks/lib/regression.mjs b/js/benchmarks/lib/regression.mjs new file mode 100644 index 00000000..f3df7d78 --- /dev/null +++ b/js/benchmarks/lib/regression.mjs @@ -0,0 +1,93 @@ +function timedScenarios(report) { + return report.suites + .filter((suite) => Array.isArray(suite.scenarios)) + .flatMap((suite) => + suite.scenarios.flatMap((scenario) => + Object.entries(scenario.implementations).map( + ([implementation, statistics]) => ({ + key: `${suite.kind}\u0000${scenario.name}\u0000${implementation}`, + suite: suite.name, + scenario: scenario.name, + implementation, + medianMs: statistics.medianMs, + }) + ) + ) + ); +} + +export function compareBenchmarkReports( + baseline, + current, + { thresholdPercent = 15, minimumAbsoluteMs = 2 } = {} +) { + const baselineEntries = new Map( + timedScenarios(baseline).map((entry) => [entry.key, entry]) + ); + const comparisons = timedScenarios(current) + .filter((entry) => baselineEntries.has(entry.key)) + .map((entry) => { + const before = baselineEntries.get(entry.key).medianMs; + const deltaMs = entry.medianMs - before; + const deltaPercent = before === 0 ? null : (deltaMs / before) * 100; + const material = Math.abs(deltaMs) >= minimumAbsoluteMs; + const status = + !material || + deltaPercent === null || + Math.abs(deltaPercent) < thresholdPercent + ? 'stable' + : deltaPercent > 0 + ? 'regression' + : 'improvement'; + return { + suite: entry.suite, + scenario: entry.scenario, + implementation: entry.implementation, + baselineMedianMs: before, + currentMedianMs: entry.medianMs, + deltaMs, + deltaPercent, + status, + }; + }); + + return { + schemaVersion: 1, + baselineGeneratedAt: baseline.generatedAt, + currentGeneratedAt: current.generatedAt, + thresholdPercent, + minimumAbsoluteMs, + summary: { + compared: comparisons.length, + regressions: comparisons.filter(({ status }) => status === 'regression') + .length, + improvements: comparisons.filter(({ status }) => status === 'improvement') + .length, + stable: comparisons.filter(({ status }) => status === 'stable').length, + }, + comparisons, + }; +} + +export function regressionMarkdown(comparison) { + const lines = [ + '# Benchmark comparison', + '', + `Compared ${comparison.summary.compared} measurements: ${comparison.summary.regressions} possible regressions, ${comparison.summary.improvements} improvements, and ${comparison.summary.stable} stable.`, + '', + '| Status | Suite | Scenario | API | Baseline | Current | Change |', + '| --- | --- | --- | --- | ---: | ---: | ---: |', + ]; + for (const entry of comparison.comparisons) { + const percent = + entry.deltaPercent === null ? 'n/a' : `${entry.deltaPercent.toFixed(1)}%`; + lines.push( + `| ${entry.status} | ${entry.suite} | ${entry.scenario} | ${entry.implementation} | ${entry.baselineMedianMs.toFixed(2)} ms | ${entry.currentMedianMs.toFixed(2)} ms | ${percent} |` + ); + } + lines.push( + '', + '> Timing classifications are review signals, not a merge gate. Confirm possible regressions with repeated runs on a controlled host.' + ); + return `${lines.join('\n')}\n`; +} diff --git a/js/benchmarks/lib/report.mjs b/js/benchmarks/lib/report.mjs new file mode 100644 index 00000000..54d08d6f --- /dev/null +++ b/js/benchmarks/lib/report.mjs @@ -0,0 +1,85 @@ +import { mkdir, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +export const escapeHtml = (value) => + String(value) + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); + +const number = (value, digits = 2) => + Number.isFinite(value) ? value.toFixed(digits) : 'n/a'; + +function performanceSection(suite) { + return suite.scenarios + .map((scenario) => { + const fastest = scenario.ranking[0]?.medianMs ?? 0; + const rows = scenario.ranking + .map(({ name, medianMs, relativeToFastest }) => { + const width = Math.max(2, (fastest / medianMs) * 100); + return `${escapeHtml(name)}${number(medianMs)} ms${number(relativeToFastest)}x`; + }) + .join(''); + return `
${escapeHtml(scenario.name)}${rows}
APIMedianvs fastestRelative speed
`; + }) + .join(''); +} + +function featureSection(suite) { + const rows = suite.competitors + .map( + (entry) => + `${escapeHtml(entry.name)}${entry.supported}${entry.gaps}${number(entry.coveragePercent, 1)}%` + ) + .join(''); + return `${rows}
Upstream corpusPorted behaviorsKnown gapsCoverage
`; +} + +function sizeSection(suite) { + const rows = suite.packages + .map( + (entry) => + `${escapeHtml(entry.name)}${escapeHtml(entry.version)}${entry.packedBytes.toLocaleString()}${entry.installedBytes.toLocaleString()}${entry.minimalBundleBytes.toLocaleString()}${number(entry.treeShakingPercent, 1)}%${entry.importMemory ? entry.importMemory.heapUsedBytes.toLocaleString() : 'built in'}` + ) + .join(''); + return `${rows}
PackageVersionnpm pack (bytes)Installed closureMinimal bundleTree-shakenImport heap delta
`; +} + +function renderSuite(suite) { + if (suite.kind === 'performance' || suite.kind === 'real-world') { + return performanceSection(suite); + } + if (suite.kind === 'features') { + return featureSection(suite); + } + if (suite.kind === 'bundle-size') { + return sizeSection(suite); + } + return `
${escapeHtml(JSON.stringify(suite, null, 2))}
`; +} + +function htmlReport(report) { + const sections = report.suites + .map( + (suite) => + `

${escapeHtml(suite.name)}

${renderSuite(suite)}
` + ) + .join(''); + return ` +command-stream benchmark report + +

command-stream benchmark report

Generated ${escapeHtml(report.generatedAt)} with ${escapeHtml(report.environment.runtime)} on ${escapeHtml(report.environment.platform)} ${escapeHtml(report.environment.arch)}. Lower latency is better.

${sections}`; +} + +export async function writeReports(report, outputDirectory) { + await mkdir(outputDirectory, { recursive: true }); + const jsonPath = join(outputDirectory, 'benchmark-results.json'); + const htmlPath = join(outputDirectory, 'benchmark-report.html'); + await Promise.all([ + writeFile(jsonPath, `${JSON.stringify(report, null, 2)}\n`), + writeFile(htmlPath, htmlReport(report)), + ]); + return { json: jsonPath, html: htmlPath }; +} diff --git a/js/benchmarks/suites/bundle-size.mjs b/js/benchmarks/suites/bundle-size.mjs new file mode 100644 index 00000000..1b7d4eed --- /dev/null +++ b/js/benchmarks/suites/bundle-size.mjs @@ -0,0 +1,241 @@ +import { execFileSync } from 'node:child_process'; +import { lstatSync, readFileSync, readdirSync, realpathSync } from 'node:fs'; +import { dirname, join, parse, resolve } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { createRequire } from 'node:module'; +import { build } from 'esbuild'; + +const benchmarkDirectory = dirname(dirname(fileURLToPath(import.meta.url))); +const jsDirectory = dirname(benchmarkDirectory); +const memoryFixture = join(benchmarkDirectory, 'fixtures', 'import-memory.mjs'); +const requireFromJs = createRequire(join(jsDirectory, 'package.json')); + +const packageConfigurations = [ + { + name: 'command-stream', + root: jsDirectory, + importUrl: pathToFileURL(join(jsDirectory, 'src', '$.mjs')).href, + fullImport: `import * as api from './src/$.mjs'; globalThis.__benchmark = api`, + minimalImport: `import { exec } from './src/$.mjs'; globalThis.__benchmark = exec`, + }, + { + name: 'execa', + fullImport: `import * as api from 'execa'; globalThis.__benchmark = api`, + minimalImport: `import { execa as api } from 'execa'; globalThis.__benchmark = api`, + }, + { name: 'cross-spawn', full: 'cross-spawn', minimal: 'default' }, + { + name: 'ShellJS', + packageName: 'shelljs', + full: 'shelljs', + minimal: 'default', + }, + { name: 'zx', full: 'zx', minimal: '$' }, +]; + +function packageManifest(packageRoot) { + return JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf8')); +} + +function findPackageRoot(packageName, fromDirectory = jsDirectory) { + let current = resolve(fromDirectory); + const filesystemRoot = parse(current).root; + while (true) { + const candidate = join(current, 'node_modules', ...packageName.split('/')); + try { + const manifest = packageManifest(candidate); + if (manifest.name === packageName) { + return realpathSync(candidate); + } + } catch (error) { + if (error.code !== 'ENOENT') { + throw error; + } + } + if (current === filesystemRoot) { + break; + } + current = dirname(current); + } + throw new Error(`Could not locate package root for ${packageName}`); +} + +function directorySize(directory) { + let bytes = 0; + for (const entry of readdirSync(directory, { withFileTypes: true })) { + if (entry.name === 'node_modules') { + continue; + } + const filename = join(directory, entry.name); + if (entry.isDirectory()) { + bytes += directorySize(filename); + } else if (entry.isFile() || entry.isSymbolicLink()) { + bytes += lstatSync(filename).size; + } + } + return bytes; +} + +function dependencyClosureSize(packageRoot, primaryUnpackedBytes) { + const visited = new Set(); + let total = 0; + + function visit(currentRoot, primary = false) { + const canonical = realpathSync(currentRoot); + if (visited.has(canonical)) { + return; + } + visited.add(canonical); + total += primary ? primaryUnpackedBytes : directorySize(canonical); + + const manifest = packageManifest(canonical); + for (const dependency of Object.keys(manifest.dependencies ?? {})) { + visit(findPackageRoot(dependency, canonical)); + } + } + + visit(packageRoot, true); + return total; +} + +export function parseNpmPackOutput(output, packageRoot = 'package') { + const parsed = JSON.parse(output); + const result = Array.isArray(parsed) ? parsed[0] : Object.values(parsed)[0]; + if ( + !result || + !Number.isFinite(result.size) || + !Number.isFinite(result.unpackedSize) + ) { + throw new Error(`npm pack returned invalid metrics for ${packageRoot}`); + } + return { + packedBytes: result.size, + unpackedBytes: result.unpackedSize, + fileCount: result.entryCount ?? result.files?.length ?? null, + }; +} + +function npmPackMetrics(packageRoot) { + const output = execFileSync( + 'npm', + ['pack', packageRoot, '--dry-run', '--json', '--ignore-scripts'], + { cwd: jsDirectory, encoding: 'utf8', maxBuffer: 20 * 1024 * 1024 } + ); + return parseNpmPackOutput(output, packageRoot); +} + +function importStatement(packageName, selectedExport) { + if (selectedExport === packageName) { + return `import * as api from '${packageName}'; globalThis.__benchmark = api`; + } + if (selectedExport === 'default') { + return `import api from '${packageName}'; globalThis.__benchmark = api`; + } + return `import { ${selectedExport} as api } from '${packageName}'; globalThis.__benchmark = api`; +} + +async function bundledBytes(source) { + const result = await build({ + absWorkingDir: jsDirectory, + bundle: true, + format: 'esm', + loader: { '.node': 'file' }, + logLevel: 'silent', + minify: true, + platform: 'node', + outdir: 'benchmark-bundle', + stdin: { + contents: source, + resolveDir: jsDirectory, + sourcefile: 'benchmark-entry.mjs', + }, + treeShaking: true, + write: false, + }); + return result.outputFiles.reduce( + (sum, file) => sum + file.contents.byteLength, + 0 + ); +} + +function measureImportMemory(importUrl) { + const output = execFileSync( + 'node', + ['--expose-gc', memoryFixture, importUrl], + { cwd: jsDirectory, encoding: 'utf8' } + ); + return JSON.parse(output); +} + +async function measurePackage(configuration) { + const packageName = configuration.packageName ?? configuration.name; + const packageRoot = configuration.root ?? findPackageRoot(packageName); + const manifest = packageManifest(packageRoot); + const pack = npmPackMetrics(packageRoot); + const fullSource = + configuration.fullImport ?? + importStatement(packageName, configuration.full); + const minimalSource = + configuration.minimalImport ?? + importStatement(packageName, configuration.minimal); + const [fullBundleBytes, minimalBundleBytes] = await Promise.all([ + bundledBytes(fullSource), + bundledBytes(minimalSource), + ]); + const treeShakingPercent = + fullBundleBytes === 0 + ? 0 + : Math.max(0, (1 - minimalBundleBytes / fullBundleBytes) * 100); + + return { + name: configuration.name, + version: manifest.version, + packedBytes: pack.packedBytes, + unpackedBytes: pack.unpackedBytes, + fileCount: pack.fileCount, + installedBytes: dependencyClosureSize(packageRoot, pack.unpackedBytes), + fullBundleBytes, + minimalBundleBytes, + treeShakingPercent, + importMemory: measureImportMemory( + configuration.importUrl ?? + pathToFileURL(requireFromJs.resolve(packageName)).href + ), + }; +} + +export async function runBundleSizeSuite() { + const packages = []; + for (const configuration of packageConfigurations) { + packages.push(await measurePackage(configuration)); + } + packages.push({ + name: 'Bun.$', + version: + typeof globalThis.Bun === 'undefined' + ? 'built into Bun' + : globalThis.Bun.version, + packedBytes: 0, + unpackedBytes: 0, + fileCount: 0, + installedBytes: 0, + fullBundleBytes: 0, + minimalBundleBytes: 0, + treeShakingPercent: null, + importMemory: null, + }); + + return { + kind: 'bundle-size', + name: 'Package and bundle size', + methodology: + 'npm pack sizes, recursive production dependency footprint, esbuild minified Node bundles, and fresh-process import memory deltas.', + packages, + }; +} + +export const bundleSizeInternals = { + directorySize, + findPackageRoot, + npmPackMetrics, +}; diff --git a/js/benchmarks/suites/features.mjs b/js/benchmarks/suites/features.mjs new file mode 100644 index 00000000..012d701c --- /dev/null +++ b/js/benchmarks/suites/features.mjs @@ -0,0 +1,47 @@ +import { + competitors, + missingFeatures, + portedCases, + snapshotDate, +} from '../../tests/competitor-corpus.mjs'; + +const requestedCompetitors = [ + ['bun-shell', 'Bun.$'], + ['cross-spawn', 'cross-spawn'], + ['execa', 'execa'], + ['shelljs', 'ShellJS'], + ['zx', 'zx'], +]; + +export function runFeatureSuite() { + const known = new Map(competitors.map((entry) => [entry.id, entry])); + const summaries = requestedCompetitors.map(([id, name]) => { + const supportedCases = portedCases.filter(({ competitors: sources }) => + sources.includes(id) + ); + const gaps = missingFeatures.filter(({ competitors: sources }) => + sources.includes(id) + ); + const total = supportedCases.length + gaps.length; + return { + id, + name, + upstreamCommit: known.get(id).commit, + supported: supportedCases.length, + gaps: gaps.length, + coveragePercent: + total === 0 ? 100 : (supportedCases.length / total) * 100, + supportedCases: supportedCases.map(({ id: caseId }) => caseId), + missingFeatures: gaps.map(({ id: featureId }) => featureId), + }; + }); + + return { + kind: 'features', + name: 'Feature completeness', + snapshotDate, + methodology: + 'Counts executable command-stream behavior cases and explicit gaps mapped to immutable upstream competitor tests.', + competitors: summaries, + }; +} diff --git a/js/benchmarks/suites/performance.mjs b/js/benchmarks/suites/performance.mjs new file mode 100644 index 00000000..1bd2138c --- /dev/null +++ b/js/benchmarks/suites/performance.mjs @@ -0,0 +1,229 @@ +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { ProcessRunner, exec } from '../../src/$.mjs'; + +const benchmarkDirectory = dirname(dirname(fileURLToPath(import.meta.url))); +const fixture = join(benchmarkDirectory, 'fixtures', 'workload.mjs'); + +const casesFor = (adapters, args, validate) => + Object.fromEntries( + adapters.map((adapter) => [ + adapter.name, + { + run: () => adapter.run(process.execPath, [fixture, ...args]), + validate, + }, + ]) + ); + +function concurrentCases(adapters, jobs) { + return Object.fromEntries( + adapters.map((adapter) => [ + adapter.name, + { + run: () => + Promise.all( + Array.from({ length: jobs }, (_, index) => + adapter.run(process.execPath, [fixture, 'echo', String(index)]) + ) + ), + validate: (results) => + results.every( + (result, index) => + result.exitCode === 0 && + result.stdout === JSON.stringify([String(index)]) + ), + }, + ]) + ); +} + +function streamCommand(bytes) { + return new ProcessRunner( + { + mode: 'exec', + file: process.execPath, + args: [fixture, 'emit', String(bytes)], + }, + { capture: true, mirror: false, stdin: 'ignore' } + ); +} + +async function consumeStream(bytes) { + let received = 0; + let exitCode = null; + for await (const chunk of streamCommand(bytes).stream()) { + if (chunk.type === 'stdout') { + received += Buffer.byteLength(chunk.data); + } + if (chunk.type === 'exit') { + exitCode = chunk.code; + } + } + return { received, exitCode }; +} + +async function bufferedCommand(bytes) { + const result = await exec( + process.execPath, + [fixture, 'emit', String(bytes)], + { + capture: true, + mirror: false, + stdin: 'ignore', + } + ); + return { received: Buffer.byteLength(result.stdout), exitCode: result.code }; +} + +function fixtureRunner(mode, value, options = {}) { + return new ProcessRunner( + { + mode: 'exec', + file: process.execPath, + args: [fixture, mode, String(value)], + }, + { capture: true, mirror: false, stdin: 'ignore', ...options } + ); +} + +async function programmaticPipeline(bytes) { + const result = await fixtureRunner('emit', bytes).pipe( + fixtureRunner('stdin-count', '', { stdin: 'pipe' }) + ); + return { exitCode: result.code, received: result.stdout }; +} + +async function bufferedPipeline(bytes) { + const source = await fixtureRunner('emit', bytes); + const destination = await fixtureRunner('stdin-count', '', { + stdin: source.stdout, + }); + return { exitCode: destination.code, received: destination.stdout }; +} + +async function builtInEcho() { + const result = await exec('echo', ['benchmark'], { + capture: true, + mirror: false, + stdin: 'ignore', + }); + return result.stdout.trim(); +} + +export async function runPerformanceSuite({ runner, adapters, smoke = false }) { + const outputBytes = smoke ? 64 * 1024 : 1024 * 1024; + const jobs = smoke ? 2 : 8; + const scenarioOptions = smoke ? { iterations: 2, warmup: 1 } : {}; + const scenarios = []; + + scenarios.push( + await runner.compare( + 'Process spawn latency', + casesFor( + adapters, + ['echo', 'benchmark'], + (result) => result.exitCode === 0 && result.stdout === '["benchmark"]' + ), + scenarioOptions + ) + ); + scenarios.push( + await runner.compare( + `Buffered stdout throughput (${outputBytes} bytes)`, + casesFor( + adapters, + ['emit', String(outputBytes)], + (result) => + result.exitCode === 0 && + Buffer.byteLength(result.stdout) === outputBytes + ), + scenarioOptions + ) + ); + scenarios.push( + await runner.compare( + `Concurrent execution (${jobs} processes)`, + concurrentCases(adapters, jobs), + scenarioOptions + ) + ); + scenarios.push( + await runner.compare( + 'Non-zero exit handling', + casesFor( + adapters, + ['fail', '17'], + (result) => + result.exitCode === 17 && + result.stderr === 'intentional benchmark failure' + ), + scenarioOptions + ) + ); + scenarios.push( + await runner.compare( + `command-stream output modes (${outputBytes} bytes)`, + { + buffered: { + run: () => bufferedCommand(outputBytes), + validate: ({ received, exitCode }) => + received === outputBytes && exitCode === 0, + }, + streaming: { + run: () => consumeStream(outputBytes), + validate: ({ received, exitCode }) => + received === outputBytes && exitCode === 0, + }, + }, + scenarioOptions + ) + ); + scenarios.push( + await runner.compare( + `command-stream pipeline throughput (${outputBytes} bytes)`, + { + 'pipe() API': { + run: () => programmaticPipeline(outputBytes), + validate: ({ exitCode, received }) => + exitCode === 0 && received === String(outputBytes), + }, + 'manual two-step': { + run: () => bufferedPipeline(outputBytes), + validate: ({ exitCode, received }) => + exitCode === 0 && received === String(outputBytes), + }, + }, + scenarioOptions + ) + ); + scenarios.push( + await runner.compare( + 'command-stream built-in vs system process', + { + 'built-in echo': { + run: builtInEcho, + validate: (output) => output === 'benchmark', + }, + 'spawned workload': { + run: async () => { + const result = await exec( + process.execPath, + [fixture, 'echo', 'benchmark'], + { capture: true, mirror: false, stdin: 'ignore' } + ); + return result.stdout; + }, + validate: (output) => output === '["benchmark"]', + }, + }, + scenarioOptions + ) + ); + + return { + kind: 'performance', + name: 'Performance', + scenarios, + }; +} diff --git a/js/benchmarks/suites/real-world.mjs b/js/benchmarks/suites/real-world.mjs new file mode 100644 index 00000000..6a4a1ed1 --- /dev/null +++ b/js/benchmarks/suites/real-world.mjs @@ -0,0 +1,144 @@ +import { createServer } from 'node:http'; +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const benchmarkDirectory = dirname(dirname(fileURLToPath(import.meta.url))); +const jsDirectory = dirname(benchmarkDirectory); +const fixture = join(benchmarkDirectory, 'fixtures', 'workload.mjs'); + +const adapterCases = (adapters, run, validate) => + Object.fromEntries( + adapters.map((adapter) => [ + adapter.name, + { run: () => run(adapter), validate }, + ]) + ); + +async function createData() { + const directory = await mkdtemp(join(tmpdir(), 'command-stream-benchmark-')); + const files = join(directory, 'files'); + await mkdir(files); + await Promise.all( + Array.from({ length: 12 }, (_, index) => + writeFile( + join(files, `${String(index).padStart(2, '0')}.txt`), + `file-${index}\n` + ) + ) + ); + const log = join(directory, 'application.log'); + const levels = ['INFO', 'INFO', 'WARN', 'INFO', 'ERROR']; + await writeFile( + log, + `${Array.from( + { length: 1000 }, + (_, index) => + `2026-01-01T00:00:${String(index % 60).padStart(2, '0')}Z ${levels[index % levels.length]} event-${index}` + ).join('\n')}\n` + ); + return { directory, files, log }; +} + +export async function startLocalServer() { + const server = createServer((_request, response) => { + response.writeHead(200, { 'content-type': 'text/plain' }); + response.end('benchmark-ok'); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const { port } = server.address(); + return { + url: `http://127.0.0.1:${port}/health`, + close: () => + new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())) + ), + }; +} + +export async function runRealWorldSuite({ runner, adapters, smoke = false }) { + const data = await createData(); + const server = await startLocalServer(); + const options = smoke ? { iterations: 1, warmup: 0 } : {}; + const scenarios = []; + try { + scenarios.push( + await runner.compare( + 'CI/CD validation workflow (two steps)', + adapterCases( + adapters, + (adapter) => + Promise.all([ + adapter.run(process.execPath, [ + fixture, + 'package-version', + join(jsDirectory, 'package.json'), + ]), + adapter.run(process.execPath, [ + fixture, + 'source-digest', + join(jsDirectory, 'src'), + ]), + ]), + (results) => + results.length === 2 && + results.every( + (result) => result.exitCode === 0 && result.stdout.length > 0 + ) + ), + options + ) + ); + scenarios.push( + await runner.compare( + 'Log processing (1,000 records)', + adapterCases( + adapters, + (adapter) => + adapter.run(process.execPath, [fixture, 'log-summary', data.log]), + (result) => + result.exitCode === 0 && + result.stdout === '{"INFO":600,"WARN":200,"ERROR":200}' + ), + options + ) + ); + scenarios.push( + await runner.compare( + 'File operations (12 files)', + adapterCases( + adapters, + (adapter) => + adapter.run(process.execPath, [fixture, 'file-digest', data.files]), + (result) => result.exitCode === 0 && result.stdout.startsWith('12:') + ), + options + ) + ); + scenarios.push( + await runner.compare( + 'Local network command handling', + adapterCases( + adapters, + (adapter) => + adapter.run(process.execPath, [fixture, 'http-get', server.url]), + (result) => + result.exitCode === 0 && result.stdout === '200:benchmark-ok' + ), + options + ) + ); + } finally { + await Promise.all([ + server.close(), + rm(data.directory, { force: true, recursive: true }), + ]); + } + + return { + kind: 'real-world', + name: 'Real-world workloads', + scenarios, + }; +} diff --git a/js/bun.lock b/js/bun.lock index 5df44b9c..6a08fca6 100644 --- a/js/bun.lock +++ b/js/bun.lock @@ -12,15 +12,20 @@ }, "devDependencies": { "@changesets/cli": "^2.31.1", + "cross-spawn": "7.0.6", "dejavu-fonts-ttf": "^2.37.3", + "esbuild": "0.28.2", "eslint": "^9.39.5", "eslint-config-prettier": "^10.1.8", "eslint-plugin-prettier": "^5.5.6", + "execa": "9.6.1", "husky": "^9.1.7", "jscpd": "^4.3.0", "lint-staged": "^16.4.0", "prettier": "^3.9.6", + "shelljs": "0.10.0", "subset-font": "^2.7.0", + "zx": "8.8.5", }, }, }, @@ -71,6 +76,58 @@ "@colors/colors": ["@colors/colors@1.5.0", "", {}, ""], + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.28.2", "", { "os": "android", "cpu": "arm" }, "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.2", "", { "os": "android", "cpu": "arm64" }, "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.28.2", "", { "os": "android", "cpu": "x64" }, "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.2", "", { "os": "linux", "cpu": "arm" }, "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.2", "", { "os": "linux", "cpu": "x64" }, "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.2", "", { "os": "none", "cpu": "x64" }, "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g=="], + "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.0", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, ""], "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, ""], @@ -149,6 +206,10 @@ "@resvg/resvg-js-win32-x64-msvc": ["@resvg/resvg-js-win32-x64-msvc@2.6.2", "", { "os": "win32", "cpu": "x64" }, "sha512-ZXtYhtUr5SSaBrUDq7DiyjOFJqBVL/dOBN7N/qmi/pO0IgiWW/f/ue3nbvu9joWE5aAKDoIzy/CxsY0suwGosQ=="], + "@sec-ant/readable-stream": ["@sec-ant/readable-stream@0.4.1", "", {}, "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg=="], + + "@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@4.0.0", "", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="], + "@types/estree": ["@types/estree@1.0.8", "", {}, ""], "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, ""], @@ -259,6 +320,8 @@ "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, ""], + "esbuild": ["esbuild@0.28.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.2", "@esbuild/android-arm": "0.28.2", "@esbuild/android-arm64": "0.28.2", "@esbuild/android-x64": "0.28.2", "@esbuild/darwin-arm64": "0.28.2", "@esbuild/darwin-x64": "0.28.2", "@esbuild/freebsd-arm64": "0.28.2", "@esbuild/freebsd-x64": "0.28.2", "@esbuild/linux-arm": "0.28.2", "@esbuild/linux-arm64": "0.28.2", "@esbuild/linux-ia32": "0.28.2", "@esbuild/linux-loong64": "0.28.2", "@esbuild/linux-mips64el": "0.28.2", "@esbuild/linux-ppc64": "0.28.2", "@esbuild/linux-riscv64": "0.28.2", "@esbuild/linux-s390x": "0.28.2", "@esbuild/linux-x64": "0.28.2", "@esbuild/netbsd-arm64": "0.28.2", "@esbuild/netbsd-x64": "0.28.2", "@esbuild/openbsd-arm64": "0.28.2", "@esbuild/openbsd-x64": "0.28.2", "@esbuild/openharmony-arm64": "0.28.2", "@esbuild/sunos-x64": "0.28.2", "@esbuild/win32-arm64": "0.28.2", "@esbuild/win32-ia32": "0.28.2", "@esbuild/win32-x64": "0.28.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA=="], + "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, ""], "eslint": ["eslint@9.39.5", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.2", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.6", "@eslint/js": "9.39.5", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw=="], @@ -285,7 +348,7 @@ "eventemitter3": ["eventemitter3@5.0.1", "", {}, ""], - "execa": ["execa@4.1.0", "", { "dependencies": { "cross-spawn": "^7.0.0", "get-stream": "^5.0.0", "human-signals": "^1.1.1", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.0", "onetime": "^5.1.0", "signal-exit": "^3.0.2", "strip-final-newline": "^2.0.0" } }, ""], + "execa": ["execa@9.6.1", "", { "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", "cross-spawn": "^7.0.6", "figures": "^6.1.0", "get-stream": "^9.0.0", "human-signals": "^8.0.1", "is-plain-obj": "^4.1.0", "is-stream": "^4.0.1", "npm-run-path": "^6.0.0", "pretty-ms": "^9.2.0", "signal-exit": "^4.1.0", "strip-final-newline": "^4.0.0", "yoctocolors": "^2.1.1" } }, "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA=="], "extendable-error": ["extendable-error@0.1.7", "", {}, ""], @@ -301,6 +364,8 @@ "fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, ""], + "figures": ["figures@6.1.0", "", { "dependencies": { "is-unicode-supported": "^2.0.0" } }, "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg=="], + "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, ""], "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, ""], @@ -323,7 +388,7 @@ "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, ""], - "get-stream": ["get-stream@5.2.0", "", { "dependencies": { "pump": "^3.0.0" } }, ""], + "get-stream": ["get-stream@9.0.1", "", { "dependencies": { "@sec-ant/readable-stream": "^0.4.1", "is-stream": "^4.0.1" } }, "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA=="], "gifenc": ["gifenc@1.0.3", "", {}, "sha512-xdr6AdrfGBcfzncONUOlXMBuc5wJDtOueE3c5rdG0oNgtINLD+f2iFZltrBRZYzACRbKr+mSVU/x98zv2u3jmw=="], @@ -349,7 +414,7 @@ "human-id": ["human-id@4.1.3", "", { "bin": "dist/cli.js" }, ""], - "human-signals": ["human-signals@1.1.1", "", {}, ""], + "human-signals": ["human-signals@8.0.1", "", {}, "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ=="], "husky": ["husky@9.1.7", "", { "bin": "bin.js" }, ""], @@ -373,14 +438,18 @@ "is-number": ["is-number@7.0.0", "", {}, ""], + "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], + "is-promise": ["is-promise@2.2.2", "", {}, ""], "is-regex": ["is-regex@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, ""], - "is-stream": ["is-stream@2.0.1", "", {}, ""], + "is-stream": ["is-stream@4.0.1", "", {}, "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A=="], "is-subdir": ["is-subdir@1.2.0", "", { "dependencies": { "better-path-resolve": "1.0.0" } }, ""], + "is-unicode-supported": ["is-unicode-supported@2.1.0", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="], + "is-windows": ["is-windows@1.0.2", "", {}, ""], "isexe": ["isexe@2.0.0", "", {}, ""], @@ -447,7 +516,7 @@ "node-sarif-builder": ["node-sarif-builder@4.1.0", "", { "dependencies": { "@types/sarif": "^2.1.7", "fs-extra": "^11.1.1" } }, "sha512-IWqZF6u0EI/07HTBm+zZ+MgXgWl09dnSJRGaDCPBSlOqilDcx6pj3Mpb3HvPN8V2Gr+ISw7ZrMsL7STWs1F++w=="], - "npm-run-path": ["npm-run-path@4.0.1", "", { "dependencies": { "path-key": "^3.0.0" } }, ""], + "npm-run-path": ["npm-run-path@6.0.0", "", { "dependencies": { "path-key": "^4.0.0", "unicorn-magic": "^0.3.0" } }, "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA=="], "object-assign": ["object-assign@4.1.1", "", {}, ""], @@ -475,6 +544,8 @@ "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, ""], + "parse-ms": ["parse-ms@4.0.0", "", {}, "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw=="], + "path-exists": ["path-exists@4.0.0", "", {}, ""], "path-key": ["path-key@3.1.1", "", {}, ""], @@ -495,6 +566,8 @@ "prettier-linter-helpers": ["prettier-linter-helpers@1.0.1", "", { "dependencies": { "fast-diff": "^1.1.2" } }, "sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg=="], + "pretty-ms": ["pretty-ms@9.3.1", "", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-HzMy3Geq23nVALD/M2LliU+F+M+gVNsvkQWWqeBZ8HDiCgzo6YPJ/Omrmtq24EFrIsk0a3EkQGEd7bDOo+IhGA=="], + "promise": ["promise@7.3.1", "", { "dependencies": { "asap": "~2.0.3" } }, ""], "pug": ["pug@3.0.4", "", { "dependencies": { "pug-code-gen": "^3.0.4", "pug-filters": "^4.0.0", "pug-lexer": "^5.0.1", "pug-linker": "^4.0.0", "pug-load": "^3.0.0", "pug-parser": "^6.0.0", "pug-runtime": "^3.0.1", "pug-strip-comments": "^2.0.0" } }, "sha512-kFfq5mMzrS7+wrl5pLJzZEzemx34OQ0w4SARfhy/3yxTlhbstsudDwJzhf1hP02yHzbjoVMSXUj/Sz6RNfMyXg=="], @@ -553,6 +626,8 @@ "shebang-regex": ["shebang-regex@3.0.0", "", {}, ""], + "shelljs": ["shelljs@0.10.0", "", { "dependencies": { "execa": "^5.1.1", "fast-glob": "^3.3.2" } }, "sha512-Jex+xw5Mg2qMZL3qnzXIfaxEtBaC4n7xifqaqtrZDdlheR70OGkydrPJWT0V1cA1k3nanC86x9FwAmQl6w3Klw=="], + "signal-exit": ["signal-exit@4.1.0", "", {}, ""], "slash": ["slash@3.0.0", "", {}, ""], @@ -573,7 +648,7 @@ "strip-bom": ["strip-bom@3.0.0", "", {}, ""], - "strip-final-newline": ["strip-final-newline@2.0.0", "", {}, ""], + "strip-final-newline": ["strip-final-newline@4.0.0", "", {}, "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw=="], "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, ""], @@ -595,6 +670,8 @@ "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, ""], + "unicorn-magic": ["unicorn-magic@0.3.0", "", {}, "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA=="], + "universalify": ["universalify@0.1.2", "", {}, ""], "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, ""], @@ -619,6 +696,10 @@ "yocto-queue": ["yocto-queue@0.1.0", "", {}, ""], + "yoctocolors": ["yoctocolors@2.2.0", "", {}, "sha512-xYqdZFUK/VYazNl/oCDYN+3WloWQwMfZxBoiNt6qNyk+xfOdi598muWE42rNZFp1kNOiqW936q5RhUdnpqElSg=="], + + "zx": ["zx@8.8.5", "", { "bin": { "zx": "build/cli.js" } }, "sha512-SNgDF5L0gfN7FwVOdEFguY3orU5AkfFZm9B5YSHog/UDHv+lvmd82ZAsOenOkQixigwH2+yyH198AwNdKhj+RA=="], + "@changesets/apply-release-plan/prettier": ["prettier@2.8.8", "", { "bin": "bin-prettier.js" }, ""], "@changesets/write/prettier": ["prettier@2.8.8", "", { "bin": "bin-prettier.js" }, ""], @@ -639,9 +720,9 @@ "@manypkg/get-packages/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, ""], - "cli-truncate/string-width": ["string-width@8.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.0", "strip-ansi": "^7.1.0" } }, ""], + "blamer/execa": ["execa@4.1.0", "", { "dependencies": { "cross-spawn": "^7.0.0", "get-stream": "^5.0.0", "human-signals": "^1.1.1", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.0", "onetime": "^5.1.0", "signal-exit": "^3.0.2", "strip-final-newline": "^2.0.0" } }, ""], - "execa/signal-exit": ["signal-exit@3.0.7", "", {}, ""], + "cli-truncate/string-width": ["string-width@8.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.0", "strip-ansi": "^7.1.0" } }, ""], "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, ""], @@ -661,10 +742,14 @@ "node-sarif-builder/fs-extra": ["fs-extra@11.4.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-EQsFzMUJkCKGr1ePqlYADkIUmHW1s3ZXr5Yqy6wbGrfUCphpl2maM/kyOIRA2HpP3AaFQTZXD4ldjek+nccddA=="], + "npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="], + "read-yaml-file/js-yaml": ["js-yaml@3.15.2", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": "bin/js-yaml.js" }, "sha512-6EuL879VkRA+1Cz578mKMiKvjPNEuk6+r1JaFzoSWejZmtf7xWbIyw1e3KkxlkzTIt9Taw6JBhEppG7utc1P+w=="], "restore-cursor/onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, ""], + "shelljs/execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="], + "slice-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, ""], "slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, ""], @@ -689,6 +774,18 @@ "@manypkg/find-root/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, ""], + "blamer/execa/get-stream": ["get-stream@5.2.0", "", { "dependencies": { "pump": "^3.0.0" } }, ""], + + "blamer/execa/human-signals": ["human-signals@1.1.1", "", {}, ""], + + "blamer/execa/is-stream": ["is-stream@2.0.1", "", {}, ""], + + "blamer/execa/npm-run-path": ["npm-run-path@4.0.1", "", { "dependencies": { "path-key": "^3.0.0" } }, ""], + + "blamer/execa/signal-exit": ["signal-exit@3.0.7", "", {}, ""], + + "blamer/execa/strip-final-newline": ["strip-final-newline@2.0.0", "", {}, ""], + "cli-truncate/string-width/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, ""], "jscpd-sarif-reporter/fs-extra/jsonfile": ["jsonfile@6.2.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, ""], @@ -707,6 +804,18 @@ "read-yaml-file/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, ""], + "shelljs/execa/get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="], + + "shelljs/execa/human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="], + + "shelljs/execa/is-stream": ["is-stream@2.0.1", "", {}, ""], + + "shelljs/execa/npm-run-path": ["npm-run-path@4.0.1", "", { "dependencies": { "path-key": "^3.0.0" } }, ""], + + "shelljs/execa/signal-exit": ["signal-exit@3.0.7", "", {}, ""], + + "shelljs/execa/strip-final-newline": ["strip-final-newline@2.0.0", "", {}, ""], + "wrap-ansi/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, ""], "wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, ""], diff --git a/js/package-lock.json b/js/package-lock.json index fee245f2..e6c26167 100644 --- a/js/package-lock.json +++ b/js/package-lock.json @@ -16,15 +16,20 @@ }, "devDependencies": { "@changesets/cli": "^2.31.1", + "cross-spawn": "7.0.6", "dejavu-fonts-ttf": "^2.37.3", + "esbuild": "0.28.2", "eslint": "^9.39.5", "eslint-config-prettier": "^10.1.8", "eslint-plugin-prettier": "^5.5.6", + "execa": "9.6.1", "husky": "^9.1.7", "jscpd": "^4.3.0", "lint-staged": "^16.4.0", "prettier": "^3.9.6", - "subset-font": "^2.7.0" + "shelljs": "0.10.0", + "subset-font": "^2.7.0", + "zx": "8.8.5" }, "engines": { "bun": ">=1.0.0", @@ -326,6 +331,448 @@ "node": ">=0.1.90" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.0", "dev": true, @@ -1050,6 +1497,26 @@ "node": ">= 10" } }, + "node_modules/@sec-ant/readable-stream": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", + "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sindresorhus/merge-streams": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", + "integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@types/estree": { "version": "1.0.8", "dev": true, @@ -1184,37 +1651,130 @@ "node": ">= 10.0.0" } }, - "node_modules/badgen": { - "version": "3.3.2", + "node_modules/badgen": { + "version": "3.3.2", + "dev": true, + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/better-path-resolve": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "is-windows": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/blamer": { + "version": "1.0.7", + "dev": true, + "license": "MIT", + "dependencies": { + "execa": "^4.0.0", + "which": "^2.0.2" + }, + "engines": { + "node": ">=8.9" + } + }, + "node_modules/blamer/node_modules/execa": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/execa/-/execa-4.1.0.tgz", + "integrity": "sha512-j5W0//W7f8UxAn8hXVnwG8tLwdiUy4FJLcSupCg6maBYZDpyBvTApK7KyuI4bKj8KOh1r2YH+6ucuYtJv1bTZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.0", + "get-stream": "^5.0.0", + "human-signals": "^1.1.1", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.0", + "onetime": "^5.1.0", + "signal-exit": "^3.0.2", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/blamer/node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/blamer/node_modules/human-signals": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-1.1.1.tgz", + "integrity": "sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw==", "dev": true, - "license": "MIT" + "license": "Apache-2.0", + "engines": { + "node": ">=8.12.0" + } }, - "node_modules/balanced-match": { - "version": "1.0.2", + "node_modules/blamer/node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } }, - "node_modules/better-path-resolve": { - "version": "1.0.0", + "node_modules/blamer/node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", "dev": true, "license": "MIT", "dependencies": { - "is-windows": "^1.0.0" + "path-key": "^3.0.0" }, "engines": { - "node": ">=4" + "node": ">=8" } }, - "node_modules/blamer": { - "version": "1.0.7", + "node_modules/blamer/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/blamer/node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", "dev": true, "license": "MIT", - "dependencies": { - "execa": "^4.0.0", - "which": "^2.0.2" - }, "engines": { - "node": ">=8.9" + "node": ">=6" } }, "node_modules/brace-expansion": { @@ -1444,6 +2004,8 @@ }, "node_modules/cross-spawn": { "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dev": true, "license": "MIT", "dependencies": { @@ -1525,6 +2087,8 @@ }, "node_modules/end-of-stream": { "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", "dev": true, "license": "MIT", "dependencies": { @@ -1581,6 +2145,48 @@ "node": ">= 0.4" } }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "dev": true, @@ -1791,32 +2397,32 @@ "license": "MIT" }, "node_modules/execa": { - "version": "4.1.0", + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz", + "integrity": "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==", "dev": true, "license": "MIT", "dependencies": { - "cross-spawn": "^7.0.0", - "get-stream": "^5.0.0", - "human-signals": "^1.1.1", - "is-stream": "^2.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^4.0.0", - "onetime": "^5.1.0", - "signal-exit": "^3.0.2", - "strip-final-newline": "^2.0.0" + "@sindresorhus/merge-streams": "^4.0.0", + "cross-spawn": "^7.0.6", + "figures": "^6.1.0", + "get-stream": "^9.0.0", + "human-signals": "^8.0.1", + "is-plain-obj": "^4.1.0", + "is-stream": "^4.0.1", + "npm-run-path": "^6.0.0", + "pretty-ms": "^9.2.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^4.0.0", + "yoctocolors": "^2.1.1" }, "engines": { - "node": ">=10" + "node": "^18.19.0 || >=20.5.0" }, "funding": { "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, - "node_modules/execa/node_modules/signal-exit": { - "version": "3.0.7", - "dev": true, - "license": "ISC" - }, "node_modules/extendable-error": { "version": "0.1.7", "dev": true, @@ -1876,6 +2482,22 @@ "reusify": "^1.0.4" } }, + "node_modules/figures": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz", + "integrity": "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-unicode-supported": "^2.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/file-entry-cache": { "version": "8.0.0", "dev": true, @@ -2009,14 +2631,17 @@ } }, "node_modules/get-stream": { - "version": "5.2.0", + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz", + "integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==", "dev": true, "license": "MIT", "dependencies": { - "pump": "^3.0.0" + "@sec-ant/readable-stream": "^0.4.1", + "is-stream": "^4.0.1" }, "engines": { - "node": ">=8" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -2141,11 +2766,13 @@ } }, "node_modules/human-signals": { - "version": "1.1.1", + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-8.0.1.tgz", + "integrity": "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==", "dev": true, "license": "Apache-2.0", "engines": { - "node": ">=8.12.0" + "node": ">=18.18.0" } }, "node_modules/husky": { @@ -2285,6 +2912,19 @@ "node": ">=0.12.0" } }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-promise": { "version": "2.2.2", "dev": true, @@ -2308,11 +2948,13 @@ } }, "node_modules/is-stream": { - "version": "2.0.1", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", + "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", "dev": true, "license": "MIT", "engines": { - "node": ">=8" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -2329,6 +2971,19 @@ "node": ">=4" } }, + "node_modules/is-unicode-supported": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-windows": { "version": "1.0.2", "dev": true, @@ -2648,6 +3303,8 @@ }, "node_modules/merge-stream": { "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", "dev": true, "license": "MIT" }, @@ -2684,6 +3341,8 @@ }, "node_modules/mimic-fn": { "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", "dev": true, "license": "MIT", "engines": { @@ -2787,14 +3446,33 @@ } }, "node_modules/npm-run-path": { - "version": "4.0.1", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz", + "integrity": "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==", "dev": true, "license": "MIT", "dependencies": { - "path-key": "^3.0.0" + "path-key": "^4.0.0", + "unicorn-magic": "^0.3.0" }, "engines": { - "node": ">=8" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/object-assign": { @@ -2807,6 +3485,8 @@ }, "node_modules/once": { "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", "dev": true, "license": "ISC", "dependencies": { @@ -2815,6 +3495,8 @@ }, "node_modules/onetime": { "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", "dev": true, "license": "MIT", "dependencies": { @@ -2941,6 +3623,19 @@ "node": ">=6" } }, + "node_modules/parse-ms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz", + "integrity": "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/path-exists": { "version": "4.0.0", "dev": true, @@ -3027,6 +3722,22 @@ "node": ">=6.0.0" } }, + "node_modules/pretty-ms": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.1.tgz", + "integrity": "sha512-HzMy3Geq23nVALD/M2LliU+F+M+gVNsvkQWWqeBZ8HDiCgzo6YPJ/Omrmtq24EFrIsk0a3EkQGEd7bDOo+IhGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "parse-ms": "^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/promise": { "version": "7.3.1", "dev": true, @@ -3148,7 +3859,9 @@ "license": "MIT" }, "node_modules/pump": { - "version": "3.0.3", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", "dev": true, "license": "MIT", "dependencies": { @@ -3369,6 +4082,110 @@ "node": ">=8" } }, + "node_modules/shelljs": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/shelljs/-/shelljs-0.10.0.tgz", + "integrity": "sha512-Jex+xw5Mg2qMZL3qnzXIfaxEtBaC4n7xifqaqtrZDdlheR70OGkydrPJWT0V1cA1k3nanC86x9FwAmQl6w3Klw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "execa": "^5.1.1", + "fast-glob": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/shelljs/node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/shelljs/node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/shelljs/node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/shelljs/node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/shelljs/node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shelljs/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/shelljs/node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/signal-exit": { "version": "4.1.0", "dev": true, @@ -3488,11 +4305,16 @@ } }, "node_modules/strip-final-newline": { - "version": "2.0.0", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz", + "integrity": "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==", "dev": true, "license": "MIT", "engines": { - "node": ">=6" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/strip-json-comments": { @@ -3598,6 +4420,19 @@ "node": ">= 0.8.0" } }, + "node_modules/unicorn-magic": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", + "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/universalify": { "version": "0.1.2", "dev": true, @@ -3753,6 +4588,8 @@ }, "node_modules/wrappy": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "dev": true, "license": "ISC" }, @@ -3780,6 +4617,32 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/yoctocolors": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.2.0.tgz", + "integrity": "sha512-xYqdZFUK/VYazNl/oCDYN+3WloWQwMfZxBoiNt6qNyk+xfOdi598muWE42rNZFp1kNOiqW936q5RhUdnpqElSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zx": { + "version": "8.8.5", + "resolved": "https://registry.npmjs.org/zx/-/zx-8.8.5.tgz", + "integrity": "sha512-SNgDF5L0gfN7FwVOdEFguY3orU5AkfFZm9B5YSHog/UDHv+lvmd82ZAsOenOkQixigwH2+yyH198AwNdKhj+RA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "zx": "build/cli.js" + }, + "engines": { + "node": ">= 12.17.0" + } } } } diff --git a/js/package.json b/js/package.json index 09b9c0ec..fb554024 100644 --- a/js/package.json +++ b/js/package.json @@ -33,6 +33,13 @@ "test:sync": "cd .. && bun test js/tests/sync.test.mjs --timeout 10000", "test:builtin": "cd .. && bun test js/tests/builtin-commands.test.mjs --timeout 10000", "test:pipe": "cd .. && bun test js/tests/pipe.test.mjs --timeout 10000", + "benchmark": "bun benchmarks/cli.mjs", + "benchmark:bundle": "bun benchmarks/cli.mjs --suite bundle-size", + "benchmark:features": "bun benchmarks/cli.mjs --suite features", + "benchmark:performance": "bun benchmarks/cli.mjs --suite performance", + "benchmark:real-world": "bun benchmarks/cli.mjs --suite real-world", + "benchmark:smoke": "bun benchmarks/cli.mjs --smoke", + "benchmark:test": "cd .. && bun test js/tests/benchmark-suite.test.mjs --timeout 10000", "lint": "cd .. && js/node_modules/.bin/eslint . --max-warnings 0", "lint:fix": "cd .. && js/node_modules/.bin/eslint . --fix --max-warnings 0", "format": "cd .. && js/node_modules/.bin/prettier --write .", @@ -72,15 +79,20 @@ ], "devDependencies": { "@changesets/cli": "^2.31.1", + "cross-spawn": "7.0.6", "dejavu-fonts-ttf": "^2.37.3", + "esbuild": "0.28.2", "eslint": "^9.39.5", "eslint-config-prettier": "^10.1.8", "eslint-plugin-prettier": "^5.5.6", + "execa": "9.6.1", "husky": "^9.1.7", "jscpd": "^4.3.0", "lint-staged": "^16.4.0", "prettier": "^3.9.6", - "subset-font": "^2.7.0" + "shelljs": "0.10.0", + "subset-font": "^2.7.0", + "zx": "8.8.5" }, "dependencies": { "@resvg/resvg-js": "^2.6.2", diff --git a/js/tests/benchmark-suite.test.mjs b/js/tests/benchmark-suite.test.mjs new file mode 100644 index 00000000..7ecc3bed --- /dev/null +++ b/js/tests/benchmark-suite.test.mjs @@ -0,0 +1,264 @@ +import { describe, expect, test } from 'bun:test'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { connect } from 'node:net'; +import { join } from 'node:path'; +import { + BenchmarkRunner, + summarizeSamples, +} from '../benchmarks/lib/benchmark-runner.mjs'; +import { + EXPECTED_ADAPTERS, + executableForZx, + loadCompetitorAdapters, +} from '../benchmarks/lib/competitor-adapters.mjs'; +import { escapeHtml, writeReports } from '../benchmarks/lib/report.mjs'; +import { parseArguments } from '../benchmarks/cli.mjs'; +import { parseNpmPackOutput } from '../benchmarks/suites/bundle-size.mjs'; +import { startLocalServer } from '../benchmarks/suites/real-world.mjs'; +import { + compareBenchmarkReports, + regressionMarkdown, +} from '../benchmarks/lib/regression.mjs'; + +describe('benchmark statistics', () => { + test('reports stable distribution statistics without rounding source data', () => { + expect(summarizeSamples([1, 2, 3, 4])).toEqual({ + samples: 4, + meanMs: 2.5, + medianMs: 2.5, + minMs: 1, + maxMs: 4, + p95Ms: 4, + p99Ms: 4, + standardDeviationMs: Math.sqrt(1.25), + operationsPerSecond: 400, + }); + }); + + test('fails the suite when a measured result is invalid', async () => { + const runner = new BenchmarkRunner({ iterations: 2, warmup: 0 }); + + await expect( + runner.compare('validation', { + broken: { + run: async () => 'wrong', + validate: (value) => value === 'expected', + }, + }) + ).rejects.toThrow('validation failed'); + }); + + test('measures every implementation the requested number of times', async () => { + const calls = { alpha: 0, beta: 0 }; + const runner = new BenchmarkRunner({ iterations: 3, warmup: 2 }); + const result = await runner.compare('complete sample', { + alpha: { + run: async () => ++calls.alpha, + validate: Number.isInteger, + }, + beta: { + run: async () => ++calls.beta, + validate: Number.isInteger, + }, + }); + + expect(calls).toEqual({ alpha: 5, beta: 5 }); + expect(result.implementations.alpha.samples).toBe(3); + expect(result.implementations.beta.samples).toBe(3); + expect(result.ranking.map(({ name }) => name).sort()).toEqual([ + 'alpha', + 'beta', + ]); + }); +}); + +describe('competitor adapters', () => { + test('makes Windows executables addressable by zx default Bash', () => { + expect(executableForZx('C:\\Program Files\\Bun\\bun.exe', 'win32')).toBe( + 'C:/Program Files/Bun/bun.exe' + ); + expect(executableForZx('/usr/bin/bun', 'linux')).toBe('/usr/bin/bun'); + }); + + test('executes the same exact-argv workload through every available API', async () => { + const adapters = await loadCompetitorAdapters(); + const names = adapters.map(({ name }) => name); + + expect(names).toEqual( + EXPECTED_ADAPTERS.filter( + (name) => name !== 'Bun.$' || typeof globalThis.Bun !== 'undefined' + ) + ); + + for (const adapter of adapters) { + expect(adapter.version.length).toBeGreaterThan(0); + const result = await adapter.run(process.execPath, [ + '-e', + 'process.stdout.write(JSON.stringify(process.argv.slice(1)))', + 'hello world', + '$literal', + ]); + expect(`${adapter.name}: ${result.exitCode}`).toBe(`${adapter.name}: 0`); + expect(JSON.parse(result.stdout)).toEqual(['hello world', '$literal']); + expect(result.stderr).toBe(''); + } + }); +}); + +describe('benchmark reports', () => { + test('escapes measured labels before writing HTML', () => { + expect(escapeHtml('')).toBe( + '<script>"x" & y</script>' + ); + }); + + test('writes machine-readable and interactive reports', async () => { + const outputDirectory = mkdtempSync(join(tmpdir(), 'benchmark-report-')); + try { + const paths = await writeReports( + { + schemaVersion: 1, + generatedAt: '2026-09-15T00:00:00.000Z', + environment: { runtime: 'test' }, + suites: [], + }, + outputDirectory + ); + expect(paths.json.endsWith('benchmark-results.json')).toBe(true); + expect(paths.html.endsWith('benchmark-report.html')).toBe(true); + } finally { + rmSync(outputDirectory, { force: true, recursive: true }); + } + }); +}); + +describe('benchmark CLI inputs', () => { + test('parses focused playground options', () => { + const options = parseArguments([ + '--suite', + 'performance,features', + '--adapter', + 'command-stream,execa', + '--iterations', + '7', + '--warmup', + '1', + '--smoke', + ]); + expect(options.suites).toEqual(['performance', 'features']); + expect(options.adapters).toEqual(['command-stream', 'execa']); + expect(options.iterations).toBe(7); + expect(options.warmup).toBe(1); + expect(options.smoke).toBe(true); + }); + + test('rejects unknown suites before running commands', () => { + expect(() => parseArguments(['--suite', 'imaginary'])).toThrow( + 'Unknown suite: imaginary' + ); + expect(() => parseArguments(['--iterations'])).toThrow( + '--iterations expects a value' + ); + expect(() => parseArguments(['--adapter', ''])).toThrow( + 'Unknown adapter: (empty)' + ); + }); + + test('accepts npm 10 array and npm 12 keyed pack output', () => { + const record = { size: 123, unpackedSize: 456, entryCount: 7 }; + expect(parseNpmPackOutput(JSON.stringify([record]))).toEqual({ + packedBytes: 123, + unpackedBytes: 456, + fileCount: 7, + }); + expect( + parseNpmPackOutput(JSON.stringify({ 'example-package': record })) + ).toEqual({ + packedBytes: 123, + unpackedBytes: 456, + fileCount: 7, + }); + }); +}); + +describe('benchmark regression comparison', () => { + const report = (medianMs, generatedAt) => ({ + generatedAt, + suites: [ + { + kind: 'performance', + name: 'Performance', + scenarios: [ + { + name: 'spawn', + implementations: { command: { medianMs } }, + }, + ], + }, + ], + }); + + test('classifies material changes while retaining exact measurements', () => { + const comparison = compareBenchmarkReports( + report(10, 'before'), + report(13, 'after'), + { thresholdPercent: 20, minimumAbsoluteMs: 2 } + ); + expect(comparison.summary).toEqual({ + compared: 1, + regressions: 1, + improvements: 0, + stable: 0, + }); + expect(comparison.comparisons[0]).toMatchObject({ + baselineMedianMs: 10, + currentMedianMs: 13, + deltaMs: 3, + deltaPercent: 30, + status: 'regression', + }); + expect(regressionMarkdown(comparison)).toContain('| regression |'); + }); + + test('does not classify sub-millisecond noise as a regression', () => { + const comparison = compareBenchmarkReports( + report(1, 'before'), + report(1.5, 'after') + ); + expect(comparison.summary.stable).toBe(1); + }); +}); + +describe('real-world benchmark fixtures', () => { + test('handles an HTTP request split across packets', async () => { + const server = await startLocalServer(); + try { + const { hostname, port, pathname } = new URL(server.url); + const response = await new Promise((resolve, reject) => { + const chunks = []; + const socket = connect(Number(port), hostname, () => { + socket.write('G'); + setTimeout( + () => + socket.end( + `ET ${pathname} HTTP/1.1\r\nHost: ${hostname}:${port}\r\nConnection: close\r\n\r\n` + ), + 10 + ); + }); + socket.setTimeout(2_000, () => + socket.destroy(new Error('fragmented HTTP request timed out')) + ); + socket.on('data', (chunk) => chunks.push(chunk)); + socket.on('end', () => resolve(Buffer.concat(chunks).toString())); + socket.on('error', reject); + }); + + expect(response).toContain('HTTP/1.1 200 OK'); + expect(response).toContain('\r\nbenchmark-ok\r\n'); + } finally { + await server.close(); + } + }); +}); diff --git a/js/tests/language-parity.test.mjs b/js/tests/language-parity.test.mjs new file mode 100644 index 00000000..a5ef4d47 --- /dev/null +++ b/js/tests/language-parity.test.mjs @@ -0,0 +1,90 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import { execFileSync, spawnSync } from 'node:child_process'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..'); +const parityScript = join( + repoRoot, + '.github', + 'scripts', + 'check-language-parity.sh' +); +const repositories = []; + +function git(directory, ...args) { + return execFileSync('git', args, { cwd: directory, encoding: 'utf8' }); +} + +function parityResult(changedFiles) { + const directory = mkdtempSync(join(tmpdir(), 'command-stream-parity-')); + repositories.push(directory); + git(directory, 'init', '--initial-branch=main', '--quiet'); + git(directory, 'config', 'user.email', 'tests@command-stream.invalid'); + git(directory, 'config', 'user.name', 'command-stream tests'); + + for (const path of [ + 'js/src/.keep', + 'rust/src/.keep', + 'js/benchmarks/.keep', + 'rust/benchmarks/.keep', + ]) { + const absolute = join(directory, path); + mkdirSync(dirname(absolute), { recursive: true }); + writeFileSync(absolute, 'base\n'); + } + git(directory, 'add', '.'); + git(directory, 'commit', '--quiet', '--message', 'base'); + git(directory, 'switch', '--quiet', '--create', 'feature'); + + for (const path of changedFiles) { + writeFileSync(join(directory, path), 'changed\n'); + } + git(directory, 'add', '.'); + git(directory, 'commit', '--quiet', '--message', 'feature'); + + return spawnSync('bash', [parityScript], { + cwd: directory, + encoding: 'utf8', + env: { ...process.env, BASE_REF: 'main' }, + }); +} + +afterEach(() => { + while (repositories.length > 0) { + rmSync(repositories.pop(), { force: true, recursive: true }); + } +}); + +describe.skipIf(process.platform === 'win32')('language parity guard', () => { + test.each([ + ['JavaScript source', 'js/src/.keep', 'Rust source'], + ['Rust source', 'rust/src/.keep', 'JavaScript source'], + ['JavaScript benchmarks', 'js/benchmarks/.keep', 'Rust benchmarks'], + ['Rust benchmarks', 'rust/benchmarks/.keep', 'JavaScript benchmarks'], + ])('%s-only changes fail', (_language, path, expectedMessage) => { + const result = parityResult([path]); + + expect(result.status).toBe(1); + expect(result.stdout).toContain(expectedMessage); + }); + + test('paired benchmark changes pass', () => { + const result = parityResult([ + 'js/benchmarks/.keep', + 'rust/benchmarks/.keep', + ]); + + expect(result.status).toBe(0); + expect(result.stdout).toContain('Language parity check passed.'); + }); + + test('a benchmark edit cannot stand in for a source implementation', () => { + const result = parityResult(['js/src/.keep', 'rust/benchmarks/.keep']); + + expect(result.status).toBe(1); + expect(result.stdout).toContain('Rust source'); + }); +}); diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 5a79d85a..a2444168 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -9,6 +9,7 @@ repository = "https://github.com/link-foundation/command-stream" keywords = ["shell", "command", "streaming", "async", "process"] categories = ["command-line-utilities", "asynchronous"] readme = "README.md" +exclude = ["benchmarks/**"] [lib] name = "command_stream" diff --git a/rust/README.md b/rust/README.md index 3fa16823..db859b59 100644 --- a/rust/README.md +++ b/rust/README.md @@ -303,6 +303,14 @@ competitor-specific tests are accounted for in the Run the focused executable corpus with `cargo test --test competitor_compatibility`. +The [Rust benchmark playground](benchmarks/README.md) turns six of those native +process-library mappings into validated performance, crate-footprint, +feature-coverage, and real-world comparisons. Its CI-sized profile is: + +```bash +cargo run --release --locked --manifest-path benchmarks/Cargo.toml -- --smoke +``` + - Shell parser for pipelines, command lists, logical operators, and redirection. - Built-in command implementations for file-system and shell utility commands. - Async execution with `tokio`. diff --git a/rust/benchmarks/.gitignore b/rust/benchmarks/.gitignore new file mode 100644 index 00000000..93f19f45 --- /dev/null +++ b/rust/benchmarks/.gitignore @@ -0,0 +1,3 @@ +/baseline/ +/results/ +/target/ diff --git a/rust/benchmarks/Cargo.lock b/rust/benchmarks/Cargo.lock new file mode 100644 index 00000000..596e5222 --- /dev/null +++ b/rust/benchmarks/Cargo.lock @@ -0,0 +1,1300 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "blocking" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3eb0f42d6c360dc3f8a821f6bf2fdea7f72bfd36b3076eb0e6d1e9e0752fff4" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chrono" +version = "0.4.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "command-stream" +version = "0.18.6" +dependencies = [ + "async-trait", + "chrono", + "filetime", + "glob", + "libc", + "nix 0.29.0", + "once_cell", + "portable-pty", + "regex", + "serde", + "serde_json", + "thiserror 2.0.20", + "tokio", + "vt100", + "which", +] + +[[package]] +name = "command-stream-benchmarks" +version = "0.1.0" +dependencies = [ + "async-process", + "chrono", + "command-stream", + "duct", + "futures", + "serde", + "serde_json", + "subprocess", + "tempfile", + "tokio", + "xshell", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "duct" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61b9e2a29ff01e8bb526a571ad06c10ed72aae80d5999ed204f7971f99f19974" +dependencies = [ + "libc", + "os_pipe", + "shared_child", + "shared_thread", +] + +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" + +[[package]] +name = "env_home" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f84e12ccf0a7ddc17a6c41c93326024c42920d7ee630d04950e6926645c0fe" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mio" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "nix" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases 0.1.1", + "libc", +] + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases 0.2.2", + "libc", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "os_pipe" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys", +] + +[[package]] +name = "portable-pty" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4a596a2b3d2752d94f51fac2d4a96737b8705dddd311a32b9af47211f08671e" +dependencies = [ + "anyhow", + "bitflags 1.3.2", + "downcast-rs", + "filedescriptor", + "lazy_static", + "libc", + "log", + "nix 0.28.0", + "serial2", + "shared_library", + "shell-words", + "winapi", + "winreg", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "serde_json" +version = "1.0.148" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3084b546a1dd6289475996f182a22aba973866ea8e8b02c51d9f46b1336a22da" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serial2" +version = "0.2.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b16809bc35793b19ce4e0c53924bc0dce3937f15487997cfdaed936004180730" +dependencies = [ + "cfg-if", + "libc", + "windows-sys", +] + +[[package]] +name = "shared_child" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "607549934f6cc26b89cfecfdc46fa90f1e5d1536a68349b0c3a4f9d1c0d37959" +dependencies = [ + "libc", + "sigchld", + "windows-sys", +] + +[[package]] +name = "shared_library" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11" +dependencies = [ + "lazy_static", + "libc", +] + +[[package]] +name = "shared_thread" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de1c6cdf07f3a4b1900680728ac1a12f72aa6424f37138f9253116461576e00f" + +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "sigchld" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24f2b37f04360cd465089b87a9c3869c08220a2f3458463f0adf8badf5e77f2c" +dependencies = [ + "libc", + "os_pipe", + "signal-hook", +] + +[[package]] +name = "signal-hook" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a0c28ca5908dbdbcd52e6fdaa00358ab88637f8ab33e1f188dd510eb44b53d" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "subprocess" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5c2982c58b661c6509861bd3383870b6918030606e8b5afb0ade14b4a3cff12" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl 2.0.20", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "vt100" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84cd863bf0db7e392ba3bd04994be3473491b31e66340672af5d11943c6274de" +dependencies = [ + "itoa", + "log", + "unicode-width", + "vte", +] + +[[package]] +name = "vte" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5022b5fbf9407086c180e9557be968742d839e68346af7792b8592489732197" +dependencies = [ + "arrayvec", + "utf8parse", + "vte_generate_state_changes", +] + +[[package]] +name = "vte_generate_state_changes" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e369bee1b05d510a7b4ed645f5faa90619e05437111783ea5848f28d97d3c2e" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.5", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "which" +version = "7.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d643ce3fd3e5b54854602a080f34fb10ab75e0b813ee32d00ca2b44fa74762" +dependencies = [ + "either", + "env_home", + "rustix", + "winsafe", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winreg" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" +dependencies = [ + "winapi", +] + +[[package]] +name = "winsafe" +version = "0.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" + +[[package]] +name = "xshell" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e7290c623014758632efe00737145b6867b66292c42167f2ec381eb566a373d" +dependencies = [ + "xshell-macros", +] + +[[package]] +name = "xshell-macros" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32ac00cd3f8ec9c1d33fb3e7958a82df6989c42d747bd326c822b1d625283547" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/rust/benchmarks/Cargo.toml b/rust/benchmarks/Cargo.toml new file mode 100644 index 00000000..f7e94aa3 --- /dev/null +++ b/rust/benchmarks/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "command-stream-benchmarks" +version = "0.1.0" +edition = "2021" +publish = false +default-run = "command-stream-benchmarks" + +[dependencies] +async-process = "=2.5.0" +chrono = "=0.4.42" +command-stream = { path = ".." } +duct = "=1.1.2" +futures = "=0.3.34" +serde = { version = "=1.0.228", features = ["derive"] } +serde_json = "=1.0.148" +subprocess = "=1.2.1" +tempfile = "=3.27.0" +tokio = { version = "=1.53.1", features = ["full", "process"] } +xshell = "=0.2.7" + +[lints.rust] +unsafe_code = "forbid" + +[lints.clippy] +all = { level = "warn", priority = -1 } diff --git a/rust/benchmarks/README.md b/rust/benchmarks/README.md new file mode 100644 index 00000000..378f72c1 --- /dev/null +++ b/rust/benchmarks/README.md @@ -0,0 +1,81 @@ +# command-stream Rust benchmarks + +This package measures the Rust implementation against `std::process`, Tokio +process, async-process, duct, subprocess, and xshell. Every timed operation runs +the same executable and arguments and validates its output before the sample is +accepted. Versions are pinned in `Cargo.lock`. + +The reports are a reproducible benchmark playground, not a universal speed +claim. Compare implementations within one report: hardware, operating system, +toolchain, CPU load, and filesystem state all affect timings. + +## Quick start + +From the `rust/` directory, run the CI-sized profile: + +```bash +cargo run --release --locked --manifest-path benchmarks/Cargo.toml -- --smoke +``` + +Run the complete profile with 30 measured iterations and 5 warmups: + +```bash +cargo run --release --locked --manifest-path benchmarks/Cargo.toml +``` + +Focus on a suite or API with the CLI: + +```bash +cargo run --release --locked --manifest-path benchmarks/Cargo.toml -- --list +cargo run --release --locked --manifest-path benchmarks/Cargo.toml -- \ + --suite performance --adapter command-stream,xshell +cargo run --release --locked --manifest-path benchmarks/Cargo.toml -- \ + --suite crate-size,features +``` + +Reports are written to `benchmarks/results/benchmark-results.json` and +`benchmark-report.html`. CI uploads both. Once the suite exists on the base +branch, pull requests also benchmark base and head with the same smoke profile +and produce machine-readable and Markdown comparisons. + +## Measurements + +| Suite | Measurements | +| ----------- | ------------------------------------------------------------------------------------------------------------ | +| Performance | Exact-argument spawn latency, buffered stdout, concurrency, and nonzero exits. | +| Rust APIs | command-stream buffering versus streaming, pipeline versus manual handoff, and built-in versus spawned echo. | +| Crate size | Resolved crate source bytes and unique transitive source-closure bytes. | +| Features | Ported behavior and known-gap counts from immutable upstream Rust test corpora. | +| Real-world | Parallel CI checks, log analysis, file hashing, and a local HTTP health check. | + +The timing runner rotates API order to reduce first-position bias and records +mean, median, min, max, standard deviation, p95, p99, and operations per second. +Median determines the ranking. A failed process or invalid output aborts the +scenario instead of recording a misleading sample. + +Crate footprint is computed from `cargo metadata --locked`. It counts each +resolved source tree once, excludes VCS/build output and this benchmark package, +and reports `std::process` as zero because it ships with Rust. It measures source +footprint, not final binary size; compiler settings and which APIs an application +uses determine binary size. + +Feature counts come directly from `tests/competitor_dispositions.jsonl`, which +pins upstream sources to immutable commits and records both executable ports and +explicit gaps. Run `cargo test --test competitor_compatibility` in `rust/` to +execute that compatibility corpus. + +## Base/head comparison + +Compare two generated reports: + +```bash +cargo run --release --locked --manifest-path benchmarks/Cargo.toml \ + --bin compare -- \ + --baseline benchmarks/baseline/benchmark-results.json \ + --current benchmarks/results/benchmark-results.json \ + --output benchmarks/results +``` + +The default review signal is a change of at least 15% and 2 ms. Classification +is informational because shared CI machines are noisy; confirm possible +regressions with repeated runs on a controlled host. diff --git a/rust/benchmarks/src/adapters.rs b/rust/benchmarks/src/adapters.rs new file mode 100644 index 00000000..138764ef --- /dev/null +++ b/rust/benchmarks/src/adapters.rs @@ -0,0 +1,241 @@ +use crate::model::AdapterMetadata; +use crate::BenchmarkResult; +use std::path::{Path, PathBuf}; +use std::process::Output; + +pub const EXPECTED_ADAPTERS: &[&str] = &[ + "command-stream", + "std::process", + "Tokio process", + "async-process", + "duct", + "subprocess", + "xshell", +]; + +#[derive(Debug, Clone, Copy)] +pub enum Adapter { + CommandStream, + StdProcess, + TokioProcess, + AsyncProcess, + Duct, + Subprocess, + Xshell, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Execution { + pub exit_code: i32, + pub stdout: Vec, + pub stderr: Vec, +} + +impl Adapter { + pub fn all() -> Vec { + vec![ + Self::CommandStream, + Self::StdProcess, + Self::TokioProcess, + Self::AsyncProcess, + Self::Duct, + Self::Subprocess, + Self::Xshell, + ] + } + + pub fn name(self) -> &'static str { + match self { + Self::CommandStream => "command-stream", + Self::StdProcess => "std::process", + Self::TokioProcess => "Tokio process", + Self::AsyncProcess => "async-process", + Self::Duct => "duct", + Self::Subprocess => "subprocess", + Self::Xshell => "xshell", + } + } + + pub fn version(self) -> String { + match self { + Self::CommandStream => command_stream_version(), + Self::StdProcess => format!("{} standard library", rustc_version()), + Self::TokioProcess => "1.53.1".to_string(), + Self::AsyncProcess => "2.5.0".to_string(), + Self::Duct => "1.1.2".to_string(), + Self::Subprocess => "1.2.1".to_string(), + Self::Xshell => "0.2.7".to_string(), + } + } + + pub fn metadata(self) -> AdapterMetadata { + AdapterMetadata { + name: self.name().to_string(), + version: self.version(), + } + } + + pub async fn run( + self, + program: impl AsRef, + arguments: &[String], + ) -> BenchmarkResult { + let program = program.as_ref().to_path_buf(); + let arguments = arguments.to_vec(); + match self { + Self::CommandStream => { + let result = command_stream::StreamingRunner::from_argv(program, arguments) + .collect() + .await?; + Ok(Execution { + exit_code: result.code, + stdout: result.stdout.into_bytes(), + stderr: result.stderr.into_bytes(), + }) + } + Self::TokioProcess => { + let output = tokio::process::Command::new(program) + .args(arguments) + .output() + .await?; + Ok(output.into()) + } + Self::AsyncProcess => { + let output = async_process::Command::new(program) + .args(arguments) + .output() + .await?; + Ok(output.into()) + } + Self::StdProcess => { + run_blocking(move || std::process::Command::new(program).args(arguments).output()) + .await + } + Self::Duct => { + run_blocking(move || { + duct::cmd(program, arguments) + .stdout_capture() + .stderr_capture() + .unchecked() + .run() + }) + .await + } + Self::Subprocess => { + let capture = tokio::task::spawn_blocking(move || { + subprocess::Exec::cmd(program.into_os_string()) + .args(&arguments) + .capture() + }) + .await??; + let exit_code = capture + .exit_status + .code() + .and_then(|code| i32::try_from(code).ok()) + .or_else(|| capture.exit_status.signal().map(|signal| 128 + signal)) + .unwrap_or(1); + Ok(Execution { + exit_code, + stdout: capture.stdout, + stderr: capture.stderr, + }) + } + Self::Xshell => { + run_blocking(move || { + let shell = xshell::Shell::new().map_err(std::io::Error::other)?; + xshell::cmd!(shell, "{program} {arguments...}") + .quiet() + .ignore_status() + .output() + .map_err(std::io::Error::other) + }) + .await + } + } + } +} + +fn command_stream_version() -> String { + include_str!("../../Cargo.toml") + .lines() + .find_map(|line| { + line.trim() + .strip_prefix("version = \"") + .and_then(|value| value.strip_suffix('"')) + }) + .unwrap_or("unknown") + .to_string() +} + +fn rustc_version() -> String { + std::process::Command::new("rustc") + .arg("--version") + .output() + .ok() + .filter(|output| output.status.success()) + .and_then(|output| String::from_utf8(output.stdout).ok()) + .map_or_else(|| "Rust".to_string(), |value| value.trim().to_string()) +} + +async fn run_blocking(operation: F) -> BenchmarkResult +where + F: FnOnce() -> std::io::Result + Send + 'static, +{ + let output = tokio::task::spawn_blocking(operation).await??; + Ok(output.into()) +} + +impl From for Execution { + fn from(output: Output) -> Self { + Self { + exit_code: exit_code(&output.status), + stdout: output.stdout, + stderr: output.stderr, + } + } +} + +fn exit_code(status: &std::process::ExitStatus) -> i32 { + if let Some(code) = status.code() { + return code; + } + #[cfg(unix)] + { + use std::os::unix::process::ExitStatusExt; + status.signal().map_or(1, |signal| 128 + signal) + } + #[cfg(not(unix))] + 1 +} + +pub fn select_adapters(names: Option<&[String]>) -> BenchmarkResult> { + let available = Adapter::all(); + let Some(names) = names else { + return Ok(available); + }; + let selected = available + .into_iter() + .filter(|adapter| names.iter().any(|name| name == adapter.name())) + .collect::>(); + let unavailable = names + .iter() + .filter(|name| !selected.iter().any(|adapter| adapter.name() == *name)) + .cloned() + .collect::>(); + if unavailable.is_empty() { + Ok(selected) + } else { + Err(format!("unknown adapter: {}", unavailable.join(", ")).into()) + } +} + +pub fn fixture_arguments(mode: &str, values: &[String]) -> Vec { + std::iter::once("__fixture".to_string()) + .chain(std::iter::once(mode.to_string())) + .chain(values.iter().cloned()) + .collect() +} + +pub fn benchmark_executable() -> BenchmarkResult { + Ok(std::env::current_exe()?) +} diff --git a/rust/benchmarks/src/bin/compare.rs b/rust/benchmarks/src/bin/compare.rs new file mode 100644 index 00000000..92664074 --- /dev/null +++ b/rust/benchmarks/src/bin/compare.rs @@ -0,0 +1,85 @@ +use command_stream_benchmarks::model::Report; +use command_stream_benchmarks::regression::{compare_reports, comparison_markdown}; +use command_stream_benchmarks::BenchmarkResult; +use std::fs; +use std::path::PathBuf; + +struct Options { + baseline: PathBuf, + current: PathBuf, + output: PathBuf, + threshold_percent: f64, + minimum_absolute_ms: f64, +} + +fn main() { + if let Err(error) = run() { + eprintln!("{error}"); + std::process::exit(1); + } +} + +fn run() -> BenchmarkResult<()> { + let options = parse_arguments(&std::env::args().skip(1).collect::>())?; + let baseline: Report = serde_json::from_str(&fs::read_to_string(&options.baseline)?)?; + let current: Report = serde_json::from_str(&fs::read_to_string(&options.current)?)?; + let comparison = compare_reports( + &baseline, + ¤t, + options.threshold_percent, + options.minimum_absolute_ms, + ); + fs::create_dir_all(&options.output)?; + let json = options.output.join("benchmark-comparison.json"); + let markdown = options.output.join("benchmark-comparison.md"); + fs::write( + &json, + format!("{}\n", serde_json::to_string_pretty(&comparison)?), + )?; + fs::write(&markdown, comparison_markdown(&comparison))?; + println!("JSON: {}", json.display()); + println!("Markdown: {}", markdown.display()); + Ok(()) +} + +fn parse_arguments(arguments: &[String]) -> BenchmarkResult { + let mut baseline = None; + let mut current = None; + let mut output = PathBuf::from("benchmarks/results/comparison"); + let mut threshold_percent = 15.0; + let mut minimum_absolute_ms = 2.0; + let mut index = 0; + while index < arguments.len() { + let flag = &arguments[index]; + index += 1; + let value = arguments + .get(index) + .ok_or_else(|| format!("{flag} expects a value"))?; + match flag.as_str() { + "--baseline" => baseline = Some(PathBuf::from(value)), + "--current" => current = Some(PathBuf::from(value)), + "--output" => output = PathBuf::from(value), + "--threshold-percent" => threshold_percent = positive_number(value, flag)?, + "--minimum-absolute-ms" => minimum_absolute_ms = positive_number(value, flag)?, + _ => return Err(format!("unknown argument: {flag}").into()), + } + index += 1; + } + Ok(Options { + baseline: baseline.ok_or("--baseline is required")?, + current: current.ok_or("--current is required")?, + output, + threshold_percent, + minimum_absolute_ms, + }) +} + +fn positive_number(value: &str, flag: &str) -> BenchmarkResult { + let parsed = value + .parse::() + .map_err(|_| format!("{flag} expects a non-negative number"))?; + if !parsed.is_finite() || parsed < 0.0 { + return Err(format!("{flag} expects a non-negative number").into()); + } + Ok(parsed) +} diff --git a/rust/benchmarks/src/cli.rs b/rust/benchmarks/src/cli.rs new file mode 100644 index 00000000..365ab651 --- /dev/null +++ b/rust/benchmarks/src/cli.rs @@ -0,0 +1,110 @@ +use crate::adapters::EXPECTED_ADAPTERS; +use crate::BenchmarkResult; +use std::path::PathBuf; + +pub const SUITE_NAMES: &[&str] = &["performance", "crate-size", "features", "real-world"]; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Options { + pub adapters: Option>, + pub help: bool, + pub iterations: usize, + pub list: bool, + pub output: PathBuf, + pub smoke: bool, + pub suites: Vec, + pub warmup: usize, +} + +pub fn usage() -> &'static str { + "command-stream Rust benchmark playground + +Usage: cargo run --release --manifest-path benchmarks/Cargo.toml -- [options] + + --suite Select suites (default: all) + --adapter Select process APIs (default: all) + --iterations Measured iterations per timing scenario (default: 30) + --warmup Warmup iterations per implementation (default: 5) + --output Report directory (default: benchmarks/results) + --smoke Use tiny deterministic workloads for CI + --list List suites and adapters + --help Show this help +" +} + +pub fn parse_arguments(arguments: &[String]) -> BenchmarkResult { + let mut options = Options { + adapters: None, + help: false, + iterations: 30, + list: false, + output: PathBuf::from("benchmarks/results"), + smoke: false, + suites: SUITE_NAMES.iter().map(ToString::to_string).collect(), + warmup: 5, + }; + let mut index = 0; + while index < arguments.len() { + let flag = arguments[index].as_str(); + match flag { + "--help" => options.help = true, + "--list" => options.list = true, + "--smoke" => options.smoke = true, + "--suite" | "--adapter" | "--iterations" | "--warmup" | "--output" => { + index += 1; + let value = arguments + .get(index) + .ok_or_else(|| format!("{flag} expects a value"))?; + match flag { + "--suite" => options.suites = comma_list(value), + "--adapter" => options.adapters = Some(comma_list(value)), + "--iterations" => options.iterations = integer(value, flag, 1)?, + "--warmup" => options.warmup = integer(value, flag, 0)?, + "--output" => options.output = PathBuf::from(value), + _ => unreachable!(), + } + } + _ => return Err(format!("unknown argument: {flag}").into()), + } + index += 1; + } + + validate_names("suite", &options.suites, SUITE_NAMES)?; + if let Some(adapters) = &options.adapters { + validate_names("adapter", adapters, EXPECTED_ADAPTERS)?; + } + Ok(options) +} + +fn comma_list(value: &str) -> Vec { + value + .split(',') + .map(str::trim) + .filter(|entry| !entry.is_empty()) + .map(str::to_string) + .collect() +} + +fn integer(value: &str, flag: &str, minimum: usize) -> BenchmarkResult { + let parsed = value + .parse::() + .map_err(|_| format!("{flag} expects an integer >= {minimum}"))?; + if parsed < minimum || parsed.to_string() != value { + return Err(format!("{flag} expects an integer >= {minimum}").into()); + } + Ok(parsed) +} + +fn validate_names(kind: &str, values: &[String], expected: &[&str]) -> BenchmarkResult<()> { + let invalid = values + .iter() + .find(|value| !expected.contains(&value.as_str())); + if values.is_empty() || invalid.is_some() { + return Err(format!( + "unknown {kind}: {}", + invalid.map_or("(empty)", String::as_str) + ) + .into()); + } + Ok(()) +} diff --git a/rust/benchmarks/src/fixture.rs b/rust/benchmarks/src/fixture.rs new file mode 100644 index 00000000..cd4e5e20 --- /dev/null +++ b/rust/benchmarks/src/fixture.rs @@ -0,0 +1,200 @@ +use crate::BenchmarkResult; +use std::collections::BTreeMap; +use std::fs::{self, File}; +use std::io::{Read, Write}; +use std::net::TcpStream; +use std::path::{Path, PathBuf}; + +pub fn run_fixture(arguments: &[String]) -> BenchmarkResult> { + if arguments.first().map(String::as_str) != Some("__fixture") { + return Ok(None); + } + let mode = arguments.get(1).ok_or("benchmark fixture expects a mode")?; + let values = &arguments[2..]; + let code = match mode.as_str() { + "echo" => { + print!("{}", serde_json::to_string(values)?); + 0 + } + "emit" => { + let bytes = parse_usize(values.first(), "emit bytes")?; + std::io::stdout().write_all(&vec![b'x'; bytes])?; + 0 + } + "fail" => { + let code = parse_i32(values.first(), "failure exit code")?; + eprint!("intentional benchmark failure"); + code + } + "stdin-count" => { + let mut input = Vec::new(); + std::io::stdin().read_to_end(&mut input)?; + print!("{}", input.len()); + 0 + } + "package-version" => { + let manifest = required_path(values.first(), "manifest path")?; + print!("{}", package_version(&manifest)?); + 0 + } + "source-digest" => { + let directory = required_path(values.first(), "source directory")?; + print!("{:016x}", directory_digest(&directory)?); + 0 + } + "log-summary" => { + let log = required_path(values.first(), "log path")?; + print!("{}", log_summary(&log)?); + 0 + } + "file-digest" => { + let directory = required_path(values.first(), "files directory")?; + let (files, digest) = files_digest(&directory)?; + print!("{files}:{digest:016x}"); + 0 + } + "http-get" => { + let url = values.first().ok_or("http-get expects a URL")?; + print!("{}", http_get(url)?); + 0 + } + _ => return Err(format!("unknown benchmark fixture mode: {mode}").into()), + }; + Ok(Some(code)) +} + +fn parse_usize(value: Option<&String>, label: &str) -> BenchmarkResult { + Ok(value.ok_or_else(|| format!("missing {label}"))?.parse()?) +} + +fn parse_i32(value: Option<&String>, label: &str) -> BenchmarkResult { + Ok(value.ok_or_else(|| format!("missing {label}"))?.parse()?) +} + +fn required_path(value: Option<&String>, label: &str) -> BenchmarkResult { + Ok(PathBuf::from( + value.ok_or_else(|| format!("missing {label}"))?, + )) +} + +fn package_version(manifest: &Path) -> BenchmarkResult { + let source = fs::read_to_string(manifest)?; + source + .lines() + .find_map(|line| { + line.trim() + .strip_prefix("version = \"") + .and_then(|value| value.strip_suffix('"')) + .map(str::to_string) + }) + .ok_or_else(|| format!("no package version in {}", manifest.display()).into()) +} + +fn directory_digest(directory: &Path) -> BenchmarkResult { + let mut paths = Vec::new(); + collect_files(directory, &mut paths)?; + paths.sort(); + let mut digest = FNV_OFFSET; + for path in paths { + digest = fnv_update(digest, path.to_string_lossy().as_bytes()); + digest = fnv_update(digest, &fs::read(path)?); + } + Ok(digest) +} + +fn files_digest(directory: &Path) -> BenchmarkResult<(usize, u64)> { + let mut paths = Vec::new(); + collect_files(directory, &mut paths)?; + paths.sort(); + let mut digest = FNV_OFFSET; + for path in &paths { + digest = fnv_update(digest, &fs::read(path)?); + } + Ok((paths.len(), digest)) +} + +fn collect_files(directory: &Path, output: &mut Vec) -> BenchmarkResult<()> { + for entry in fs::read_dir(directory)? { + let entry = entry?; + if entry.file_type()?.is_dir() { + collect_files(&entry.path(), output)?; + } else if entry.file_type()?.is_file() { + output.push(entry.path()); + } + } + Ok(()) +} + +fn log_summary(path: &Path) -> BenchmarkResult { + let mut counts = BTreeMap::new(); + let contents = fs::read_to_string(path)?; + for line in contents.lines() { + let level = line + .split_whitespace() + .nth(1) + .ok_or_else(|| format!("invalid log line: {line}"))?; + *counts.entry(level.to_string()).or_insert(0_u32) += 1; + } + Ok(serde_json::to_string(&counts)?) +} + +fn http_get(url: &str) -> BenchmarkResult { + let authority_and_path = url + .strip_prefix("http://") + .ok_or("fixture only supports http:// URLs")?; + let (authority, path) = authority_and_path + .split_once('/') + .map_or((authority_and_path, "/".to_string()), |(host, path)| { + (host, format!("/{path}")) + }); + let mut stream = TcpStream::connect(authority)?; + write!( + stream, + "GET {path} HTTP/1.1\r\nHost: {authority}\r\nConnection: close\r\n\r\n" + )?; + let mut response = String::new(); + stream.read_to_string(&mut response)?; + let (headers, body) = response + .split_once("\r\n\r\n") + .ok_or("invalid HTTP response")?; + let status = headers + .lines() + .next() + .and_then(|line| line.split_whitespace().nth(1)) + .ok_or("missing HTTP status")?; + Ok(format!("{status}:{body}")) +} + +const FNV_OFFSET: u64 = 0xcbf29ce484222325; +const FNV_PRIME: u64 = 0x100000001b3; + +fn fnv_update(mut digest: u64, bytes: &[u8]) -> u64 { + for byte in bytes { + digest ^= u64::from(*byte); + digest = digest.wrapping_mul(FNV_PRIME); + } + digest +} + +pub fn create_real_world_data(root: &Path) -> BenchmarkResult<(PathBuf, PathBuf)> { + let files = root.join("files"); + fs::create_dir(&files)?; + for index in 0..12 { + fs::write( + files.join(format!("{index:02}.txt")), + format!("file-{index}\n"), + )?; + } + let log = root.join("application.log"); + let levels = ["INFO", "INFO", "WARN", "INFO", "ERROR"]; + let mut output = File::create(&log)?; + for index in 0..1_000 { + writeln!( + output, + "2026-01-01T00:00:{:02}Z {} event-{index}", + index % 60, + levels[index % levels.len()] + )?; + } + Ok((files, log)) +} diff --git a/rust/benchmarks/src/lib.rs b/rust/benchmarks/src/lib.rs new file mode 100644 index 00000000..14b22481 --- /dev/null +++ b/rust/benchmarks/src/lib.rs @@ -0,0 +1,10 @@ +pub mod adapters; +pub mod cli; +pub mod fixture; +pub mod model; +pub mod regression; +pub mod report; +pub mod runner; +pub mod suites; + +pub type BenchmarkResult = Result>; diff --git a/rust/benchmarks/src/main.rs b/rust/benchmarks/src/main.rs new file mode 100644 index 00000000..c9aa401e --- /dev/null +++ b/rust/benchmarks/src/main.rs @@ -0,0 +1,144 @@ +use chrono::Utc; +use command_stream_benchmarks::adapters::{select_adapters, EXPECTED_ADAPTERS}; +use command_stream_benchmarks::cli::{parse_arguments, usage, SUITE_NAMES}; +use command_stream_benchmarks::fixture::run_fixture; +use command_stream_benchmarks::model::{Configuration, Environment, Report, RunnerDefaults}; +use command_stream_benchmarks::report::write_reports; +use command_stream_benchmarks::runner::BenchmarkRunner; +use command_stream_benchmarks::suites::{crate_size, features, performance, real_world}; +use command_stream_benchmarks::BenchmarkResult; +use serde_json::Value; +use std::path::Path; +use std::process::Command; + +#[tokio::main] +async fn main() { + if let Err(error) = run().await { + eprintln!("{error}"); + std::process::exit(1); + } +} + +async fn run() -> BenchmarkResult<()> { + let arguments = std::env::args().skip(1).collect::>(); + if let Some(code) = run_fixture(&arguments)? { + std::process::exit(code); + } + let options = parse_arguments(&arguments)?; + if options.help { + print!("{}", usage()); + return Ok(()); + } + if options.list { + println!("Suites: {}", SUITE_NAMES.join(", ")); + println!("Adapters: {}", EXPECTED_ADAPTERS.join(", ")); + return Ok(()); + } + + let needs_adapters = options + .suites + .iter() + .any(|name| matches!(name.as_str(), "performance" | "real-world")); + let adapters = if needs_adapters { + select_adapters(options.adapters.as_deref())? + } else { + Vec::new() + }; + let runner = BenchmarkRunner::new(options.iterations, options.warmup)?; + let benchmark_directory = Path::new(env!("CARGO_MANIFEST_DIR")); + let rust_directory = benchmark_directory + .parent() + .ok_or("benchmark package must be nested under the Rust crate")?; + let mut suites = Vec::new(); + for suite in &options.suites { + println!("\nRunning {suite}..."); + let result = match suite.as_str() { + "performance" => { + serde_json::to_value(performance::run(&runner, &adapters, options.smoke).await?)? + } + "crate-size" => crate_size::run(benchmark_directory)?, + "features" => features::run(rust_directory)?, + "real-world" => serde_json::to_value( + real_world::run(&runner, &adapters, options.smoke, rust_directory).await?, + )?, + _ => unreachable!("suite names were validated"), + }; + print_suite(&result); + suites.push(result); + } + + let report = Report { + schema_version: 1, + generated_at: Utc::now().to_rfc3339(), + environment: Environment { + arch: std::env::consts::ARCH.to_string(), + cpus: std::thread::available_parallelism().ok().map(usize::from), + platform: std::env::consts::OS.to_string(), + runtime: rustc_version(), + }, + configuration: Configuration { + adapters: adapters.iter().map(|adapter| adapter.metadata()).collect(), + runner_defaults: RunnerDefaults { + iterations: options.iterations, + warmup: options.warmup, + }, + smoke: options.smoke, + suites: options.suites, + }, + suites, + }; + let paths = write_reports(&report, &options.output)?; + println!("\nJSON: {}", paths.json.display()); + println!("HTML: {}", paths.html.display()); + Ok(()) +} + +fn rustc_version() -> String { + Command::new("rustc") + .arg("--version") + .output() + .ok() + .filter(|output| output.status.success()) + .and_then(|output| String::from_utf8(output.stdout).ok()) + .map_or_else( + || "Rust (unknown version)".to_string(), + |value| value.trim().to_string(), + ) +} + +fn print_suite(suite: &Value) { + println!("\n## {}", suite["name"].as_str().unwrap_or("Benchmark")); + if let Some(scenarios) = suite["scenarios"].as_array() { + for scenario in scenarios { + println!("\n{}", scenario["name"].as_str().unwrap_or("Scenario")); + for entry in scenario["ranking"].as_array().into_iter().flatten() { + println!( + " {}. {:<18} {:>9.2} ms {:>5.2}x", + entry["rank"].as_u64().unwrap_or_default(), + entry["name"].as_str().unwrap_or_default(), + entry["medianMs"].as_f64().unwrap_or_default(), + entry["relativeToFastest"].as_f64().unwrap_or_default(), + ); + } + } + } else if let Some(competitors) = suite["competitors"].as_array() { + for entry in competitors { + println!( + " {:<18} {} ported / {} known gaps ({:.1}%)", + entry["name"].as_str().unwrap_or_default(), + entry["supported"], + entry["gaps"], + entry["coveragePercent"].as_f64().unwrap_or_default(), + ); + } + } else if let Some(crates) = suite["crates"].as_array() { + for entry in crates { + println!( + " {:<18} source {:>10} B closure {:>10} B", + entry["name"].as_str().unwrap_or_default(), + entry["sourceBytes"], + entry["dependencyClosureBytes"], + ); + } + } +} diff --git a/rust/benchmarks/src/model.rs b/rust/benchmarks/src/model.rs new file mode 100644 index 00000000..a3c33d13 --- /dev/null +++ b/rust/benchmarks/src/model.rs @@ -0,0 +1,84 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::BTreeMap; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct Statistics { + pub samples: usize, + pub mean_ms: f64, + pub median_ms: f64, + pub min_ms: f64, + pub max_ms: f64, + pub p95_ms: f64, + pub p99_ms: f64, + pub standard_deviation_ms: f64, + pub operations_per_second: f64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct Ranking { + pub rank: usize, + pub name: String, + pub median_ms: f64, + pub relative_to_fastest: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct Scenario { + pub name: String, + pub iterations: usize, + pub warmup: usize, + pub implementations: BTreeMap, + pub ranking: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Report { + pub schema_version: u32, + pub generated_at: String, + pub environment: Environment, + pub configuration: Configuration, + pub suites: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Environment { + pub arch: String, + pub cpus: Option, + pub platform: String, + pub runtime: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Configuration { + pub adapters: Vec, + pub runner_defaults: RunnerDefaults, + pub smoke: bool, + pub suites: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AdapterMetadata { + pub name: String, + pub version: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RunnerDefaults { + pub iterations: usize, + pub warmup: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TimedSuite { + pub kind: String, + pub name: String, + pub scenarios: Vec, +} diff --git a/rust/benchmarks/src/regression.rs b/rust/benchmarks/src/regression.rs new file mode 100644 index 00000000..ca6a626c --- /dev/null +++ b/rust/benchmarks/src/regression.rs @@ -0,0 +1,180 @@ +use crate::model::Report; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BenchmarkComparison { + pub schema_version: u32, + pub baseline_generated_at: String, + pub current_generated_at: String, + pub threshold_percent: f64, + pub minimum_absolute_ms: f64, + pub summary: ComparisonSummary, + pub comparisons: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ComparisonSummary { + pub compared: usize, + pub regressions: usize, + pub improvements: usize, + pub stable: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ComparisonEntry { + pub suite: String, + pub scenario: String, + pub implementation: String, + pub baseline_median_ms: f64, + pub current_median_ms: f64, + pub delta_ms: f64, + pub delta_percent: Option, + pub status: String, +} + +struct TimedEntry { + key: String, + suite: String, + scenario: String, + implementation: String, + median_ms: f64, +} + +pub fn compare_reports( + baseline: &Report, + current: &Report, + threshold_percent: f64, + minimum_absolute_ms: f64, +) -> BenchmarkComparison { + let baseline_entries = timed_entries(baseline); + let comparisons = timed_entries(current) + .into_iter() + .filter_map(|entry| { + let before = baseline_entries + .iter() + .find(|before| before.key == entry.key)?; + let delta_ms = entry.median_ms - before.median_ms; + let delta_percent = + (before.median_ms != 0.0).then_some(delta_ms / before.median_ms * 100.0); + let status = if delta_ms.abs() < minimum_absolute_ms + || delta_percent.is_none_or(|delta| delta.abs() < threshold_percent) + { + "stable" + } else if delta_ms > 0.0 { + "regression" + } else { + "improvement" + }; + Some(ComparisonEntry { + suite: entry.suite, + scenario: entry.scenario, + implementation: entry.implementation, + baseline_median_ms: before.median_ms, + current_median_ms: entry.median_ms, + delta_ms, + delta_percent, + status: status.to_string(), + }) + }) + .collect::>(); + let summary = ComparisonSummary { + compared: comparisons.len(), + regressions: comparisons + .iter() + .filter(|entry| entry.status == "regression") + .count(), + improvements: comparisons + .iter() + .filter(|entry| entry.status == "improvement") + .count(), + stable: comparisons + .iter() + .filter(|entry| entry.status == "stable") + .count(), + }; + BenchmarkComparison { + schema_version: 1, + baseline_generated_at: baseline.generated_at.clone(), + current_generated_at: current.generated_at.clone(), + threshold_percent, + minimum_absolute_ms, + summary, + comparisons, + } +} + +pub fn comparison_markdown(comparison: &BenchmarkComparison) -> String { + let mut lines = vec![ + "# Rust benchmark comparison".to_string(), + String::new(), + format!( + "Compared {} measurements: {} possible regressions, {} improvements, and {} stable.", + comparison.summary.compared, + comparison.summary.regressions, + comparison.summary.improvements, + comparison.summary.stable + ), + String::new(), + "| Status | Suite | Scenario | API | Baseline | Current | Change |".to_string(), + "| --- | --- | --- | --- | ---: | ---: | ---: |".to_string(), + ]; + for entry in &comparison.comparisons { + let percent = entry + .delta_percent + .map_or_else(|| "n/a".to_string(), |delta| format!("{delta:.1}%")); + lines.push(format!( + "| {} | {} | {} | {} | {:.2} ms | {:.2} ms | {} |", + entry.status, + entry.suite, + entry.scenario, + entry.implementation, + entry.baseline_median_ms, + entry.current_median_ms, + percent + )); + } + lines.extend([ + String::new(), + "> Timing classifications are review signals, not a merge gate. Confirm possible regressions with repeated runs on a controlled host.".to_string(), + String::new(), + ]); + lines.join("\n") +} + +fn timed_entries(report: &Report) -> Vec { + report + .suites + .iter() + .filter_map(|suite| { + let suite_name = suite["name"].as_str()?.to_string(); + Some( + suite["scenarios"] + .as_array()? + .iter() + .flat_map(move |scenario| scenario_entries(&suite_name, scenario)), + ) + }) + .flatten() + .collect() +} + +fn scenario_entries(suite: &str, scenario: &Value) -> Vec { + let scenario_name = scenario["name"].as_str().unwrap_or_default(); + scenario["implementations"] + .as_object() + .into_iter() + .flatten() + .filter_map(|(implementation, statistics)| { + Some(TimedEntry { + key: format!("{suite}\0{scenario_name}\0{implementation}"), + suite: suite.to_string(), + scenario: scenario_name.to_string(), + implementation: implementation.clone(), + median_ms: statistics["medianMs"].as_f64()?, + }) + }) + .collect() +} diff --git a/rust/benchmarks/src/report.rs b/rust/benchmarks/src/report.rs new file mode 100644 index 00000000..5e8cb0d1 --- /dev/null +++ b/rust/benchmarks/src/report.rs @@ -0,0 +1,134 @@ +use crate::model::Report; +use crate::BenchmarkResult; +use serde_json::Value; +use std::fs; +use std::path::{Path, PathBuf}; + +pub struct ReportPaths { + pub json: PathBuf, + pub html: PathBuf, +} + +pub fn escape_html(value: &str) -> String { + value + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} + +pub fn write_reports(report: &Report, output_directory: &Path) -> BenchmarkResult { + fs::create_dir_all(output_directory)?; + let json = output_directory.join("benchmark-results.json"); + let html = output_directory.join("benchmark-report.html"); + fs::write( + &json, + format!("{}\n", serde_json::to_string_pretty(report)?), + )?; + fs::write(&html, html_report(report))?; + Ok(ReportPaths { json, html }) +} + +fn html_report(report: &Report) -> String { + let sections = report + .suites + .iter() + .map(render_suite) + .collect::>() + .join(""); + format!( + "\ncommand-stream Rust benchmark report\n\n

command-stream Rust benchmark report

Generated {} with {} on {} {}. Lower latency is better.

{sections}", + escape_html(&report.generated_at), + escape_html(&report.environment.runtime), + escape_html(&report.environment.platform), + escape_html(&report.environment.arch), + ) +} + +fn render_suite(suite: &Value) -> String { + let name = string(suite, "name"); + let body = match string(suite, "kind").as_str() { + "performance" | "real-world" => performance_section(suite), + "features" => feature_section(suite), + "crate-size" => size_section(suite), + _ => format!("
{}
", escape_html(&suite.to_string())), + }; + format!("

{}

{body}
", escape_html(&name)) +} + +fn performance_section(suite: &Value) -> String { + suite["scenarios"] + .as_array() + .into_iter() + .flatten() + .map(|scenario| { + let rows = scenario["ranking"] + .as_array() + .into_iter() + .flatten() + .map(|entry| { + let median = number(&entry["medianMs"], 2); + let relative = number(&entry["relativeToFastest"], 2); + let width = entry["relativeToFastest"] + .as_f64() + .map_or(2.0, |value| (100.0 / value).max(2.0)); + format!( + "{}{median} ms{relative}x", + escape_html(&string(entry, "name")) + ) + }) + .collect::(); + format!( + "
{}{rows}
APIMedianvs fastestRelative speed
", + escape_html(&string(scenario, "name")) + ) + }) + .collect() +} + +fn feature_section(suite: &Value) -> String { + let rows = suite["competitors"] + .as_array() + .into_iter() + .flatten() + .map(|entry| { + format!( + "{}{}{}{}%", + escape_html(&string(entry, "name")), + entry["supported"], + entry["gaps"], + number(&entry["coveragePercent"], 1) + ) + }) + .collect::(); + format!("{rows}
Upstream corpusPorted behaviorsKnown gapsCoverage
") +} + +fn size_section(suite: &Value) -> String { + let rows = suite["crates"] + .as_array() + .into_iter() + .flatten() + .map(|entry| { + format!( + "{}{}{}{}", + escape_html(&string(entry, "name")), + escape_html(&string(entry, "version")), + entry["sourceBytes"], + entry["dependencyClosureBytes"] + ) + }) + .collect::(); + format!("{rows}
Crate/APIVersionSource bytesDependency closure bytes
") +} + +fn string(value: &Value, key: &str) -> String { + value[key].as_str().unwrap_or_default().to_string() +} + +fn number(value: &Value, digits: usize) -> String { + value + .as_f64() + .map_or_else(|| "n/a".to_string(), |number| format!("{number:.digits$}")) +} diff --git a/rust/benchmarks/src/runner.rs b/rust/benchmarks/src/runner.rs new file mode 100644 index 00000000..cc994d2d --- /dev/null +++ b/rust/benchmarks/src/runner.rs @@ -0,0 +1,159 @@ +use crate::model::{Ranking, Scenario, Statistics}; +use crate::BenchmarkResult; +use std::collections::BTreeMap; +use std::future::Future; +use std::pin::Pin; +use std::time::Instant; + +type OperationFuture = Pin>>>; +type Operation = Box OperationFuture>; + +pub struct BenchmarkCase { + pub name: String, + operation: Operation, +} + +impl BenchmarkCase { + pub fn new(name: impl Into, operation: F) -> Self + where + F: Fn() -> Fut + 'static, + Fut: Future> + 'static, + { + Self { + name: name.into(), + operation: Box::new(move || Box::pin(operation())), + } + } + + async fn execute(&self, scenario: &str, phase: &str) -> BenchmarkResult<()> { + (self.operation)() + .await + .map_err(|error| format!("{scenario}/{} {phase} failed: {error}", self.name).into()) + } +} + +#[derive(Debug, Clone, Copy)] +pub struct BenchmarkRunner { + pub iterations: usize, + pub warmup: usize, +} + +impl BenchmarkRunner { + pub fn new(iterations: usize, warmup: usize) -> BenchmarkResult { + if iterations == 0 { + return Err("iterations must be greater than zero".into()); + } + Ok(Self { iterations, warmup }) + } + + pub async fn compare( + &self, + name: impl Into, + cases: Vec, + overrides: Option<(usize, usize)>, + ) -> BenchmarkResult { + let name = name.into(); + if cases.is_empty() { + return Err(format!("{name} must include at least one implementation").into()); + } + let (iterations, warmup) = overrides.unwrap_or((self.iterations, self.warmup)); + if iterations == 0 { + return Err(format!("{name} iterations must be greater than zero").into()); + } + + for case in &cases { + for index in 0..warmup { + case.execute(&name, &format!("warmup {}", index + 1)) + .await?; + } + } + + let mut samples = cases + .iter() + .map(|case| (case.name.clone(), Vec::with_capacity(iterations))) + .collect::>(); + for iteration in 0..iterations { + let offset = iteration % cases.len(); + for index in 0..cases.len() { + let case = &cases[(offset + index) % cases.len()]; + let started_at = Instant::now(); + case.execute(&name, &format!("iteration {}", iteration + 1)) + .await?; + samples + .get_mut(&case.name) + .expect("every case has a sample bucket") + .push(started_at.elapsed().as_secs_f64() * 1_000.0); + } + } + + let implementations = samples + .into_iter() + .map(|(implementation, values)| (implementation, summarize_samples(&values))) + .collect::>(); + let mut ordered = implementations.iter().collect::>(); + ordered.sort_by(|left, right| left.1.median_ms.total_cmp(&right.1.median_ms)); + let fastest = ordered[0].1.median_ms; + let ranking = ordered + .into_iter() + .enumerate() + .map(|(index, (implementation, statistics))| Ranking { + rank: index + 1, + name: implementation.clone(), + median_ms: statistics.median_ms, + relative_to_fastest: (fastest > 0.0).then_some(statistics.median_ms / fastest), + }) + .collect(); + + Ok(Scenario { + name, + iterations, + warmup, + implementations, + ranking, + }) + } +} + +pub fn summarize_samples(samples: &[f64]) -> Statistics { + assert!( + !samples.is_empty(), + "at least one timing sample is required" + ); + let mut sorted = samples.to_vec(); + sorted.sort_by(f64::total_cmp); + let mean_ms = samples.iter().sum::() / samples.len() as f64; + let middle = sorted.len() / 2; + let median_ms = if sorted.len().is_multiple_of(2) { + (sorted[middle - 1] + sorted[middle]) / 2.0 + } else { + sorted[middle] + }; + let variance = samples + .iter() + .map(|sample| (sample - mean_ms).powi(2)) + .sum::() + / samples.len() as f64; + + Statistics { + samples: samples.len(), + mean_ms, + median_ms, + min_ms: sorted[0], + max_ms: sorted[sorted.len() - 1], + p95_ms: percentile(&sorted, 0.95), + p99_ms: percentile(&sorted, 0.99), + standard_deviation_ms: variance.sqrt(), + operations_per_second: if mean_ms == 0.0 { + f64::INFINITY + } else { + 1_000.0 / mean_ms + }, + } +} + +fn percentile(sorted: &[f64], probability: f64) -> f64 { + let index = ((probability * sorted.len() as f64).ceil() as usize) + .saturating_sub(1) + .min(sorted.len() - 1); + sorted[index] +} diff --git a/rust/benchmarks/src/suites/crate_size.rs b/rust/benchmarks/src/suites/crate_size.rs new file mode 100644 index 00000000..87b35c61 --- /dev/null +++ b/rust/benchmarks/src/suites/crate_size.rs @@ -0,0 +1,161 @@ +use crate::BenchmarkResult; +use serde_json::{json, Value}; +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +const CRATES: &[(&str, &str)] = &[ + ("command-stream", "command-stream"), + ("tokio", "Tokio process"), + ("async-process", "async-process"), + ("duct", "duct"), + ("subprocess", "subprocess"), + ("xshell", "xshell"), +]; + +pub fn run(benchmark_directory: &Path) -> BenchmarkResult { + let manifest = benchmark_directory.join("Cargo.toml"); + let cargo = std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into()); + let output = Command::new(cargo) + .args([ + "metadata", + "--format-version", + "1", + "--locked", + "--manifest-path", + ]) + .arg(&manifest) + .output()?; + if !output.status.success() { + return Err(format!( + "cargo metadata failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + ) + .into()); + } + let metadata: Value = serde_json::from_slice(&output.stdout)?; + let packages = metadata["packages"] + .as_array() + .ok_or("cargo metadata did not return packages")?; + let package_by_id = packages + .iter() + .filter_map(|package| Some((package["id"].as_str()?.to_string(), package))) + .collect::>(); + let nodes = metadata["resolve"]["nodes"] + .as_array() + .ok_or("cargo metadata did not return a resolved graph")?; + let dependencies = nodes + .iter() + .filter_map(|node| { + let id = node["id"].as_str()?.to_string(); + let deps = node["deps"] + .as_array()? + .iter() + .filter_map(|dependency| dependency["pkg"].as_str().map(str::to_string)) + .collect::>(); + Some((id, deps)) + }) + .collect::>(); + let benchmark_id = packages + .iter() + .find(|package| package["name"] == "command-stream-benchmarks") + .and_then(|package| package["id"].as_str()) + .ok_or("cargo metadata is missing the benchmark package")?; + let direct_ids = dependencies + .get(benchmark_id) + .ok_or("cargo metadata is missing benchmark dependencies")?; + + let mut results = Vec::new(); + for (crate_name, display_name) in CRATES { + let id = direct_ids + .iter() + .find(|id| { + package_by_id + .get(*id) + .is_some_and(|package| package["name"] == *crate_name) + }) + .ok_or_else(|| format!("benchmark dependency {crate_name} was not resolved"))?; + let package = package_by_id + .get(id) + .ok_or_else(|| format!("metadata is missing package {id}"))?; + let root = package_root(package)?; + let source_bytes = directory_size(&root, *crate_name == "command-stream")?; + let closure_ids = dependency_closure(id, &dependencies); + let dependency_closure_bytes = closure_ids.iter().try_fold(0_u64, |total, id| { + let package = package_by_id + .get(id) + .ok_or_else(|| format!("metadata is missing package {id}"))?; + let package_name = package["name"].as_str().unwrap_or_default(); + let size = directory_size(&package_root(package)?, package_name == "command-stream")?; + Ok::<_, Box>(total + size) + })?; + results.push(json!({ + "name": display_name, + "crate": crate_name, + "version": package["version"], + "sourceBytes": source_bytes, + "dependencyClosureBytes": dependency_closure_bytes, + "dependencyCount": closure_ids.len().saturating_sub(1), + })); + } + results.push(json!({ + "name": "std::process", + "crate": null, + "version": "built into Rust", + "sourceBytes": 0, + "dependencyClosureBytes": 0, + "dependencyCount": 0, + })); + + Ok(json!({ + "kind": "crate-size", + "name": "Crate source footprint", + "methodology": "Bytes in each resolved crate source tree and its unique transitive Cargo dependency closure. Build artifacts, VCS metadata, and this benchmark package are excluded.", + "crates": results, + })) +} + +fn package_root(package: &Value) -> BenchmarkResult { + let manifest = package["manifest_path"] + .as_str() + .ok_or("package metadata is missing manifest_path")?; + Path::new(manifest) + .parent() + .map(Path::to_path_buf) + .ok_or_else(|| format!("invalid manifest path: {manifest}").into()) +} + +fn dependency_closure( + root: &str, + dependencies: &BTreeMap>, +) -> BTreeSet { + let mut pending = vec![root.to_string()]; + let mut visited = BTreeSet::new(); + while let Some(id) = pending.pop() { + if visited.insert(id.clone()) { + pending.extend(dependencies.get(&id).into_iter().flatten().cloned()); + } + } + visited +} + +fn directory_size(directory: &Path, exclude_benchmarks: bool) -> BenchmarkResult { + let mut bytes = 0; + for entry in fs::read_dir(directory)? { + let entry = entry?; + let name = entry.file_name(); + if matches!(name.to_str(), Some(".git" | "target")) + || (exclude_benchmarks && name == "benchmarks") + { + continue; + } + let file_type = entry.file_type()?; + if file_type.is_dir() { + bytes += directory_size(&entry.path(), false)?; + } else if file_type.is_file() || file_type.is_symlink() { + bytes += entry.metadata()?.len(); + } + } + Ok(bytes) +} diff --git a/rust/benchmarks/src/suites/features.rs b/rust/benchmarks/src/suites/features.rs new file mode 100644 index 00000000..d9a958bf --- /dev/null +++ b/rust/benchmarks/src/suites/features.rs @@ -0,0 +1,90 @@ +use crate::BenchmarkResult; +use serde_json::{json, Value}; +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::Path; + +const COMPETITORS: &[(&str, &str)] = &[ + ("rust-std-process", "std::process"), + ("tokio-process", "Tokio process"), + ("async-process", "async-process"), + ("duct", "duct"), + ("subprocess", "subprocess"), + ("xshell", "xshell"), +]; + +pub fn run(rust_directory: &Path) -> BenchmarkResult { + let corpus = rust_directory.join("tests/competitor_dispositions.jsonl"); + let mut snapshot_date = None; + let mut commits = BTreeMap::new(); + let mut ported: BTreeMap> = BTreeMap::new(); + let mut missing: BTreeMap> = BTreeMap::new(); + + for (index, line) in fs::read_to_string(&corpus)?.lines().enumerate() { + let record: Value = serde_json::from_str(line).map_err(|error| { + format!("{}:{}: invalid JSON: {error}", corpus.display(), index + 1) + })?; + match record["record"].as_str() { + Some("manifest") => snapshot_date = record["snapshotDate"].as_str().map(str::to_string), + Some("source") => { + if let (Some(id), Some(commit)) = (record["id"].as_str(), record["commit"].as_str()) + { + commits.insert(id.to_string(), commit.to_string()); + } + } + Some("unit") => { + let Some(source) = record["source"].as_str() else { + continue; + }; + let Some(id) = record["disposition"]["id"].as_str() else { + continue; + }; + match record["disposition"]["kind"].as_str() { + Some("ported") => { + ported + .entry(source.to_string()) + .or_default() + .insert(id.to_string()); + } + Some("missing") => { + missing + .entry(source.to_string()) + .or_default() + .insert(id.to_string()); + } + _ => {} + } + } + _ => {} + } + } + + let competitors = COMPETITORS + .iter() + .map(|(id, name)| { + let supported_cases = ported.get(*id).cloned().unwrap_or_default(); + let missing_features = missing.get(*id).cloned().unwrap_or_default(); + let supported = supported_cases.len(); + let gaps = missing_features.len(); + let total = supported + gaps; + json!({ + "id": id, + "name": name, + "upstreamCommit": commits.get(*id), + "supported": supported, + "gaps": gaps, + "coveragePercent": if total == 0 { 100.0 } else { supported as f64 / total as f64 * 100.0 }, + "supportedCases": supported_cases, + "missingFeatures": missing_features, + }) + }) + .collect::>(); + + Ok(json!({ + "kind": "features", + "name": "Feature completeness", + "snapshotDate": snapshot_date.ok_or("competitor corpus is missing its manifest")?, + "methodology": "Counts executable command-stream behavior cases and explicit gaps mapped to immutable upstream Rust process-library tests.", + "competitors": competitors, + })) +} diff --git a/rust/benchmarks/src/suites/mod.rs b/rust/benchmarks/src/suites/mod.rs new file mode 100644 index 00000000..eec224b6 --- /dev/null +++ b/rust/benchmarks/src/suites/mod.rs @@ -0,0 +1,4 @@ +pub mod crate_size; +pub mod features; +pub mod performance; +pub mod real_world; diff --git a/rust/benchmarks/src/suites/performance.rs b/rust/benchmarks/src/suites/performance.rs new file mode 100644 index 00000000..78a11ad3 --- /dev/null +++ b/rust/benchmarks/src/suites/performance.rs @@ -0,0 +1,306 @@ +use crate::adapters::{benchmark_executable, fixture_arguments, Adapter, Execution}; +use crate::model::TimedSuite; +use crate::runner::{BenchmarkCase, BenchmarkRunner}; +use crate::BenchmarkResult; +use futures::future::join_all; +use std::path::Path; + +fn adapter_cases( + adapters: &[Adapter], + executable: &Path, + arguments: &[String], + validate: F, +) -> Vec +where + F: Fn(&Execution) -> bool + Clone + 'static, +{ + adapters + .iter() + .map(|adapter| { + let adapter = *adapter; + let executable = executable.to_path_buf(); + let arguments = arguments.to_vec(); + let validate = validate.clone(); + BenchmarkCase::new(adapter.name(), move || { + let executable = executable.clone(); + let arguments = arguments.clone(); + let validate = validate.clone(); + async move { + let result = adapter + .run(executable, &arguments) + .await + .map_err(|error| error.to_string())?; + validate(&result) + .then_some(()) + .ok_or_else(|| format!("unexpected process result: {result:?}")) + } + }) + }) + .collect() +} + +fn concurrent_cases(adapters: &[Adapter], executable: &Path, jobs: usize) -> Vec { + adapters + .iter() + .map(|adapter| { + let adapter = *adapter; + let executable = executable.to_path_buf(); + BenchmarkCase::new(adapter.name(), move || { + let executable = executable.clone(); + async move { + let operations = (0..jobs).map(|index| { + let arguments = fixture_arguments("echo", &[index.to_string()]); + let executable = executable.clone(); + async move { adapter.run(executable, &arguments).await } + }); + let results = join_all(operations).await; + for (index, result) in results.into_iter().enumerate() { + let result = result.map_err(|error| error.to_string())?; + let expected = serde_json::to_vec(&vec![index.to_string()]) + .map_err(|error| error.to_string())?; + if result.exit_code != 0 || result.stdout != expected { + return Err(format!("unexpected concurrent result: {result:?}")); + } + } + Ok(()) + } + }) + }) + .collect() +} + +pub async fn run( + runner: &BenchmarkRunner, + adapters: &[Adapter], + smoke: bool, +) -> BenchmarkResult { + let executable = benchmark_executable()?; + let output_bytes = if smoke { 64 * 1_024 } else { 1_024 * 1_024 }; + let jobs = if smoke { 2 } else { 8 }; + let overrides = smoke.then_some((2, 1)); + let mut scenarios = Vec::new(); + + scenarios.push( + runner + .compare( + "Process spawn latency", + adapter_cases( + adapters, + &executable, + &fixture_arguments("echo", &["benchmark".to_string()]), + |result| result.exit_code == 0 && result.stdout == br#"["benchmark"]"#, + ), + overrides, + ) + .await?, + ); + scenarios.push( + runner + .compare( + format!("Buffered stdout throughput ({output_bytes} bytes)"), + adapter_cases( + adapters, + &executable, + &fixture_arguments("emit", &[output_bytes.to_string()]), + move |result| result.exit_code == 0 && result.stdout.len() == output_bytes, + ), + overrides, + ) + .await?, + ); + scenarios.push( + runner + .compare( + format!("Concurrent execution ({jobs} processes)"), + concurrent_cases(adapters, &executable, jobs), + overrides, + ) + .await?, + ); + scenarios.push( + runner + .compare( + "Non-zero exit handling", + adapter_cases( + adapters, + &executable, + &fixture_arguments("fail", &["17".to_string()]), + |result| { + result.exit_code == 17 && result.stderr == b"intentional benchmark failure" + }, + ), + overrides, + ) + .await?, + ); + scenarios.push( + runner + .compare( + format!("command-stream output modes ({output_bytes} bytes)"), + output_mode_cases(&executable, output_bytes), + overrides, + ) + .await?, + ); + scenarios.push( + runner + .compare( + format!("command-stream pipeline throughput ({output_bytes} bytes)"), + pipeline_cases(&executable, output_bytes), + overrides, + ) + .await?, + ); + scenarios.push( + runner + .compare( + "command-stream built-in vs system process", + built_in_cases(&executable), + overrides, + ) + .await?, + ); + + Ok(TimedSuite { + kind: "performance".to_string(), + name: "Performance".to_string(), + scenarios, + }) +} + +fn output_mode_cases(executable: &Path, bytes: usize) -> Vec { + let buffered_executable = executable.to_path_buf(); + let streamed_executable = executable.to_path_buf(); + vec![ + BenchmarkCase::new("buffered", move || { + let executable = buffered_executable.clone(); + async move { + let arguments = fixture_arguments("emit", &[bytes.to_string()]); + let result = command_stream::StreamingRunner::from_argv(executable, arguments) + .collect() + .await + .map_err(|error| error.to_string())?; + (result.code == 0 && result.stdout.len() == bytes) + .then_some(()) + .ok_or_else(|| "buffered output was incomplete".to_string()) + } + }), + BenchmarkCase::new("streaming", move || { + let executable = streamed_executable.clone(); + async move { + let arguments = fixture_arguments("emit", &[bytes.to_string()]); + let mut stream = + command_stream::StreamingRunner::from_argv(executable, arguments).stream(); + let mut received = 0; + let mut exit_code = None; + while let Some(chunk) = stream.next().await { + match chunk { + command_stream::OutputChunk::Stdout(value) => received += value.len(), + command_stream::OutputChunk::Exit(value) => exit_code = Some(value), + command_stream::OutputChunk::Stderr(_) => {} + } + } + (received == bytes && exit_code == Some(0)) + .then_some(()) + .ok_or_else(|| "streamed output was incomplete".to_string()) + } + }), + ] +} + +fn pipeline_cases(executable: &Path, bytes: usize) -> Vec { + let pipeline_executable = executable.to_path_buf(); + let manual_executable = executable.to_path_buf(); + vec![ + BenchmarkCase::new("Pipeline API", move || { + let executable = pipeline_executable.clone(); + async move { + let source = command_line( + &executable, + &fixture_arguments("emit", &[bytes.to_string()]), + ); + let destination = command_line(&executable, &fixture_arguments("stdin-count", &[])); + let result = command_stream::Pipeline::new() + .add(source) + .add(destination) + .mirror_output(false) + .run() + .await + .map_err(|error| error.to_string())?; + (result.code == 0 && result.stdout == bytes.to_string()) + .then_some(()) + .ok_or_else(|| format!("unexpected pipeline result: {result:?}")) + } + }), + BenchmarkCase::new("manual two-step", move || { + let executable = manual_executable.clone(); + async move { + let source = command_stream::StreamingRunner::from_argv( + executable.clone(), + fixture_arguments("emit", &[bytes.to_string()]), + ) + .collect() + .await + .map_err(|error| error.to_string())?; + let destination = command_stream::StreamingRunner::from_argv( + executable, + fixture_arguments("stdin-count", &[]), + ) + .stdin(source.stdout) + .collect() + .await + .map_err(|error| error.to_string())?; + (destination.code == 0 && destination.stdout == bytes.to_string()) + .then_some(()) + .ok_or_else(|| format!("unexpected manual result: {destination:?}")) + } + }), + ] +} + +fn built_in_cases(executable: &Path) -> Vec { + let executable = executable.to_path_buf(); + vec![ + BenchmarkCase::new("built-in echo", || async { + let result = command_stream::commands::echo(command_stream::CommandContext::new(vec![ + "benchmark".to_string(), + ])) + .await; + (result.code == 0 && result.stdout == "benchmark\n") + .then_some(()) + .ok_or_else(|| "unexpected built-in echo output".to_string()) + }), + BenchmarkCase::new("spawned workload", move || { + let executable = executable.clone(); + async move { + let result = command_stream::StreamingRunner::from_argv( + executable, + fixture_arguments("echo", &["benchmark".to_string()]), + ) + .collect() + .await + .map_err(|error| error.to_string())?; + (result.code == 0 && result.stdout == r#"["benchmark"]"#) + .then_some(()) + .ok_or_else(|| "unexpected spawned echo output".to_string()) + } + }), + ] +} + +fn command_line(executable: &Path, arguments: &[String]) -> String { + std::iter::once(shell_quote(&executable.to_string_lossy())) + .chain(arguments.iter().map(|argument| shell_quote(argument))) + .collect::>() + .join(" ") +} + +#[cfg(unix)] +fn shell_quote(value: &str) -> String { + command_stream::quote(value) +} + +#[cfg(windows)] +fn shell_quote(value: &str) -> String { + format!("\"{}\"", value.replace('"', "\\\"")) +} diff --git a/rust/benchmarks/src/suites/real_world.rs b/rust/benchmarks/src/suites/real_world.rs new file mode 100644 index 00000000..5a66f317 --- /dev/null +++ b/rust/benchmarks/src/suites/real_world.rs @@ -0,0 +1,266 @@ +use crate::adapters::{benchmark_executable, fixture_arguments, Adapter, Execution}; +use crate::fixture::create_real_world_data; +use crate::model::TimedSuite; +use crate::runner::{BenchmarkCase, BenchmarkRunner}; +use crate::BenchmarkResult; +use futures::future::join_all; +use std::io::{Read, Write}; +use std::net::{SocketAddr, TcpListener, TcpStream}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::thread::{self, JoinHandle}; +use std::time::Duration; + +fn adapter_cases(adapters: &[Adapter], operation: F) -> Vec +where + F: Fn(Adapter) -> Fut + Clone + 'static, + Fut: std::future::Future> + 'static, +{ + adapters + .iter() + .map(|adapter| { + let adapter = *adapter; + let operation = operation.clone(); + BenchmarkCase::new(adapter.name(), move || operation(adapter)) + }) + .collect() +} + +pub async fn run( + runner: &BenchmarkRunner, + adapters: &[Adapter], + smoke: bool, + rust_directory: &Path, +) -> BenchmarkResult { + let executable = benchmark_executable()?; + let data = tempfile::tempdir()?; + let (files, log) = create_real_world_data(data.path())?; + let server = LocalServer::start()?; + let overrides = smoke.then_some((1, 0)); + let mut scenarios = Vec::new(); + + let manifest = rust_directory.join("Cargo.toml"); + let source = rust_directory.join("src"); + let workflow_executable = executable.clone(); + scenarios.push( + runner + .compare( + "CI/CD validation workflow (two steps)", + adapter_cases(adapters, move |adapter| { + let executable = workflow_executable.clone(); + let manifest = manifest.clone(); + let source = source.clone(); + async move { + let operations = [ + fixture_arguments( + "package-version", + &[manifest.to_string_lossy().into_owned()], + ), + fixture_arguments( + "source-digest", + &[source.to_string_lossy().into_owned()], + ), + ] + .into_iter() + .map(|arguments| { + let executable = executable.clone(); + async move { adapter.run(executable, &arguments).await } + }); + let results = join_all(operations).await; + for result in results { + let result = result.map_err(|error| error.to_string())?; + if result.exit_code != 0 || result.stdout.is_empty() { + return Err(format!("unexpected workflow result: {result:?}")); + } + } + Ok(()) + } + }), + overrides, + ) + .await?, + ); + + let log_executable = executable.clone(); + scenarios.push( + runner + .compare( + "Log processing (1,000 records)", + single_process_cases(adapters, log_executable, "log-summary", log, |result| { + result.exit_code == 0 + && result.stdout == br#"{"ERROR":200,"INFO":600,"WARN":200}"# + }), + overrides, + ) + .await?, + ); + let files_executable = executable.clone(); + scenarios.push( + runner + .compare( + "File operations (12 files)", + single_process_cases(adapters, files_executable, "file-digest", files, |result| { + result.exit_code == 0 && result.stdout.starts_with(b"12:") + }), + overrides, + ) + .await?, + ); + scenarios.push( + runner + .compare( + "Local network command handling", + single_process_cases( + adapters, + executable, + "http-get", + PathBuf::from(server.url()), + |result| result.exit_code == 0 && result.stdout == b"200:benchmark-ok", + ), + overrides, + ) + .await?, + ); + + Ok(TimedSuite { + kind: "real-world".to_string(), + name: "Real-world workloads".to_string(), + scenarios, + }) +} + +fn single_process_cases( + adapters: &[Adapter], + executable: PathBuf, + mode: &'static str, + value: PathBuf, + validate: F, +) -> Vec +where + F: Fn(&Execution) -> bool + Clone + 'static, +{ + adapter_cases(adapters, move |adapter| { + let executable = executable.clone(); + let value = value.clone(); + let validate = validate.clone(); + async move { + let arguments = fixture_arguments(mode, &[value.to_string_lossy().into_owned()]); + let result = adapter + .run(executable, &arguments) + .await + .map_err(|error| error.to_string())?; + validate(&result) + .then_some(()) + .ok_or_else(|| format!("unexpected process result: {result:?}")) + } + }) +} + +struct LocalServer { + address: SocketAddr, + running: Arc, + thread: Option>, +} + +impl LocalServer { + fn start() -> BenchmarkResult { + let listener = TcpListener::bind("127.0.0.1:0")?; + let address = listener.local_addr()?; + listener.set_nonblocking(true)?; + let running = Arc::new(AtomicBool::new(true)); + let thread_running = Arc::clone(&running); + let thread = thread::spawn(move || { + while thread_running.load(Ordering::Relaxed) { + match listener.accept() { + Ok((mut stream, _)) => respond(&mut stream), + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + thread::sleep(Duration::from_millis(1)); + } + Err(_) => break, + } + } + }); + Ok(Self { + address, + running, + thread: Some(thread), + }) + } + + fn url(&self) -> String { + format!("http://{}/health", self.address) + } +} + +impl Drop for LocalServer { + fn drop(&mut self) { + self.running.store(false, Ordering::Relaxed); + let _ = TcpStream::connect(self.address); + if let Some(thread) = self.thread.take() { + let _ = thread.join(); + } + } +} + +fn respond(stream: &mut TcpStream) { + let mut request = Vec::with_capacity(1_024); + let mut chunk = [0_u8; 1_024]; + loop { + let Ok(read) = stream.read(&mut chunk) else { + return; + }; + if read == 0 { + return; + } + request.extend_from_slice(&chunk[..read]); + if request.windows(4).any(|window| window == b"\r\n\r\n") { + break; + } + if request.len() >= 16 * 1_024 { + return; + } + } + let response = b"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: 12\r\nConnection: close\r\n\r\nbenchmark-ok"; + if stream.write_all(response).is_ok() { + let _ = stream.flush(); + let _ = stream.shutdown(std::net::Shutdown::Write); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::ErrorKind; + + #[test] + fn local_server_waits_for_complete_request_headers() { + let server = LocalServer::start().expect("start server"); + let mut stream = TcpStream::connect(server.address).expect("connect to server"); + stream + .set_read_timeout(Some(Duration::from_millis(50))) + .expect("set timeout"); + + stream.write_all(b"G").expect("write request fragment"); + stream.flush().expect("flush request fragment"); + let mut byte = [0_u8; 1]; + let early_response = stream.read(&mut byte); + assert!( + matches!( + early_response, + Err(ref error) + if matches!(error.kind(), ErrorKind::WouldBlock | ErrorKind::TimedOut) + ), + "server responded before receiving complete headers: {early_response:?}" + ); + + stream + .write_all(b"ET /health HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n") + .expect("finish request"); + let mut response = String::new(); + stream + .read_to_string(&mut response) + .expect("read response without a reset"); + assert!(response.ends_with("\r\n\r\nbenchmark-ok")); + } +} diff --git a/rust/benchmarks/tests/harness.rs b/rust/benchmarks/tests/harness.rs new file mode 100644 index 00000000..74dd0e35 --- /dev/null +++ b/rust/benchmarks/tests/harness.rs @@ -0,0 +1,164 @@ +use command_stream_benchmarks::adapters::Adapter; +use command_stream_benchmarks::cli::parse_arguments; +use command_stream_benchmarks::model::{Configuration, Environment, Report, RunnerDefaults}; +use command_stream_benchmarks::regression::{compare_reports, comparison_markdown}; +use command_stream_benchmarks::report::{escape_html, write_reports}; +use command_stream_benchmarks::runner::{summarize_samples, BenchmarkCase, BenchmarkRunner}; +use command_stream_benchmarks::suites::features; +use serde_json::json; +use std::path::Path; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Arc; + +#[test] +fn parses_and_validates_cli_options() { + let options = parse_arguments(&[ + "--suite".to_string(), + "performance,features".to_string(), + "--adapter".to_string(), + "command-stream,xshell".to_string(), + "--iterations".to_string(), + "7".to_string(), + "--warmup".to_string(), + "0".to_string(), + "--smoke".to_string(), + ]) + .expect("valid benchmark options"); + assert_eq!(options.suites, ["performance", "features"]); + assert_eq!( + options.adapters.as_deref(), + Some(["command-stream".to_string(), "xshell".to_string()].as_slice()) + ); + assert_eq!(options.iterations, 7); + assert_eq!(options.warmup, 0); + assert!(options.smoke); + + assert!(parse_arguments(&["--iterations".to_string(), "0".to_string()]).is_err()); + assert!(parse_arguments(&["--suite".to_string(), "unknown".to_string()]).is_err()); +} + +#[test] +fn calculates_stable_statistics() { + let statistics = summarize_samples(&[4.0, 1.0, 3.0, 2.0]); + assert_eq!(statistics.samples, 4); + assert_eq!(statistics.mean_ms, 2.5); + assert_eq!(statistics.median_ms, 2.5); + assert_eq!(statistics.min_ms, 1.0); + assert_eq!(statistics.max_ms, 4.0); + assert_eq!(statistics.p95_ms, 4.0); +} + +#[tokio::test] +async fn runner_executes_warmups_and_measured_iterations() { + let calls = Arc::new(AtomicUsize::new(0)); + let operation_calls = Arc::clone(&calls); + let scenario = BenchmarkRunner::new(3, 2) + .expect("runner") + .compare( + "counter", + vec![BenchmarkCase::new("implementation", move || { + operation_calls.fetch_add(1, Ordering::Relaxed); + async { Ok(()) } + })], + None, + ) + .await + .expect("benchmark succeeds"); + assert_eq!(calls.load(Ordering::Relaxed), 5); + assert_eq!(scenario.implementations["implementation"].samples, 3); + assert_eq!(scenario.ranking[0].rank, 1); +} + +#[tokio::test] +async fn every_adapter_executes_and_captures_a_process() { + let test_executable = std::env::current_exe().expect("absolute test executable path"); + for adapter in Adapter::all() { + let result = adapter + .run(&test_executable, &["--help".to_string()]) + .await + .unwrap_or_else(|error| panic!("{} failed: {error}", adapter.name())); + assert_eq!(result.exit_code, 0, "{} exit code", adapter.name()); + assert!( + result.stdout.starts_with(b"Usage: "), + "{} output: {:?}", + adapter.name(), + result.stdout + ); + } +} + +#[test] +fn feature_suite_is_derived_from_the_checked_in_corpus() { + let rust_directory = Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("Rust directory"); + let suite = features::run(rust_directory).expect("feature suite"); + assert_eq!(suite["kind"], "features"); + assert_eq!(suite["snapshotDate"], "2026-09-13"); + assert_eq!(suite["competitors"].as_array().map(Vec::len), Some(6)); + assert!(suite["competitors"] + .as_array() + .expect("competitors") + .iter() + .all(|entry| entry["upstreamCommit"] + .as_str() + .is_some_and(|value| value.len() == 40))); +} + +#[test] +fn report_writer_escapes_html_and_emits_machine_readable_json() { + let directory = tempfile::tempdir().expect("temporary report directory"); + let report = report_with_median("2026-09-15T00:00:00Z", 10.0); + let paths = write_reports(&report, directory.path()).expect("reports"); + let decoded: Report = + serde_json::from_str(&std::fs::read_to_string(paths.json).expect("JSON report contents")) + .expect("valid JSON report"); + assert_eq!(decoded.schema_version, 1); + let html = std::fs::read_to_string(paths.html).expect("HTML report contents"); + assert!(html.contains("command-stream Rust benchmark report")); + assert_eq!(escape_html("<&\"'"), "<&"'"); +} + +#[test] +fn regression_comparison_classifies_material_changes() { + let baseline = report_with_median("baseline", 10.0); + let current = report_with_median("current", 13.0); + let comparison = compare_reports(&baseline, ¤t, 15.0, 1.0); + assert_eq!(comparison.summary.compared, 1); + assert_eq!(comparison.summary.regressions, 1); + assert_eq!(comparison.comparisons[0].delta_percent, Some(30.0)); + assert!(comparison_markdown(&comparison).contains("| regression |")); +} + +fn report_with_median(generated_at: &str, median_ms: f64) -> Report { + Report { + schema_version: 1, + generated_at: generated_at.to_string(), + environment: Environment { + arch: "test".to_string(), + cpus: Some(1), + platform: "test".to_string(), + runtime: "rustc test".to_string(), + }, + configuration: Configuration { + adapters: Vec::new(), + runner_defaults: RunnerDefaults { + iterations: 1, + warmup: 0, + }, + smoke: true, + suites: vec!["performance".to_string()], + }, + suites: vec![json!({ + "kind": "performance", + "name": "Performance", + "scenarios": [{ + "name": "spawn", + "implementations": { + "command-stream": { "medianMs": median_ms } + }, + "ranking": [] + }] + })], + } +} diff --git a/rust/changelog.d/20260915_200000_rust_benchmarks.md b/rust/changelog.d/20260915_200000_rust_benchmarks.md new file mode 100644 index 00000000..76f7509d --- /dev/null +++ b/rust/changelog.d/20260915_200000_rust_benchmarks.md @@ -0,0 +1,8 @@ +--- +bump: minor +--- + +### Added + +- Add a reproducible Rust benchmark suite for performance, crate footprint, + feature coverage, and real-world process workloads, with CI base/head reports.