diff --git a/.github/workflows/rust-scope.yml b/.github/workflows/rust-scope.yml new file mode 100644 index 000000000000..70c232f4694b --- /dev/null +++ b/.github/workflows/rust-scope.yml @@ -0,0 +1,33 @@ +name: Rust scope + +# The integration branch may change only the Rust CLI's paths compared with +# teambit/bit master, so it stays mergeable without touching the Node.js CLI +# (scripts/rust/check-scope.sh). No path filter: a change anywhere is checked. +on: + push: + branches: [rust-workspace-materialization] + pull_request: + branches: [rust-workspace-materialization] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: rust-scope-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + scope: + name: Rust CLI stays inside its paths + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + persist-credentials: false + - name: Fetch teambit/bit master + run: git fetch --no-tags https://github.com/teambit/bit.git master:refs/remotes/upstream/master + - name: Check the changed paths + run: bash scripts/rust/check-scope.sh refs/remotes/upstream/master diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml new file mode 100644 index 000000000000..b739eb6a5f1f --- /dev/null +++ b/.github/workflows/rust.yml @@ -0,0 +1,394 @@ +name: Rust + +# The Rust CLI (`native/`): formatting, Clippy, the perfectionist lints, rustdoc, +# the workspace tests on every platform the CLI targets, and the ported e2e cases. +on: + push: + branches: [master, rust-workspace-materialization] + paths: + - 'native/**' + - 'scripts/rust/**' + - 'scopes/harmony/testing/load-aspect/core-aspects-ids.json' + - '.github/workflows/rust.yml' + pull_request: + paths: + - 'native/**' + - 'scripts/rust/**' + - 'scopes/harmony/testing/load-aspect/core-aspects-ids.json' + - '.github/workflows/rust.yml' + workflow_dispatch: + inputs: + build-distribution: + description: Build native binaries and bvm archives + type: boolean + default: false + +permissions: + contents: read + +concurrency: + group: rust-${{ github.event.pull_request.number || github.ref }} + # Superseded PR pushes are cancelled; every pushed commit keeps its result. + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + lint: + name: pnpm Rust formatting and lints + runs-on: ubuntu-latest + timeout-minutes: 60 + defaults: + run: + working-directory: native + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: actions/setup-node@v4 + with: + node-version: 22.22.0 + - name: Cache Cargo and formatter artifacts + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + ~/.cargo/bin/cargo-dylint + ~/.cargo/bin/dylint-link + ~/.dylint_drivers + ~/.cache/pnpm/rustfmt + native/target/debug + key: rust-lint-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('native/rust-toolchain.toml', 'native/Cargo.lock', 'native/Cargo.toml', 'scripts/rust/rustfmt.json') }}-dylint-6.0.4 + # Keeps the installed Dylint binaries and registry when only the lockfile changes. + restore-keys: rust-lint-${{ runner.os }}- + - name: Cache pinned perfectionist library + uses: actions/cache@v4 + with: + path: native/target/dylint + key: rust-perfectionist-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('native/rust-toolchain.toml', 'native/dylint.toml') }}-dylint-6.0.4 + - name: Install pinned Rust and Clippy + run: | + rustup show + rustup component add clippy + - name: Check pnpm's pinned formatter + run: node ../scripts/rust/rustfmt.mjs --all -- --check + - name: Check Clippy + run: cargo clippy --locked --workspace --all-targets --features bit-cli/e2e -- -D warnings + - name: Install pnpm's Dylint version + run: | + if ! cargo dylint --version 2>/dev/null | grep -Fxq 'cargo-dylint 6.0.4' || ! command -v dylint-link >/dev/null; then + cargo install --locked --force --version 6.0.4 cargo-dylint dylint-link + fi + - name: Check perfectionist + env: + RUSTFLAGS: '-D warnings' + run: | + bash ../scripts/rust/test-dylint-git.sh + export DYLINT_REAL_GIT="$(command -v git)" + mkdir -p "$RUNNER_TEMP/dylint-git" + cp ../scripts/rust/dylint-git.sh "$RUNNER_TEMP/dylint-git/git" + chmod +x "$RUNNER_TEMP/dylint-git/git" + export PATH="$RUNNER_TEMP/dylint-git:$PATH" + cargo dylint --all -- --all-targets --workspace --features bit-cli/e2e + - name: Check Rust documentation + env: + RUSTDOCFLAGS: '-D warnings' + run: cargo doc --locked --workspace --no-deps + - name: Check that the e2e porting plan lists every e2e case + run: node ../scripts/rust/e2e-port-plan.mjs --check + - name: Check the native runtime boundary census + run: node ../scripts/rust/native-boundaries.mjs --check + - name: Check upstream onboarding assets + run: node ../scripts/rust/check-ci-scaffold.mjs + - name: Check benchmark JSON and bitmap history validators + run: | + python3 ../scripts/rust/lane-restoration-benchmark-tests.py + python3 ../scripts/rust/lane-graph-reset-benchmark-tests.py + python3 ../scripts/rust/bitmap-history-oracle-tests.py + python3 ../scripts/rust/package-bvm-tests.py + + test: + name: Rust tests (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, ubuntu-24.04-arm, macos-15-intel, macos-15, windows-latest] + runs-on: ${{ matrix.os }} + timeout-minutes: ${{ matrix.os == 'macos-15-intel' && 90 || 60 }} + defaults: + run: + working-directory: native + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - name: Cache dependencies and build artifacts + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + native/target/debug + key: rust-test-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('native/rust-toolchain.toml', 'native/Cargo.lock') }} + restore-keys: rust-test-${{ runner.os }}-${{ runner.arch }}- + - name: Install pinned Rust + run: rustup show + - uses: actions/setup-node@v4 + with: + node-version: 22.22.0 + - name: Test the workspace + run: cargo test --locked --workspace + + e2e: + # The e2e cases ported to the native CLI (native/plans/TEST_PORTING.md), with the + # released Node.js CLI that cli/tests/e2e/bit-version names behind it. + name: Ported e2e cases (${{ matrix.shard }}/4) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + shard: [1, 2, 3, 4] + # Shard 1 also runs the reduced-stack regressions before saving caches. + timeout-minutes: 90 + defaults: + run: + working-directory: native + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - name: Read the pinned Node.js CLI version + id: bit + run: echo "version=$(tr -d '[:space:]' < cli/tests/e2e/bit-version)" >> "$GITHUB_OUTPUT" + - name: Cache dependencies and build artifacts + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + native/target/debug + key: rust-e2e-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('native/rust-toolchain.toml', 'native/Cargo.lock') }} + restore-keys: rust-e2e-${{ runner.os }}-${{ runner.arch }}- + - name: Cache the Node.js CLI + id: bvm-cache + uses: actions/cache@v4 + with: + path: ~/.bvm/versions/${{ steps.bit.outputs.version }} + key: bvm-bit-${{ runner.os }}-${{ runner.arch }}-${{ steps.bit.outputs.version }} + - name: Install pinned Rust + run: rustup show + - uses: actions/setup-node@v4 + with: + node-version: 22.22.0 + - name: Install the Node.js CLI + if: steps.bvm-cache.outputs.cache-hit != 'true' + # Outside native/, whose package.json pins pnpm as the package manager. + working-directory: ${{ runner.temp }} + env: + BIT_VERSION: ${{ steps.bit.outputs.version }} + run: | + npx -y @teambit/bvm@3.1.4 config set RELEASE_TYPE nightly + npx -y @teambit/bvm@3.1.4 install "$BIT_VERSION" --skip-update-path + - name: Install the released upstream pnpm reference engine + run: npm ci --prefix ../scripts/rust/pnpm-oracle --@pnpm:registry=https://registry.npmjs.org --ignore-scripts --no-audit --no-fund + - name: Run the ported e2e cases + env: + SHARD: ${{ matrix.shard }} + SHARDS: 4 + run: | + mapfile -t shard_tests < <( + cargo test --locked -p bit-cli --features e2e --test e2e -- --list --format=terse \ + | sed -n 's/: test$//p' \ + | awk -v shard="$SHARD" -v shards="$SHARDS" '(NR - 1) % shards == (shard - 1)' + ) + echo "Running ${#shard_tests[@]} e2e tests in shard $SHARD/$SHARDS" + cargo test --locked -p bit-cli --features e2e --test e2e -- --exact "${shard_tests[@]}" + - name: Check lane imports with the Windows main-stack budget + if: matrix.shard == 1 + run: python3 ../scripts/rust/check-native-stack.py + + status-diff-windows: + name: Workspace VCS parity (Windows, ${{ matrix.group }}) + runs-on: windows-latest + strategy: + fail-fast: false + matrix: + include: + - group: status + filters: commands::list commands::cat harmony::file_history diff_completion status_completion status_component_policy status_environments status_relative status_component_selectors status_graph_remote status_autosync + - group: snapshot + filters: commands::snap commands::tag snapshot_environments update_dependents_cascade::older + - group: checkout + filters: checkout_platform checkout_local checkout_callbacks checkout_policy checkout_selection reset_selection harmony::untag harmony::revert harmony::lane_adoption harmony::lane_restoration harmony::lane_graph_reset + - group: transfer + filters: commands::import commands::export commands::resume_export restoring_a_directory_link export_completion export_hub export_callbacks + - group: merge-history + filters: merge_history merge_lifecycle merge_callbacks merge_policy merge_recovery harmony::lanes::abort_merge_lane + - group: provisioning + filters: commands::init commands::lane commands::format commands::lint commands::compile commands::tester harmony::config harmony::doctor harmony::install_compat harmony::lane_import_callbacks harmony::ci_sync::onboarding::native + - group: stash + filters: harmony::stash + - group: tracking + filters: "commands::add:: harmony::add::" + - group: identity + filters: commands::move_paths harmony::rename harmony::deprecation + - group: mutation + filters: commands::remove commands::delete commands::recover harmony::scope_rename + - group: fork + filters: harmony::fork harmony::scope_fork + timeout-minutes: 60 + defaults: + run: + working-directory: native + shell: bash + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - name: Set the BVM cache directory + run: echo "BVM_GLOBALS_DIR=$RUNNER_TEMP/bit-bvm" >> "$GITHUB_ENV" + - uses: actions/setup-node@v4 + with: + node-version: 22.22.0 + - name: Read the pinned Node.js CLI version + id: bit + run: echo "version=$(tr -d '[:space:]' < cli/tests/e2e/bit-version)" >> "$GITHUB_OUTPUT" + - name: Cache dependencies and build artifacts + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + native/target/debug + key: rust-status-diff-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('native/rust-toolchain.toml', 'native/Cargo.lock') }} + restore-keys: | + rust-status-diff-${{ runner.os }}-${{ runner.arch }}- + rust-test-${{ runner.os }}-${{ runner.arch }}- + - name: Cache the Node.js CLI + id: bvm-cache + uses: actions/cache@v4 + with: + path: ${{ env.BVM_GLOBALS_DIR }}/versions/${{ steps.bit.outputs.version }} + key: bvm-status-diff-${{ runner.os }}-${{ runner.arch }}-${{ steps.bit.outputs.version }} + - name: Install pinned Rust + run: rustup show + - name: Install the Node.js CLI + if: steps.bvm-cache.outputs.cache-hit != 'true' + working-directory: ${{ runner.temp }} + env: + BIT_VERSION: ${{ steps.bit.outputs.version }} + run: | + npx -y @teambit/bvm@3.1.4 config set RELEASE_TYPE nightly + npx -y @teambit/bvm@3.1.4 install "$BIT_VERSION" --skip-update-path + - name: Install the released upstream pnpm reference engine + run: npm ci --prefix ../scripts/rust/pnpm-oracle --@pnpm:registry=https://registry.npmjs.org --ignore-scripts --no-audit --no-fund + - name: Compare native workspace VCS with the pinned Node.js CLI + env: + PARITY_FILTERS: ${{ matrix.filters }} + run: | + parity_failed=0 + for parity_filter in $PARITY_FILTERS; do + if ! cargo test --locked -p bit-cli --features e2e --test e2e "$parity_filter" -- --nocapture; then + parity_failed=1 + fi + done + exit "$parity_failed" + + distribution: + name: Native distribution (${{ matrix.target }}) + if: github.event_name == 'workflow_dispatch' && inputs.build-distribution + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + target: x86_64-unknown-linux-gnu + platform: linux + arch: x64 + - os: ubuntu-24.04-arm + target: aarch64-unknown-linux-gnu + platform: linux + arch: arm64 + - os: macos-15-intel + target: x86_64-apple-darwin + platform: darwin + arch: x64 + - os: macos-15 + target: aarch64-apple-darwin + platform: darwin + arch: arm64 + - os: windows-latest + target: x86_64-pc-windows-msvc + platform: win + arch: x64 + - os: ubuntu-latest + target: x86_64-unknown-linux-musl + platform: linux + arch: x64 + runs-on: ${{ matrix.os }} + timeout-minutes: 120 + defaults: + run: + shell: bash + working-directory: native + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: actions/setup-node@v4 + with: + node-version: 22.22.0 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + native/target + key: rust-dist-${{ matrix.target }}-${{ hashFiles('native/rust-toolchain.toml', 'native/Cargo.lock') }} + - name: Install pinned Rust target + env: + TARGET: ${{ matrix.target }} + run: | + rustup show + rustup target add "$TARGET" + - name: Install musl toolchain + if: matrix.target == 'x86_64-unknown-linux-musl' + run: | + sudo apt-get update + sudo apt-get install -y musl-tools + echo "CMAKE_TOOLCHAIN_FILE=$PWD/cmake/linux-musl.cmake" >> "$GITHUB_ENV" + echo "CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_LINKER=musl-gcc" >> "$GITHUB_ENV" + - name: Build distribution binary + env: + TARGET: ${{ matrix.target }} + run: cargo build --locked --profile dist --target "$TARGET" -p bit-cli + - name: Package native executable + env: + TARGET: ${{ matrix.target }} + PLATFORM: ${{ matrix.platform }} + run: | + mkdir -p distribution + executable=bit + if [ "$PLATFORM" = win ]; then executable=bit.exe; fi + cp "target/$TARGET/dist/$executable" "distribution/$executable" + - name: Package bvm archive with released Node fallback + if: matrix.target != 'x86_64-unknown-linux-musl' + env: + TARGET: ${{ matrix.target }} + PLATFORM: ${{ matrix.platform }} + ARCH: ${{ matrix.arch }} + run: | + base=$(tr -d '\r\n' < cli/tests/e2e/bit-version) + version="$base-native.$GITHUB_RUN_NUMBER" + executable=bit + if [ "$PLATFORM" = win ]; then executable=bit.exe; fi + curl --fail --location --retry 3 "https://bvm.bit.dev/bit/versions/$base/bit-$base-$PLATFORM-$ARCH.tar.gz" -o base.tar.gz + python3 ../scripts/rust/package-bvm.py --base base.tar.gz --binary "distribution/$executable" --version "$version" --platform "$PLATFORM" --output "distribution/bit-$version-$PLATFORM-$ARCH.tar.gz" + - uses: actions/upload-artifact@v4 + with: + name: bit-native-${{ matrix.target }}-${{ github.sha }} + path: native/distribution/ + if-no-files-found: error + retention-days: 30 diff --git a/docs/rust-cli.md b/docs/rust-cli.md new file mode 100644 index 000000000000..278bde3e20bc --- /dev/null +++ b/docs/rust-cli.md @@ -0,0 +1,776 @@ +# The Rust `bit` CLI + +`native/cli` builds a `bit` executable. Commands implemented in Rust run in +that process. Every other command runs the Node.js Bit CLI with the same +arguments, terminal, environment and exit code. The Node.js CLI does not know +this binary exists, so nothing outside `native/` has to change for it to work. + +The remaining implementation is tracked in +[zkochan/bit#395](https://github.com/zkochan/bit/issues/395), following the earlier +[#125](https://github.com/zkochan/bit/issues/125) milestones. Yarn support is excluded. + +This is still a hybrid CLI. A native dispatch can call the published Bit JavaScript +host; a passing test or a command registration alone does not prove Rust ownership. +The generated [runtime boundary census](rust/native-boundaries.md) locates source +sites to audit alongside the command coverage table. Run +`node scripts/rust/native-boundaries.mjs --check` to verify that census is current. + +## Dispatch + +1. The first argument names the command, as the Node.js CLI spells it, + aliases included. +2. A command in the table in `native/cli/src/commands.rs` runs natively. +3. A native command may decline its input, for example `--help`, a directory + outside a workspace, or a yarn workspace. A declined command is delegated, + and a warning on stderr names the command and the reason: + ``⚠ bit-native: `status` runs on the Node.js CLI: ``. Stdout stays + the Node.js CLI's own. `--help` and `BIT_NATIVE_TRACE` (which prints the + reason anyway) do not warn. +4. Everything else is delegated. + +On Unix, delegation replaces the process image, so the Node.js CLI keeps the +PID, the terminal and signal handling. On Windows it runs as a child process. +The Rust process ignores Ctrl-C and exits with the child's exit code. + +| Variable | Effect | +| -------------------- | ---------------------------------------------------------------------------------------- | +| `BIT_NATIVE=off` | Delegate every command. Use it for A/B comparisons and bisecting. | +| `BIT_NATIVE_TRACE=1` | Print every dispatch decision and the delegated command line to stderr. | +| `BIT_NATIVE_QUIET=1` | Do not warn when a native command falls back to the Node.js CLI. | +| `BIT_NODE_CLI` | Path of the Node.js CLI entry: `@teambit/bit`'s `bin/bit`, or a checkout's `bin/bit.js`. | +| `BIT_NODE_BINARY` | The Node that runs the entry. | + +## Finding the Node.js CLI + +Discovery only inspects paths. It never downloads anything or runs Node. The +first match wins: + +1. `BIT_NODE_CLI`. +2. `../node_modules/@teambit/bit/bin/bit` relative to the executable's + directory. This is the bvm layout once bvm ships this binary as `bin/bit`. +3. The checkout the executable was built in: `/bin/bit.js` when the + executable is `/native/target//bit` and the checkout's + dependencies are installed. A local build then runs with the Node.js CLI of its + own commit, which its native commands reproduce (see [Versions](#versions)). +4. bvm's default link, `/links//node_modules/@teambit/bit/bin/bit`. + The bvm directory is `BVM_GLOBALS_DIR`, else `%LOCALAPPDATA%\.bvm` on + Windows, else `~/.bvm`. +5. A `bit` on `PATH` that is not this executable, typically bvm's wrapper script. + +The entry runs with `BIT_NODE_BINARY`, else the newest Node in the bvm +directory the entry lives in, else `node` from `PATH`. That Node's directory +goes first on `PATH`, as bvm's wrapper does. + +To try the binary against a checkout: + +```sh +cd native && pnpm run build:debug +BIT_NODE_CLI=$PWD/../bin/bit.js ./target/debug/bit status +``` + +## Setup + +`native/` is a pnpm project with Cargo support (`cargo.enabled` in its +`pnpm-workspace.yaml`). Install pnpm 12.11 or later, a C compiler and CMake 3.14 +or later: + +```sh +cd native +pnpm install # the Rust toolchain rust-toolchain.toml names, and the crates +pnpm run build # target/release/bit (build:debug for target/debug/bit) +``` + +Object compression statically links bundled zlib-ng. The repository's CMake +toolchain disables its new compression strategies to reproduce released Node's +default compressed bytes. A custom `CMAKE_TOOLCHAIN_FILE` must also set +`WITH_NEW_STRATEGIES=OFF`; the compression regression tests verify exact streams. +Cross-compilation requires a toolchain file that also selects the target system +and C compiler. + +`pnpm install` downloads the toolchain once per machine into pnpm's store, +verified against the Rust release signing key, and links it into +`native/.pnpm/rust`. It also vendors the crates into `native/.pnpm/crates`, +which `native/.cargo/config.toml` points Cargo at. `pnpm run` puts the linked +toolchain first on `PATH`, so building and testing need neither rustup nor a +system Rust. +`devEngines` in `native/package.json` pins the pnpm and Node.js versions, and +pnpm downloads them when they are missing. + +| Script | Runs | +| --------------------------- | --------------------------------------------------------------------------- | +| `pnpm run build` | `cargo build --release -p bit-cli` (thin LTO) | +| `pnpm run build:dist` | `cargo build --profile dist -p bit-cli` (fat LTO) | +| `pnpm run build:debug` | `cargo build -p bit-cli` | +| `pnpm run test` | `cargo test --workspace` | +| `pnpm run test:e2e` | the ported e2e cases: `cargo test -p bit-cli --features e2e --test e2e` | +| `pnpm run lint` | `cargo clippy --workspace --all-targets --features bit-cli/e2e -- -D warnings` | +| `pnpm run doc` | `cargo doc --workspace --no-deps` | +| `pnpm run fmt`, `fmt:check` | pnpm's pinned rustfmt over the workspace | + +`build` uses thin LTO so an optimized rebuild stays fast. Binaries that are +shipped (bvm packages, release assets) must come from `build:dist`, which +writes `target/dist/bit` with fat LTO and one codegen unit: about 20% smaller +and a few percent faster, but a rebuild takes minutes and over 4 GB of memory. + +The pinned pnpm engine includes its generated CAS loader, so Cargo builds need no +source preparation. Two checks still need rustup: `fmt` and `fmt:check` +build pnpm's pinned rustfmt fork with a dated nightly, and the perfectionist +lints (`cargo dylint`) use the nightly toolchain `cargo-dylint` installs. + +`test:e2e` runs the cases of the Node.js CLI's e2e suite ported to the native CLI +([`native/plans/TEST_PORTING.md`](../native/plans/TEST_PORTING.md) tracks them). +They run the `bit` binary against the released Node.js CLI that +`native/cli/tests/e2e/bit-version` names, which `bvm install ` provides. +Before E2E runs, install the released upstream engine reference with +`npm ci --prefix scripts/rust/pnpm-oracle --@pnpm:registry=https://registry.npmjs.org --ignore-scripts` from the repository root. +The test harness resolves only `@pnpm/napi` to published 12.16.0: the pinned Bit release +still contains 12.13.0, while upstream master now requires 12.16.0. This test-only +preload leaves the installed Bit files untouched and must be removed when the pinned +Bit release includes the new engine. Installation comparisons retain the full new +validation metadata, masking only independently generated clock values. + +`bit-workspace-config` supplies the shared comment-preserving JSONC document, scope and +workspace discovery, global config paths/readers, environment-key mapping and store layering. +Installation, status/diff, snapshot/checkout/merge, transfer, provisioning and diagnostics reuse +these readers and renderers; repository object reads use the bounded Rust object reader. +Prepared scope documents share the published four-space/no-final-newline serializer, and package +manifest edits share the existing newline, indentation and field-order formatter. + +Configuration lookup gives nonempty `BIT_CONFIG_*` values precedence over workspace, scope and +global stores. Lists retain stored values and omit environment overrides. The native config +command preserves the published origin order, including global-first deletion when no origin +is given, JSON/text rendering, comment-preserving workspace backups and private global-file +permissions. It also retains the released non-string and inherited-property getter behavior. + +## The JavaScript host + +Some behavior is still implemented by aspects in JavaScript, for example an +env's `getDependencies()` or the compile step after `bit install`. Native +commands reach it through `native/cli/src/host/host.js`. The script is embedded +in the binary and runs from a private `.cjs` temporary file on the Node.js CLI's +own Node, keeping the command line within Windows limits. The Rust session owns +and removes that file after the child stops. The host starts on the first call +only and loads modules that the published `@teambit/bit` package already ships. + +The protocol is one JSON request per line on the script's stdin, +`{id, method, params}`, and one response per line on its stdout, +`{id, result}` or `{id, error}`. Output from aspects goes to stderr, so it +reaches the terminal without corrupting the protocol. Closing stdin ends the +script. A script that does not exit within five seconds is killed. + +Each host method is removed once its behavior is ported to Rust. + +## Native commands + +| Command | Crate | Host methods | Declines when | +| --------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `init [path]`, `--bare [name]`, naming/default-directory/default-scope/generator options, `--shared`, `--standalone`, package-manager and reset options, `--agent` | `bit-cli`, `bit-workspace-config`: provisioning and repair/reset plans, scope selection, permissions, package/configuration rendering, agent instructions and MCP | Published package templates and instruction assets are read as files; no ordinary Harmony host | Fresh Git initialization requiring the interactive wizard, malformed workspace configuration, unavailable published assets, unsupported syntax or paths decline before effects; started filesystem failures remain owned | +| `config`, `config get/set/del/list`, `--local`, `--local-track`, `--origin`, `--detailed`, list `--json` | `bit-cli`, `bit-workspace-config`: store precedence, native persistence, JSON/text/color reports and workspace history | none; Git supplies the released user-name/email fallback | Unknown grammar/origins, unavailable stores, malformed or unsupported store shapes, versioned workspace config-store mutations and `__proto__` mutations decline before effects; write failures remain owned | +| `add [paths...]`, `a`, identity/namespace/scope/main/env options, `--override`, `--root`, and `--json` | Rust resolves selectors and ignores, validates ownership and main files, retains a projected bitmap, prepares component package links and publishes bitmap history only after linking succeeds | Published environment resolution, component-load and package transformers, and bounded remote-existence facts; source inventory, tracking decisions and persistence remain native | Unknown grammar, unscoped vendor components, unsafe or unsupported filesystem/model/configuration inputs and explicit resource limits decline before effects; provider or link failures after ownership remain native errors | +| `stash save` / `s`, `list`, `load [stash-id]` / `pop`, messages, `--include-new`, manual/ours/theirs merge and force options | Rust detached Version/Source capture, dependency graphs, retained stash storage, checkout and batch cleanup, and unrecognized-subcommand reporting in and outside a workspace | Permitted environment/load/dependency facts and component-add/compile callbacks | Unknown grammar, the published boolean pattern option and empty-message conversion, unsafe/corrupt metadata, missing/re-added tracked components or paths requiring canonical recovery, unavailable dependency objects and explicit resource limits; declines precede publication | +| `doctor [diagnosis]`, `--list`, `--json`, `--save`, `--archive`, archive inclusion/exclusion flags, `--remote` | `bit-cli`, `bit-workspace-config`, `bit-object-import`, `bit-client-transfer`: eight built-in diagnoses, unknown-diagnosis error reporting, reports/archives and file/HTTP remote diagnostics | Published BVM latest-version fact and configured JavaScript remote resolvers | Unknown grammar, unavailable published metadata, stores requiring canonical repair, corrupt or over-budget objects and CI error presentations requiring the released CLI stack decline before effects | +| `install`, `in` | `bit-install` ([source](../native/install)) | `hostInfo`, `envPolicy`, `appTypes`, `packageJsonProps`, `transformPackageJson`, `afterInstall` | `--help`, outside a workspace, non-UTF-8 arguments, the yarn package manager, a Node.js CLI found only as a wrapper script | +| `mini-status`, `ms`, with or without `--json`; `status --quick [--json]` | Rust status inventory, `bit-ignore` and shared scope/configuration readers | none | Component patterns and `--show-issues` on the private `mini-status` command, invalid arguments, unsafe paths, malformed legacy data or explicit inventory limits | +| `status --json`, `s -j`, and text output with warnings, verbose, strict, fail-on-error, expand, lanes and ignore-circular-dependencies flags | Shared Rust inventory, policy/configuration projection, repository comparison, dependency diagnostics and lane/history engines; pending-import components are excluded from the invalid list, following upstream 2.2.162 | Harmony environment policies, dependency detectors and custom load/issue callbacks; Intl ordering for tailored locales | Invalid arguments, unsafe or corrupt data, unavailable remote objects, explicit resource limits | +| `diff [component] [version] [to-version]` with JSON, name-only, stat, file, files-only, configs-only, parent, verbose or table output | Shared Rust status capture and repository engine, native patterns/state selectors, retained file/HTTP version import and Git diff | Permitted Harmony load/environment/detector callbacks and metadata presentation | Invalid selectors or arguments, unsafe/corrupt objects, unavailable remote objects and explicit resource limits | +| `log ` with default, `--json`, `--one-line`, `--parents`, `--full-hash`, `--full-message`, or `--show-hidden` | the engine’s `componentLog` follows validated Version parents from the current lane head or main head (matching `.bitmap` by `rootDir`, name, or identity and falling back to `.bit/index.json`, or reporting `OutsideWorkspaceError`, `InvalidBitId`, `InvalidScopeName`, `InvalidName`, and `ComponentNotFound`), preserves published BFS lane marking on divergent histories, and projects HEAD removal/deprecation ranges and staged deprecation; Rust formats and topologically orders merge graphs for `--parents` | none | `--remote`, missing or corrupt history objects, filtered-parent graphs for `--parents`, and unsupported flags | +| `log / --remote` with default, `--json`, or `--one-line`; `--parents` for `file://` remotes | the Rust engine resolves HTTP and file remotes from workspace/global configuration; it queries bounded GraphQL history or reads validated bare-scope Component and Version objects, then formats the CLI views | none | HTTP `--parents` (the GraphQL projection has no parent hashes), active lanes on a file scope, unsupported flags, or invalid remote responses | +| `cat `, with `--file`, `--config`, `--all`, `--json` | fresh Rust workspace inventory and source reads, validated historical Version/Source lookup, source and JSON/text formatting, and proven documentation-cache publication | the published SourceFile loader, semantic parser and FsCache handle cold rich files without Harmony, preserving load-before-parse order, concurrent parsing and nonfatal cache IO; `CatCmd` supplies identity/configuration for `--config`/`--all` and uncertain cache states; source bodies are never returned by these adapters | custom Harmony callbacks requiring canonical startup, unsupported scope/layout/grammar and unsafe or corrupt inputs decline before effects; metadata/cache failures after effects remain owned | +| `log-file ` with default or `--one-line` | the engine’s `componentFileHistory` validates local Version and Source objects, including after deprecation is staged (reporting `OutsideWorkspaceError` outside a workspace and `unable to find component for file ` for paths outside any `.bitmap` component); Rust selects file changes and renders the source CLI’s patch or table view | the published historical `workspace.get` loader supplies its dependency-cache effects without returning Source bodies | custom component loaders that can change historical sources, binary sources, unavailable history and unsupported flags | +| `blame ` with default or `--include-message` | the same validated file history request, including after deprecation is staged (reporting `OutsideWorkspaceError` outside a workspace and `no results found` for unversioned or unowned paths); Rust attributes changed lines by Version ancestry and renders the source CLI’s table | none | binary sources, unavailable history, and unsupported flags | +| `list [remote-scope]`, `ls`, with `--ids`, `--json`, `--local-scope`, `--scope`, `--namespace`, `--outdated`, `--include-deleted` | shared Rust model/bitmap inventory, configured file/HTTP remote listing and latest-version queries, ordered metadata and table/JSON presentation; remote reads import no objects | configured JavaScript resolvers and hub scalar URL resolution in a retained session | unsupported grammar/collation, unsupported metadata and predictable published CI diagnostics decline before effects; include-deleted queries on older HTTP servers retain canonical warning behavior without replaying started requests | +| `import ` with `-x`, `-o`, `--override`, `--merge=manual\|ours\|theirs`, `-p `, dependency/depth options, `--all-history`, `--fetch-deps`, `--json`, `--verbose`, `--track-only`, `--write-to-empty-dir`, `--write-deps package.json\|workspace.jsonc`, and dependent/via/all selection with `--silent`; current-lane scoped wildcard imports; `fetch -c [ids...] [--all-history]`; `fetch [main or tracked/scoped lanes...] --lanes [--all-history]` | Rust lists configured HTTP/file remotes, captures and validates object closures, main and lane history, resolves advanced remote Lane refreshes, and materializes new or tracked components with native three-way merge and directory selection, including owner-prefixed paths for scoped components that collide with tracked directories. Retained policy completion updates workspace dependency ranges and conflict files with comment-preserving bytes before native installation. No-ID object imports on an active lane refresh visible and hidden versions without promoting components or rewriting workspace sources, including `--all-history` and `--fetch-deps`. Cross-scope lane objects are fetched from the lane scope without advancing origin main refs. Lane fetches preserve main-first transfer and lane-first result ordering, including version-distinct bitmap seeds, active-lane `--fetch-deps` and parent Sources. JSON emits the released handler’s exact result without loader output | Harmony providers, real component-class/onLoad and dependency-policy facts; environment/package/compiler hooks; published semver grammar facts for workspace policy. Rust owns installation orchestration and pnpm execution; configured mutable installation subscribers use the actual published InstallMain in the retained operation session, with supported stock pnpm package steps executed in Rust on Linux | unsupported flags, package-name or unscoped wildcard selectors, unsupported locale/package manager/layouts, unsafe or corrupt objects, missing remote IDs, unresolved local edits without a supported strategy, binary merge conflicts and explicit resource limits. Advertised fetch JSON, no-ID import JSON (released argument-handler failure), and unsupported plans decline before effects; the plural `--from-original-scopes` flag preserves the released handler’s ignored-option behavior; interactive dependent selection and ordinary occupied, untracked destinations; native adoption/dependent modes are listed below; started publication failures remain owned | +| `export [ids...]`, `e`, `--all`, `--all-versions`, `--head-only`, `--origin-directly`, `--resume `, `--verbose`, `--json`, `--ignore-missing-artifacts`, `--fork-lane-new-scope`, `--eject`, `--open-browser` | Rust selects immutable object closures for main and current-lane exports, including local versions, full history, hidden updates, forked lanes, valid readmes, repeated exports, embedded scopes and published wildcard regular expressions. Retained Apply/Stage/Finalize receipts own remote transfer, acknowledged model/history/ref publication and bitmap/staged-control reconciliation. Default generated component packages use the shared Rust manifest renderer and linker, with their inputs validated before transfer. Resume reuses server pending objects without pushing them again. JSON preserves the released handler's ignored head-only/fork flags and its actual component/lane serialization | published providers, package and post-export callbacks; JSON class projection; optional eject and browser adapters | unknown flags or malformed input; explicit IDs on a lane and unsnapped readmes retain the released CLI's error stack through a decline before effects; cross-scope forks without the required flag and other unsupported plans decline before providers or transfer. After a transfer starts, failures remain owned and never delegate | +| `switch ` with or without `-x`, `--skip-fetch`, `--alias`, `--workspace-only`, static `--pattern` on an empty workspace, force strategies and `--auto-merge-resolve` | the checkout engine’s prepare, plan, capture, materialization and apply path, including alias tracking and selected lane visibility; native `bit-install` between checkout bitmap publication and final lane/alias publication when requested | published grouped component loading in checkout order supplies configured environment/compiler and onLoad callbacks, including providers without custom onLoad; the same retained Host supplies installation and compilation | `--branch`, state-dependent or unsupported patterns, unsupported lane metadata, interactive conflicts, unsupported package managers, and anything the engine declines before writes. Released 2.2.94 declares `--json` but lacks its required JSON handler; this invocation delegates before effects to preserve the published failure | +| `checkout [pattern]`, `U`, with `-x`, force/manual strategies, `--workspace-only`, and `head --include-new-from-scope` | shared Rust repository prepare/selection/capture/materialization/apply, including glob/list/negation/state/AND selectors, short snapshot refs with published numeric positional coercion and Windows lookup errors preserved, ancestor/tag/main/lane targets, missing-file reset, root-component protection, dependency-policy updates/conflicts, canonical conflict errors, scope licenses, binary conflicts and new default-scope components; native installation after VCS publication in the retained planning session | published Harmony providers and component onLoad callbacks; private source spools; build/installation tooling | interactive merge prompting, unsupported flags/transports/package managers, removed target Versions; unavailable published adapters and unsafe/corrupt inputs decline before effects when eligible; after provider startup or a cache/publication prefix, errors remain native | +| `revert ` with or without `-x` | shared checkout target/ref resolution and native file materialization while retaining current bitmap/version; supports glob/list/negation/state/AND selectors, short refs with published numeric positional coercion, binary/CRLF contents, file removals, root components and local stored targets | the same published lifecycle and installation adapters as checkout | unsupported flags, transport/history cases and package managers; the same pre-effect boundaries as checkout | +| `lane checkout [-x]`; `lane revert [-x] [--restore-deleted-components] [--json]` | Native LaneHistory remote refresh and persistence before history-ID lookup, followed by historical per-component checkout and revert, absent-component restoration, preserved existing bitmap versions and released colored JSON file-status labels | real component/provider hooks and retained installation/compiler tooling | CI main/missing-history diagnostics and uncached non-file remote history targets delegate before providers to retain the published V8 stack. Cached CI success and known file-remote history refresh stay native. Lane checkout has no released JSON option. The final merged executable completed fresh 49-workload plus eight graph-reset qualification; see the shared qualification record below | +| `reset [pattern]` with optional `--head`, `--force` and `--silent`; `reset --silent` with optional `--head`; `reset --soft /` or `reset --soft --silent`; `reset --never-exported` | Rust selects main and lane Version graphs against their exported remote or inherited boundaries, using cached VersionHistory for sparse ancestry and preserving immutable Version/Source objects and working-tree bytes; lane reset handles divergence, merge parents, inherited members, hidden updateDependents and detached main histories; glob/list/negation/state/AND selectors freeze a batch, including pending-deleted rows; explicit resets check dependents; Component/Lane objects, index and bitmap publish once per batch, with staged aspect config and deletion metadata restored and VersionHistory/LaneHistory reconciled; validated graph population prefixes survive later owned errors; soft reset clears pending bitmap tags; never-exported reset clears unscoped bitmap versions | none | interactive all-component confirmation, unavailable or inconsistent ancestry, unsupported flags, stateful hard-lane reset selectors under CI, and predictable CI lane-reset errors whose actual published stack is delegated before providers or prefix writes | +| `merge [pattern]` with or without `-x`, `--build` | Rust selects glob/list/negation/state/AND predicates, captures once, performs three-way merge and policy publication, installs when requested, and resolves snapshots and dependent cascades after materialization | one retained published provider session supplies onLoad, dependency detection, pre-snap and builder callbacks; `force_local_build` follows workspace configuration | flags and package layouts outside the native grammar, interactive conflict selection, unsupported compositions/docs/app files, unavailable adapters, incomplete local history closure and unsafe/corrupt inputs; pre-effect CI errors requiring a JavaScript stack delegate; errors after effects remain owned | +| `merge [pattern] --resolve`, `-m` | Rust resolves pending file/config merges, preserves squash/unrelated ancestry and dependent cascades, publishes Versions and clears completed merge conflict state | published providers, hydration, pre-snap and optional builder callbacks | unsupported flags, selectors and package layouts, unavailable adapters and unsafe inputs before effects; callback/build failures preserve their published state prefix | +| `merge [pattern] --abort`, `lane merge-abort` | native recovery plans and restores workspace/lane controls and pre-merge main component models, with interactive lane confirmation before restoration and callbacks | published component hydration after canonical restoration; installation/compilation when requested | complex component patterns, malformed main-model backups or main-model restoration requiring index repair, unparseable sources whose hashes do not prove a changed reset target, unsupported package managers and inputs the recovery engine declines before effects | +| `lane merge [pattern]` with or without `-x`, `--build`, `--resolve-unrelated [ours\|theirs]` | native graph divergence, three-way merge, hidden/model-backed targets, removal policy, component/workspace configuration updates and conflicts, lane/history publication and same-session automatic snapshots; retains incoming type-package dependencies and preserves squash and unrelated relations; cross-scope hidden updates fetch origin main history and retain lane ancestry through the merge | the same retained lifecycle and build adapters as component merge | unsupported lane flags/selectors/package layouts, unavailable adapters, direct `--no-snap` squashing errors requiring delegation before effects, and the component merge boundaries | +| `lane`, `lane list` (including `--details`, `--json`, `--merged` and `--not-merged`), `lane current` (including `--json`), `lane show [local-lane]` (including `--json`), `lane list --remote `, `lane show --remote` | the engine's `laneList` and `scopeList`, `.bitmap` and `scope.json`, including visible components, hidden update-dependents, readme metadata and main-head ancestry for merge filters; bare `lane` delegates directly to `lane list`; `lane current` reads only `.bitmap` and `scope.json`; `lane list --remote ` and `lane show --remote` inspect configured `file://` bare-scope remotes from either a workspace or a bare scope, with validated Lane and ancestry objects, or configured HTTP scopes through their limited GraphQL lane projection | configured workspace remote resolvers for scalar URLs; otherwise none | unconfigured remotes without a resolver, remote inspection outside a workspace or bare scope, lanes Node migrates to record deleted components (schema before `1.0.0`) | +| `lane create ` with `--scope`, `--alias`, and `--fork-lane-new-scope` | Rust writes the Lane and LaneHistory objects, index, scope tracking and bitmap for main and lane forks (or reports `OutsideWorkspaceError` outside a workspace); staged main snaps move into the lane with main-head restoration, lane origins and VersionHistory population; native status inherits exported parent refs for new forks | user identity from the existing global/workspace configuration reader | unexported main tags, outdated or detached workspace heads, unsupported flags or names, and repository metadata the native planner declines before writing | +| `move `, `mv` | native source relocation or reconciliation of an already moved directory; retained source/control proofs, projected bitmap paths, generated packages, source links and bitmap history | retained published providers, component onLoad callbacks and package transformers | unknown grammar, paths outside the workspace, unsafe filesystem layouts and unsupported inputs decline before provider startup; CI validation errors requiring a JavaScript stack delegate before effects | +| `rename ` with `--scope`, `--path`, `--refactor`, `--preserve`, `--ast`, `--delete` or `--deprecate` | native identity selection, source/path and import refactoring, new-component rename or exported-component copy with source deletion/deprecation, component aspect/env references, package links and bitmap history | retained published onLoad and package callbacks; actual registered onComponentAdd/compiler hooks; the published TypeScript transformer for `--ast`, using private file spools | unknown flags, non-workspace execution, unsafe paths, unsupported metadata or bounded-input limits | +| `scope rename ` with `--refactor`, `--preserve`, `--deprecate`, `-x`; `scope rename-owner ` with `--refactor` or `--ast`; `scope set [component-pattern]`; `scope trust [action] [pattern]` | native batch selection, default-scope/config and bitmap publication, exported replacement/deletion/deprecation, source and package-import refactoring, package linking and history; optional native installation followed by compilation; `scope trust` manages `trustedScopes` in `workspace.jsonc` (`list`, `add`, `remove`, `clear`), and `scope` reports unrecognized subcommands in and outside a workspace and `OutsideWorkspaceError` on `rename`/`rename-owner` outside a workspace | actual per-component environment, onLoad, onComponentAdd, package and compiler hooks in a retained session for rename; `scope set` and `scope trust` use no Harmony host | unknown grammar, stateful (`$`), attribute (`:`), or `AND` component patterns on `scope set`, unsafe paths, unsupported metadata or bounded inputs | +| `fork [target]` with `--scope`, `--path`, `--env`, `--refactor`, `--skip-config`, `--preserve`, `--no-link`, `--ast`, `-x`; `scope fork [target-scope] [pattern]` with `--ast`, `-x` | Rust owns retained selection, private remote capture, source/refactor materialization, tracking, provenance, dependency-policy writes, and `OutsideWorkspaceError` reporting outside a workspace. All 34 strict local, file/HTTP remote, batch, callback, installation and failure-prefix fixtures pass; the 14 original fork and three scope-fork assertions are mapped individually. Complete Linux qualification passes 360 comparison pairs and all 70 eligible native medians are lower; see the fork qualification below | actual environment/onLoad, onComponentAdd, package/compiler and TypeScript AST tooling; native installation for remote forks when requested | non-UTF-8/unsupported grammar, unsupported inventory or unsafe preflight plans, and requested historical versions of tracked workspace components decline before providers. Once providers or mutation begin, errors stay owned. Scope-fork and remote-pattern option forwarding preserves the published handler’s behavior; single-fork flags are not assumed to apply to every batch | +| `deprecate `, `d`, with `--new-id` or `--range`; `undeprecate ` | native bitmap deprecation updates, stored Version deprecation/range lookup, replacement identity validation, unchanged-result reporting and bitmap history; local glob/list/negation selectors and `$deprecated` | retained published providers and component hydration | other state selectors, remote-only replacement IDs, unknown options and unsafe/corrupt inputs decline before effects; CI validation errors requiring a JavaScript stack delegate | +| `remove --silent`, `rm`, with `--keep-files`, `--force` and `--track` | native batch selection and repository/bitmap publication, including staged models, current lanes, retained configuration and unmerged-state cleanup; source/package removal preserves partial failures and unrelated objects. Eligible removal checks compare source files and resolve package/aspect metadata without loading environments. Workspace state filters load classes only when their predicate requires them; fallback status checks reuse those classes | actual retained providers and fallback environment/onLoad/package callbacks | interactive confirmation, remote removal, unknown grammar, overlapping/unsafe paths, corrupt or unsupported metadata and explicit resource limits; eligibility declines before effects, while started failures remain owned | +| `delete --silent`, `--range`, `--snaps`, `--lane`, `--update-main`; feature-enabled `delete --hard --silent` with optional `--force` | native hard-delete remote selection, main-history dependent checks, ordered file/HTTP/hub dispatch and acknowledged-prefix handling; native soft-deletion selection, configuration and bitmap history, main/lane removal policy, dependency-range updates, package/source cleanup and nested empty-parent cleanup. Ordered workspace state filters share retained classes and status inputs | actual retained providers and component/package callbacks | disabled hard-delete feature, interactive confirmation, custom workspace providers or file scopes with custom server hooks/aspects, unsupported lane/model/configuration states, unsafe paths, unavailable object closures and unknown grammar; started failures remain owned | +| `recover `, `-x` / `--skip-dependency-installation`, `--skip-write-config-files` | native grouped recovery, bitmap/component configuration, local or remote object capture, stored removal-range classification, source restoration/overwrite and complete history publication. Cached staged models retain native divergence history; missing local models preserve the canonical objects-only import prefix | actual providers/component metadata and the existing import installation/configuration adapters | workspace-only state filters, unknown flags, new/unsupported lane or model states, unsafe paths, corrupt objects and explicit resource limits; failures after capture/publication remain owned | +| `lane remove --silent` with optional `--force` and `--remote` | Rust selects local or remote lanes, checks merged state unless forced, validates Lane objects, moves file-scope Lane objects to trash and removes scope-index entries; remote HTTP/hub writes are single-attempt and retain successful scope prefixes | remote scope server endpoints and their hooks | interactive confirmation, file scopes with custom server hooks/aspects, ambiguous names, unsupported lane schemas, or a failed preflight | +| `lane import ` with or without `-x`, `--pattern`, force strategies and `--auto-merge-resolve` | on main, the native switch plan fetches and materializes the lane and can install dependencies; on an active lane, `commandInboundPrepare` resolves the requested alias or lane name and fetches the remote Lane and component closure from configured file or HTTP scopes, then `commandInboundApply` publishes the Lane, index and refs without changing the workspace files or bitmap. Active-lane imports ignore the pattern, strategy and installation flags as the released command does | retained provider startup on active lanes; switch hydration and installation callbacks on main | `--branch`, unsupported patterns when switching from main, unsupported flags, anything the engine declines before writing | +| `lane history [name]` with `--json` and `--id`; `lane alias `; `lane rename `; `lane change-scope `; `lane remove-readme [lane-name]`; `lane remove-comp ` / `lane rc` with `--workspace-only` and `--update-main`; `lane merge-move ` with `-s` / `--scope`; `lane fetch [lane-id]` with `-a` / `--all`; `lane eject `; `lane updates [lane-name]` with `--undo` and `-j` / `--json`; `lane history-diff` with `-l` / `--lane`, `--pattern`, and `-j` / `--json`; `lane diff [values...]` | `bit-object-import` validated local and refreshed remote LaneHistory, including bare scopes, lane metadata publication, readme removal on local Lane objects, `bit delete --lane --silent` forwarding for `lane remove-comp`, native lane creation and `.bitmap` active-lane transition for `lane merge-move`, remote Lane object fetch and tracking for `lane fetch`, native component package installation plus `.bitmap` untrack for `lane eject`, `main` / missing-lane / ambiguous-lane validation in workspaces and bare scopes for `lane updates` and `lane history-diff`, and native lane component diffing with `VersionNotFoundOnFS` reporting for `lane diff` | configured remote resolvers; `delete --lane` lifecycle for `lane remove-comp` | unsupported flags, unsafe or corrupt history, metadata changes outside a current unexported lane, `lane merge-move` outside an active lane, remote `lane updates` / `lane history-diff` lookups on non-main lanes, or a lane without a readme; these decline to Node before effects | +| `snap [pattern]` | native selection and dependent cascade, including older-snapshot and transitive hidden lane dependents; source and graph hashes, Version/model objects, parents and history, refs/index and local publication; workspace-root snapshots; generated packages and source links; plus-range feature validation and planned build-task metadata | published aspect/environment hooks, issue callbacks, editor, builder task descriptors and package transformers; source bodies use private file spools | unsupported option syntax, malformed or over-budget receipts, corrupt repository inputs and unavailable published adapter modules decline before effects; after extension startup or publication, failures remain native | +| `tag [patterns...]`, `t` | snapshot publication plus semantic versions, versions files, soft tags and persistence, registry-version retries, state/glob selectors, issue policies, graph rebuild/omission and workspace-root provenance | the same extension callbacks as snap; registry availability facts | tagging on lanes, invalid editor combinations and Node-unsupported soft premajor/preminor/prepatch persistence retain canonical validation | +| `remote`, `remote list`, `remote add `, `remote del `, with `--global` / `-g` | `bit-cli`, `bit-workspace-config`: local `scope.json` and global `global-remotes.json` stores, `file://` bare-scope discovery and `scope.json` name validation, CLI table and error presentation | none | non-`file://` remote URLs on `remote add`, outside a workspace, malformed `scope.json` or `global-remotes.json`, and unsupported flags decline before effects | +| `pattern `, with `--json` / `-j` and `--remote` / `-r` | `bit-cli`, `bit-install`: `.bitmap` and `.bit/index.json` component selection, extended glob/list/negation pattern matching, file/HTTP remote scope listing via `commandRemoteInventory`, and JSON/text presentation | configured JavaScript remote resolvers for `--remote`; otherwise none | stateful (`$`), attribute (`:`), or `AND` patterns, package/aspect literal selectors, wildcard remote scope names, and unsupported flags decline before effects | +| `refactor dependency-name ` | `bit-cli`, `bit-install`, `bit-object-import`: workspace and local/bare-scope component package-name resolution, scoped-package validation, non-binary source file import rewriting (`'`, `"`, `~`, and subpath imports), empty bare-scope reporting, `Host was not found` outside a workspace or bare scope, and unrecognized-subcommand reporting in workspaces, bare scopes, and outside a workspace | none | non-empty bare scopes on `dependency-name`, unsupported workspace settings, and flags outside the native grammar decline before effects | +| `details` | `bit-cli`, `bit-workspace-config`: enables `showDetails` in the global config store | none | unsupported flags or arguments decline before effects | +| `globals` with optional `--json` / `-j` | `bit-cli`, `bit-workspace-config`: resolves global config, log, cache and capsule directories (including BVM-isolated capsule paths) and renders JSON or text | none | unsupported flags or arguments decline before effects | +| `system log` | `bit-cli`, `bit-workspace-config`: streams `debug.log` from the global log directory and reports unrecognized `system` subcommands | none | unsupported flags or arguments decline before effects | +| `version` with optional `--json` / `-j` | `bit-cli`: reads the active Node CLI package version and compares against BVM's latest remote version via `https://bvm.bit.dev/versions/latest.json` | none | unsupported flags or arguments decline before effects | +| `git set-merge-driver` with optional `--global` / `-g`; `git merge-bitmaps ` | `bit-cli`, `bit-workspace-config`: configures the Git `.bitmap` merge driver in local `.git` or global Git config and merges `.bitmap` JSONC files across base/current/other revisions | none | unsupported flags or arguments decline before effects | +| `whoami` with optional `--token ` | `bit-cli`, `bit-install`: inspects global config `user.token` and queries Bit Cloud Symphony GraphQL endpoint for authenticated username, or immediately reports unauthenticated status | none | unknown flags or arguments decline before effects | +| `logout` | `bit-cli`, `bit-workspace-config`: deletes stored `user.token` and `user.name` credentials from global configuration | none | unknown flags or arguments decline before effects | +| `build [pattern]` | native task listing and eligible capsule materialization | unsupported pipelines run through the released CLI | component peer dependencies delegate before capsule writes so released env policy and peer ranges are retained | +| `npmrc [sub-command]` | `bit-cli`: routes unrecognized npmrc subcommands with descriptive guidance, delegating interactive/registry `generate` | none | `npmrc generate` or unknown flags decline to canonical CLI | +| `use `; `unuse ` | `bit-cli`, `bit-install`, `bit-workspace-config`: adds or removes aspect configuration entries in `workspace.jsonc` root preserving comments and formatting; `unuse` cleans generator environments | none | non-workspace aspect requiring remote import on `use`, non-workspace execution, or unsupported flags decline before effects | +| `search ` with `--local-only` / `-l` and optional `--json` / `-j` | `bit-cli`, `bit-install`: searches workspace components by case-insensitive substring match, deduplicates results across queries, and renders formatted text or JSON | none | remote search without `--local-only`, unsupported flags, or outside a workspace decline before effects | +| `uninstall [packages...]` / `un` | `bit-cli`, `bit-install`: removes specified package dependencies from `workspace.jsonc` policy and runs native dependency installation to update `node_modules` | none | non-workspace execution, unsupported flags, or unsupported installation callbacks decline before effects | +| `clear-cache` / `cc` with optional `-r ` / `--remote ` | `bit-cli`, `bit-client-transfer`, `bit-workspace-config`: deletes the workspace filesystem component cache (`cache/components`) and scope index (`index.json`), or dispatches `ClearCacheAction` to a configured HTTP(S) remote scope | HTTP remote cache clearing needs published package version metadata only; local cache clearing needs no published runtime | `file://` or custom-resolved `--remote` scopes, configured startup callbacks, or unknown flags decline before effects | +| `local-only [component-pattern]` | `bit-cli`, `bit-install`, `bit-object-import`: sets, unsets, or lists `"localOnly": true` on matching component entries in `.bitmap` (verifying unstaged exported components on `main` and active lanes via stored remote refs and version history) and records `.bitmap` history | none | stateful (`$`), attribute (`:`), or `AND` patterns, outside a workspace, or unknown flags decline before effects | +| `cat-object ` with `-p` / `--pretty`, `-s` / `--stringify`, `--headers`; `cat-component ` / `cmp` with `-j` / `--json`; `cat-lane ` / `cl`; `cat-version-history ` / `cvh` with `-j` / `--json`; `cat-lane-history ` / `clh`; `cat-scope [scopePath]` with `-f` / `--full`, `-j` / `--json`, and `-e` / `--json-extra` | `bit-cli`, `bit-object-import`, `bit-install`: reads raw and structured `Component`, `Version`, `Lane`, `VersionHistory`, `LaneHistory`, `Symlink`, `ScopeMeta`, and `Source` objects directly from `.bit/objects` or bare scope objects, resolving active workspace lane heads for `@latest`, diagnosing missing objects (`object not found`), missing or ambiguous component/lane entries, missing versions (`VersionNotFound`, `VersionNotFoundOnFS`), and missing scopes (`ScopeNotFound`), and rendering scope object tables, full lists, or JSON | none | non-UTF-8/binary source bodies without headers, `--full --json` with `Version` objects on `cat-scope`, or unknown flags decline before effects | +| `component-issues` with optional `--json` / `-j` | `bit-cli`: renders the static catalog of all 31 `ComponentIssues` classes, descriptions, solutions, and tag-blocker flags as a formatted CLI table or JSON object | none | unknown flags or arguments decline before effects | +| `version-history ` / `vh ` (`show ` with `-j` / `--json` or text and optional `-s` / `--short-hash`; `build ` for complete local histories using literal IDs or glob, comma-separated, and `!` negation patterns, optionally with `--from-snap` for a single ID or `--from-all-lanes`; bare-scope `build ` for a single matched component with an absent or qualified partial history, local versions, unrelated stored entries, completed-graph references, and the current process group, optionally from a selected snapshot or across qualified unrelated lanes or up to eight qualified lane heads, or with deletion into a qualified local trash path; workspace and bare-scope `build --remote ` for configured HTTP scopes or qualified local histories with file remotes) | `bit-cli`, `bit-object-import`, `bit-install`: reads stored `VersionHistory` data, formats JSON or graph text with tags and lane pointers, proves complete ancestry for unchanged rebuilds, and reconstructs and atomically publishes bounded acyclic histories, including unrelated and squash edges, for local bare-scope components; dispatches remote HTTP rebuilds (using global remotes in bare scopes) and formats their results; for file remotes preserves the released client-scope behavior | installed Bit version metadata for HTTP headers; no JavaScript host for the supported remote path | other incomplete history rebuilds, unconfigured bare-scope or incomplete workspace file-remote rebuild dispatch, stateful (`$`), attribute (`:`), or `AND` rebuild patterns and unsupported flags, shared-head pointers from different remotes, cyclic or oversized graphs or colliding short hashes, `version-history graph`, and unknown flags decline before effects | +| `snap-distance [source-snap] [target-snap]` | `bit-cli`, `bit-object-import`, `bit-install`: traverses stored `VersionHistory` and `Version` parent graphs via BFS from both explicit snaps or between the local head and tracked remote head on `main` to compute lowest common ancestors and snap distances, and reports missing components or scopes | none | missing snap hashes in local objects, no-snap invocations on active lanes, or unknown flags decline before effects | +| `aspect ` with `-d` / `--debug`, `-m` / `--merge`, and `-j` / `--json` | `bit-cli`, `bit-install`, `bit-object-import`: lists compiled-in core aspects, inspects component aspect configurations, origins, and variant specificity rules (`list`, `get`, `get --debug`), resolves `@version` component patterns and local-scope / `rootDir` aspect identities (reporting `MissingScope` for unknown unscoped names and unrecognized subcommands), sets/updates aspect configurations in `.bitmap` (with optional deep object merge) or `/component.json`, and removes configured entries (including marking model/variant-inherited aspects with `"-"`) while recording `.bitmap` history | none | stateful/attribute/AND patterns, non-JSON object configs on `set`, or unknown flags decline before effects | +| `envs [list\|get\|set\|unset\|replace\|update]` / `env [list\|get\|set\|unset\|replace\|update]` with `-j` / `--json` and `--services ` | `bit-cli`, `bit-install`, `bit-object-import`: lists workspace or bare-scope components and their assigned environments (`list` or bare `envs`), inspects component environment services and available scripts in workspaces and bare scopes (`get`), reports `workspace not found` outside a workspace and scope, assigns, replaces, or updates verified core, workspace-defined, or locally stored custom environments on matching components (removing prior env entries and writing `"teambit.envs/envs": { "env": "" }`), or unsets configured environments in `.bitmap` / `component.json` | none | external non-core environment service inspection on `get`, remote-only custom target envs not present in the workspace, local/file scope, or core catalog, stateful/attribute/AND patterns, or unknown flags decline before effects | +| `eject-conf ` with `-p` / `--propagate` and `-o` / `--override` and `-j` / `--json` | `bit-cli`, `bit-install`, `bit-object-import`: ejects merged bitmap, existing `component.json` (when `-o` / `--override` is passed), and stored model extension configuration into `/component.json` for unversioned and versioned workspace components and cleans up `.bitmap` `config` | none | missing version objects, malformed existing `component.json`, active merge conflict state, stateful/attribute/AND patterns, or unknown flags decline before effects | +| `set-peer `; `unset-peer ` | `bit-cli`, `bit-install`, `bit-object-import`: sets `"teambit.dependencies/dependency-resolver": { "peer": true, "defaultPeerRange": "" }` (merging previous model/bitmap/component.json config) or removes `"teambit.dependencies/dependency-resolver": "-"` in `.bitmap` / `component.json`, invalidates the workspace dependency cache, and records `.bitmap` history | none | invalid semver range on `set-peer`, missing or ambiguous component IDs, outside a workspace, or unknown flags decline before effects | +| `deps \|set\|remove\|unset\|reset\|eject\|usage\|diagnose [--package \|--origins]\|blame \|write\|circular>` / `dependencies` alias; `why ` with `--depth `; `get` accepts `-t` / `--tree` and `--scope` in workspaces and bare scopes (including `@version` and scope-only IDs), and `circular` accepts `-j` / `--json` and `--include-deps` | `bit-cli`, `bit-install`, `bit-object-import`, `bit-workspace-config`: inspects workspace, bare-scope, or locally stored model dependency details as a table or tree (`get`), prints dependency source JSON for fresh built-in Node or React components and plain tagged Node and tagged or snapped React components without resolved imports, plus tagged Node components with saved plain-string or optional runtime, development, or peer policies, or ignored built-in Node development packages and unresolved names, including when workspace dependency policies are configured and for plain-string or optional runtime, development, and peer bitmap policy entries, including ignored unresolved names, built-in Node environment development packages, or unresolved `+` runtime entries alongside plain-string runtime, development, or peer entries and ignored bitmap entries, and saved unresolved `+` runtime, development, or peer entries or bitmap policies added after tagging plain Node components, including saved plain-string, ignored, or unresolved `+` policies repeated in the bitmap alongside new entries; unresolved development or peer `+` entries on fresh Node components also run natively, as do peer `+` entries combined with distinct plain-string runtime, development, or peer entries, including runtime and development together, or with distinct unresolved runtime and development `+` entries, or with an unresolved runtime `+` entry and a plain-string runtime entry, optionally with distinct plain-string development and peer entries, or an unresolved development `+` entry and a plain-string development entry, optionally with distinct plain-string runtime and peer entries, or distinct unresolved runtime and development `+` entries alongside plain-string runtime and development entries, optionally with a plain-string peer entry, on fresh or tagged Node components, with peer-over-development-over-runtime precedence for duplicate names including unresolved `+` entries, on Node components (`debug`), merges/removes/unsets/resets/ejects dependency overrides (`--dev` / `-d`, `--peer` / `-p`, `--optional` / `-o`) in `.bitmap` or `/component.json`, traces reverse dependency usage trees across `pnpm-lock.yaml` and workspace components (`usage` and top-level `why`), reports installed pnpm copies, version spread, and peer permutations (`diagnose`), including component origins for explicit dependency policies, traces dependency version changes across local component versions (`blame`), finds graph cycles as text or JSON (`circular`), and writes resolved model dependencies to `workspace.jsonc` or `package.json` (`write`), invalidating the workspace dependency cache and recording `.bitmap` / `workspace-config` history | none | `deps diagnose --origins` for inferred or environment-sourced peer dependencies, `deps get --scope` for unstored or missing dependency models, `deps debug` for unsupported saved versioned policies or resolved versioned imports, conflicting saved and bitmap policy versions, lifecycles, or ignored entries, custom or variant-configured components, unsupported component policies, resolved imports, or installed dependencies, stateful/attribute/AND patterns, or unknown flags decline before effects | +| `capsule`, `capsule list` with `-j` / `--json` and `--with-stats`, `capsule prune` with `--dry-run`, `--older-than`, `--keep-workspace-caps`, `--no-orphans`, `--with-sizes`, and `-j` / `--json`, `capsule create [component-ids...]` with `--seeders-only`, `-a` / `--always-new`, `-b` / `--base-dir `, `-i` / `--id `, `-r` / `--root-base-dir `, `--use-hash`, `-p` / `--package-manager ` (released no-op without installation), `-j` / `--json`, `capsule delete` with `-a` / `--all` and `--scope-aspects` | `bit-cli`, `bit-workspace-config`, `bit-install`, `bit-object-import`: resolves the workspace/scope capsule root directories (`object-hash` SHA-1 directory hashes under `capsules_root_dir`), lists existing capsules as text or JSON with optional full-cache size and age statistics, prunes global capsules by age and origin (or previews with `--dry-run`), including outside a workspace or scope, creates isolated component capsules with dependency closures, root origin/package metadata and ready markers, built-in core-environment component `package.json` manifests with in-network dependencies, additional bitmap or versioned-model package metadata (bitmap values take precedence), configured package names, main paths, licenses, type declaration paths, exports maps or strings, optional dependency entries, peer dependency metadata, and configured componentId objects (with released ignored version and runtime, development, and peer dependency-map overrides) and copied component source files (overwriting a source `package.json`), and deletes workspace, scope-aspect, or all capsule directories (including `--all` outside a workspace or scope); custom capsule base directories accept relative paths (including `.` and `..` segments), Unix absolute paths joined under the capsule root, `.` or `/` for the root itself, or their short hash, `--root-base-dir` selects an absolute root or resolves relative roots (including `.` and `..` segments) against the current directory, `--id` selects a safe directory name for one capsule or multiple with `--always-new`, and create JSON matches the released array shape; bare `capsule` and `capsule list` outside a workspace or scope report the released requirement error when `CI` is unset (CI stack traces delegate); outside a workspace or scope with `CI` unset, bare, one-ID, and two-ID `capsule create` forms with selected `--json`, `--seeders-only`, `--always-new`, `--base-dir`, `--use-hash`, `--root-base-dir`, `--id`, or `--package-manager` flags (including their bare and before-ID forms) emit their released errors (JSON suppresses the loader), bare `capsule delete` removes the configured global capsule root, and `capsule delete --scope-aspects` emits its released error (CI stack traces delegate); in a bare scope, selected `capsule create` forms with no IDs or an unscoped first ID emit their released pre-import errors (CI stack traces delegate) | none | dated capsule roots, Windows absolute `--base-dir` paths, multi-capsule `--id` without `--always-new`, unsafe `--id` names, `-d` / `--install-packages`, custom or variant-configured capsule environments, unsupported generated package metadata shapes, non-workspace capsule creation, other `capsule create` or `capsule delete` forms outside a workspace/scope, or `--no-color` / unknown flags decline before effects | +| `ws-config ` / `workspace-config ` with `-j` / `--json`, `-s` / `--silent`, `-c` / `--clean`, `-n` / `--no-dedupe`, `--dry-run`, `--dry-run-with-content`, `-v` / `--verbose`, `-f` / `--force`, `-w` / `--writers ` | `bit-cli`, `bit-workspace-config`, `bit-install`: lists workspace config writers (`TypescriptConfigWriter`, `EslintConfigWriter`, `PrettierConfigWriter`) as text or `--json`, writes real config files (`tsconfig.bit.*.json`, `global-types/*.d.ts`, `.eslintrc.bit.*.json`, `.prettierrc.bit.*.cjs`) in `node_modules/.cache/` and extending config files (`tsconfig.json`, `.eslintrc.json`, `.prettierrc.cjs`), cleans Bit-generated config files, and reports unrecognized `ws-config` subcommands and outside-workspace `ConsumerNotFound` errors | none | custom non-core environment config writers, interactive `clean` inside a workspace without `--silent` or `--dry-run`, or unknown flags decline before effects | +| `insights [names...]` with `-l` / `--list`, `-j` / `--json`, and `--include-deps` | `bit-cli`, `bit-workspace-config`, `bit-install`, `bit-object-import`: lists built-in insights (`-l` / `--list`, including when positional names are passed) and evaluates `circular` and `duplicate dependencies` (defaulting to both when no names are specified) in workspaces and bare scopes as text or JSON; `--include-deps` includes cycles without local seeders | none | unimported npm Bit dependencies requiring remote object fetch or unknown flags decline before effects | +| `cli generate --docs` with optional `--metadata`, `--metadata.=`, or `--skill ` | `bit-cli`, `bit-workspace-config`: renders the static markdown reference stub when `--docs` is passed, including documented metadata and skill options, or reports unknown `--skill ` values (`Unknown skill type: "". Use "commands" or "reference".`) | none | interactive `cli`, `cli generate` with `--json` or valid `--skill commands\|reference` without `--docs`, or unknown flags decline before effects | +| `internalize [component-pattern]` with `-r` / `--revert`, `-l` / `--list`, and `-j` / `--json` | `bit-cli`, `bit-install`, `bit-object-import`: sets or unsets `"teambit.component/internalize": { "internal": true/false }` on matching components in `.bitmap`, lists internal components from `.bitmap`, `/component.json`, and local scope `Version` objects, records `.bitmap` history, and formats text or JSON | none | stateful (`$`), attribute (`:`), or `AND` patterns, variant-configured internalize on `--list`, outside a workspace, or unknown flags decline before effects | +| `mcp-server [editor]` with `--consumer-project`, `--include-additional `, `-g` / `--global`, `-p` / `--print`, and `--force-standard` | `bit-cli`, `bit-workspace-config`: writes or prints editor-specific MCP server JSON configurations (`vscode`, `cursor`, `windsurf`, `roo`, `cline`, `claude-code`) and embedded rule markdown templates with editor frontmatter, and reports unrecognized `mcp-server` subcommands | none | bare `mcp-server`, `mcp-server start`, or unknown flags decline before effects | +| `gc` with `--restore`, `--restore-overwrite`, `-d` / `--dry-run`, `--keep-versions `, `--backup`, `-v` / `--verbose`, and `-j` / `--json` | `bit-cli`, `bit-workspace-config`, `bit-object-import`, `bit-install`: validates options; plans workspace collection before effects; preserves workspace, lane, remote, stash and merge roots; collects bare-scope unreachable objects while retaining full local history, lane roots and requested flattened dependency versions, including legacy component aliases and boxed names in both workspace and bare-scope roots. Scope metadata uses its content identity. Bare verbose reports include object identities and repeated lane version counts. Bare collection reproduces the released behavior of moving objects to `deleted-objects` even without `--backup`. Unix recovery preserves symlinks, overwrite rules, copy ordering, rollback and failed-cleanup directories. Filesystem failures after effects remain owned, including qualified CI diagnostics; no published-Bit host calls | none | Windows nonempty collection and recovery; workspace verbose text reports deleting multiple objects; bare verbose reports with multiple component models, multiple deleted objects or multiple foreign lane roots; configured startup callbacks, bitmap auto-sync and legacy aliases with missing or non-component targets; unsupported object metadata/layouts, incomplete required history, inventories above 100,000 objects or the existing decoder bound; collection exceeding one configured I/O batch (maximum 256), and workspace stray-file batches above 256. Workspace deletion reports containing both Version and Source objects decline before effects until glob-dependent report ordering is qualified. Move layouts with identity/type conflicts or an inaccessible destination in a multi-object batch decline before effects pending asynchronous failure-order parity. Semantic CI recovery errors needing JavaScript stack diagnostics also decline during preflight. Unknown flags also decline before effects | +| `ci [verify\|pr\|merge\|sync]` | `bit-cli`, `bit-workspace-config`, `bit-install`, `bit-object-import`: handles bare `bit ci`, validates workspace presence across `verify`/`pr`/`merge`/`sync`, validates `--auto-merge-resolve` (`merge`) and contradictory target/onboarding flags (`sync`), executes `ci sync --init` onboarding (workflow templates, comment-preserving config, agent instructions and GitHub/Enterprise checklist) without the published runtime on supported workspaces, and executes native `ci pr` (lane creation/sanitization, spec validation, snap, export, and `.bitmap`/`scope.json` cleanup) and native `ci merge` (branch checkout, `bitmapAutoSync`, version bump tagging, export, `.bitmap` Git commit, and push with rebase retry) | No callbacks for supported onboarding; PR/merge child operations retain their documented runtime boundaries | `ci verify` and reconciliation with `ci sync` still delegate. Onboarding declines before writes for configured startup callbacks, active lanes, bitmap auto-sync, and unsupported versioned/malformed CI config; custom aspect pipelines remain delegated | +| `ripple [sub-command]` | `bit-cli`, `bit-workspace-config`: handles bare `bit ripple`, validates `--limit` positive integer values on `ripple list` (text or `--json`), validates local lane and `--lane` requirements on `ripple simulate` (text or `--json`), and handles no-arg `ripple ` when no active lane or recent export exists (text or `--json`) | none | remote GraphQL `ripple` subcommands decline before effects | +| `dependents ` with `-j` / `--json` | `bit-cli`, `bit-install`, `bit-object-import`: validates that matching `.bitmap` components (resolved by ID, name, or `rootDir`) are scoped (rejecting unscoped new components with ` is a new component, there is no data about it in the scope` and unknown unscoped or invalid names/scopes with `MissingScope`, `InvalidScopeName`, or `InvalidName`), builds workspace and local-scope dependency graphs from `.bitmap`, `Version` objects, and scanned imports, and renders transitive `workspaceDependents` and `scopeDependents` as a table or `--json` | none | outside a workspace or unknown flags decline before effects | +| `eject ` / `E` with `-f` / `--force`, `-j` / `--json`, `--keep-files` | `bit-cli`, `bit-install`, `bit-object-import`: validates that matched workspace components are exported, unmodified, and unstaged (reporting modified or staged components unless `--force`), installs their published npm packages into `workspace.jsonc` `dependency-resolver.policy.dependencies` via native `bit-install`, removes source directories unless `--keep-files`, untracks `.bitmap` entries, and records `.bitmap` history | none | stateful (`$`), attribute (`:`), or `AND` patterns, unexported components, `--json`, outside a workspace, or unknown flags decline before effects | +| `clone [dir]` with `-x` / `--skip-dependency-installation`, `--branch`, `--preserve` | `bit-cli`, `bit-workspace-config`: validates `/` component ID syntax (`InvalidBitId`, `InvalidScopeName`, and `InvalidName`), rejects cloning inside an existing Bit workspace or into a non-empty target directory, and delegates workspace bootstrap and import | none | valid remote component clones into a fresh directory decline to the canonical clone workflow | +| `create ` with `-n` / `--namespace`, `-s` / `--scope`, `-a` / `--aspect`, `-t` / `--template`, `-p` / `--path`, `-e` / `--env`, `--force` | `bit-cli`, `bit-workspace-config`: validates workspace presence and non-empty template/component names before delegating template generation | none | valid component generation inside a workspace declines to the canonical generator aspect | +| `new ` with `-a` / `--aspect`, `-t` / `--template`, `-d` / `--default-scope`, `-x` / `--skip-git`, `--empty`, `--load-from `, `--standalone`, `--agent [type]` | `bit-cli`, `bit-workspace-config`: validates non-empty template/workspace names, rejects running inside an existing Bit workspace, and rejects existing non-empty target workspace directories | none | valid workspace template scaffolding into a fresh directory declines to the canonical workspace generator | +| `templates` with `-s` / `--show-all`, `-a` / `--aspect `, and `-j` / `--json` | `bit-cli`, `bit-workspace-config`: renders built-in workspace starter templates outside a workspace and built-in component generator templates inside standard workspaces (filtered by `--aspect` and `--show-all`) as formatted text or `--json` | none | custom `teambit.generator/generator` aspect configurations or unknown flags decline to the canonical generator aspect | +| `debug-load ` with `--all-aspects`, `-p` / `--aspect-plus-deps `, `--skip-deps`, `--json`, `-r` / `--re-load` | `bit-cli`, `bit-install`, `bit-object-import`: validates workspace presence (reporting `ConsumerNotFound` outside a workspace) and verifies that the target component exists in `.bitmap` before delegating aspect-load isolation probing | none | valid workspace component debug-load invocations decline to the canonical workspace aspect loader | +| `login` with `--cloud-domain `, `-p` / `--port `, `--no-browser`, `--machine-name `, `-f` / `--force`, `--suppress-browser-launch`, `-j` / `--json` | `bit-cli`: validates `--port` numeric port range, conflicting `--default-cloud-domain` + `--cloud-domain` flags (text or `--json`), and CLI flags, and handles non-interactive CI environments where browser login cannot open | none | interactive browser OAuth sessions decline to the canonical login server | +| `script [script-name]` / `run-script` with `-l` / `--list` | `bit-cli`, `bit-install`, `bit-object-import`: inspects `teambit.workspace/scripts.envs` in `workspace.jsonc` (or reports unconfigured envs outside a workspace or bare scope), resolves matching workspace `.bit-env.*` environment components (enforcing exact version matching when specified), parses `Scripts.from(...)` definitions, lists available scripts (`--list`), and executes shell or function scripts across components | none | external non-workspace script environments or unknown flags decline to the canonical scripts aspect | +| `test [pattern-or-test-file...]` / `at` with `-w` / `--watch`, `-d` / `--debug`, `--update-snapshot`, `--coverage`, `-e` / `--env `, `-s` / `--scope `, `--junit `, `--all`, `-u` / `--unmodified` | `bit-cli`, `bit-install`: validates workspace/flags, empty selections, and component patterns (`MissingBitMapComponent`, `InvalidBitId`, `InvalidScopeName`, `InvalidName`, and `NoIdMatchPattern`); owns built-in Node/React JSON selection, loader caches, test discovery, execution and result/error projection for single components and same-environment multi-component reports, including JSON and text summary output, empty changed selections, selected components without tests, and same-environment non-interactive ordinary text with discovered or explicit test files, deprecated text selection flags, JUnit XML, explicit built-in environment selection, explicit test files across components, snapshot updates, test exit codes, coverage collection and consumption of existing/test-created coverage reports | external Jest 29.7 and Babel with native-owned JS configuration adapters; installed distribution metadata resolves tools | interactive terminals, mixed environments, tailored locales for multi-component text reports, custom providers, watch/debug modes and unsupported loader inputs decline before workspace effects; stable native tool assets and Jest global cache identities differ from the installed runner; streamed test output uses temporary files, so test code inspecting stdout/stderr file types can observe a difference | +| `validate [component-pattern]` with `-a` / `--all`, `-u` / `--unmodified`, `-c` / `--continue-on-error`, `--fail-fast`, `--skip-tasks ` | `bit-cli`, `bit-install`, `bit-object-import`: resolves new, modified, or pattern/unmodified workspace components (reporting `MissingBitMapComponent`, `InvalidName`, and `NoIdMatchPattern`), validates `--skip-tasks`, and executes native `1/M Type Checking`, `2/M Linting`, and `3/M Testing` steps (respecting `--fail-fast` and `--continue-on-error`) | none | custom validator environments or components with spec files requiring the tester runtime decline to the canonical validator aspect | +| `build [component-pattern]` with `-u` / `--unmodified`, `-d` / `--dev`, `--install`, `--reuse-capsules`, `--rewrite-capsules`, `--tasks `, `--skip-tasks `, `--skip-tests`, `--loose`, `--fail-fast`, `--include-snap`, `--include-tag`, `--ignore-issues `, `--list-tasks [id]` | `bit-cli`, `bit-install`, `bit-workspace-config`: validates workspace presence, handles empty-workspace invocations (text or `--json`), validates component patterns against `.bitmap`, renders `BuildPipeline` / `TagPipeline` / `SnapPipeline` task lists for `--list-tasks`, and materializes component capsules and `dist/` outputs for standard workspace components | none | components with `.spec.` / `.test.` files, `--loose`, `--tasks` / `--skip-tasks`, or custom build pipelines decline to the canonical builder aspect | +| `artifacts ` with `-a` / `--aspect `, `-t` / `--task `, `-f` / `--files `, `--out-dir ` | `bit-cli`, `bit-install`, `bit-object-import`: validates workspace or bare-scope presence (reporting `Host was not found` outside both), resolves component patterns and `@version` IDs from `.bitmap` and `.bit/index.json` in workspaces and bare scopes (reporting missing literal or pattern matches), reads `teambit.pipelines/builder` artifacts from local or `file://` remote `Version` objects, filters by `--aspect`, `--task`, and `--files`, renders formatted artifact trees, and extracts `Source` blobs to `--out-dir` | none | stateful/attribute/AND patterns in bare scopes or unknown flags decline before effects | +| `check-types [component-pattern]` with `-a` / `--all`, `--strict`, `-j` / `--json`, `-u` / `--unmodified` | `bit-cli`, `bit-install`, `bit-workspace-config`: validates workspace presence, handles empty-workspace invocations (text or `--json`), resolves component patterns from `.bitmap` (including glob, comma, and `!` negation patterns and reporting `MissingBitMapComponent`, `InvalidName`, and `NoIdMatchPattern`), and checks TypeScript `.ts` / `.tsx` files across matched workspace components | none | custom TypeScript compiler environments or unsupported flags decline to the canonical compiler/tester aspect | +| `compile [component-names...]` with changed, verbose, JSON and dist-deletion options | Rust validates workspace presence and component selectors (`InvalidBitId`, `InvalidScopeName`, and `InvalidName`) and owns qualified built-in Node/React component loading, dependency/docs caches, selection, emission scheduling, dist copies, package linking and reports. External TypeScript supplies transpilation and source maps; Rust preserves copied-file ordering, stale dists, diagnostics, cold-cache multi-component JSON/verbose report ordering, multi-component reports with one diagnostic component, cold-cache multi-component JSON/text/verbose reports with multiple diagnostic components, and qualified partial-write errors | External TypeScript and TypeScript ESTree diagnostic tools, resolved using installed distribution metadata; no Bit runtime imports for qualified paths | Custom providers/environments, mixed environment groups, rich documentation cache misses, unsupported dependency syntax, type-generation, colliding outputs, non-text sources, warm-cache multi-component JSON/verbose or multi-diagnostic text reports, and multi-component `--changed` reports without explicit selectors remain guarded. No-selector JSON preserves the released handler failure through delegation | +| `format [component-pattern]` with `--changed`, `--check`, `-j` / `--json` | `bit-cli`, `bit-install`: validates host presence and workspace component patterns, and owns built-in Node/React environment selection, source checks, text/JSON reports, dependency/history/documentation caches and serial writes of writable changed files per component (or a single read-only changed file per component) with a cold dependency cache; Prettier runs as an external Node tool without Bit runtime modules | none for qualified inputs; external Prettier still requires Node and the installed tool package | mixed environment groups, custom environments/providers, unsupported dependency syntax, semantic documentation cache misses, stateful patterns, cached batches beyond two-component JSON checks, multi-file source writes containing non-writable files, ignored file extensions, unsupported sources/tool failures and noncanonical cache settings decline before effects | +| `lint [component-pattern]` with `--changed`, `-f` / `--fix`, `--fix-type `, `-j` / `--json` | Rust validates host presence and workspace component patterns, selects one built-in Node/React environment component or up to eight with a cold dependency cache, prepares dependency/history/documentation caches and their shared generated TypeScript config, aggregates diagnostics, renders text/JSON/CI/color reports and owns serial fixes for one file per component and writable changed files per multi-source component when their canonical config is cached; ESLint runs as an external Node tool | No Bit runtime or configuration-module imports for qualified inputs; tool/config package metadata still comes from the installed distribution | More than eight components, cached multi-component loading, mixed environment groups, multi-file fixes within a component containing non-writable files, multi-source fixes without a cached canonical config, unsupported dependency syntax, semantic documentation cache misses, noncanonical generated configs, custom environments/providers and built-in lint configurations other than 2.0.27 decline before effects | +| `link [component-ids...]` with `-j` / `--json`, `-v` / `--verbose`, `-r` / `--rewire`, `--target `, `--peers`, `--skip-fetching-objects`, `--comp-summary` | `bit-cli`, `bit-install`, `bit-workspace-config`: generates component package manifests and links workspace components into `node_modules` and `.bit_roots`, rewrites relative cross-component imports to package specifiers (`--rewire`), and links workspace components and their peer dependencies (`--peers`) into external target directories (`--target `) | none | custom workspace env/aspect `package()` transformers or unknown flags decline before effects | +| `watch` with `-v` / `--verbose`, `-x` / `--skip-pre-compilation`, `-t` / `--check-types [mode]`, `-i` / `--skip-import`, `--trigger ` | `bit-cli`, `bit-workspace-config`: validates workspace presence and CLI options before delegating file watcher startup | none | active file-watcher sessions inside a workspace decline to the canonical watcher aspect | +| `start [component-pattern]` / `c` with `-d` / `--dev`, `-p` / `--port `, `-r` / `--rebuild`, `-v` / `--verbose`, `--no-browser`, `--skip-ui-build`, `--ui-root-name `, `-u` / `--user-pattern `, `--skip-Compilation` | `bit-cli`, `bit-workspace-config`: validates workspace or bare-scope presence, validates `--port` numeric values, and validates CLI flags before delegating UI/dev server startup | none | UI server sessions inside a workspace or bare scope decline to the canonical UI aspect | +| `ui-build [ui-root-name]` with `-d` / `--dev`, `--dir ` | `bit-cli`, `bit-workspace-config`: validates workspace or bare-scope presence and CLI flags before delegating static UI bundle builds | none | UI bundle builds inside a workspace or bare scope decline to the canonical UI aspect | +| `app` / `apps`; `app list` / `apps list` with `-j` / `--json` (including inline `list` JSON values and separate literal `true` / `false`, where only `true` enables JSON output); `app run [name]` / `apps run [name]` / `app[s] c [name]` outside a workspace | `bit-cli`, `bit-install`, `bit-object-import`: scans active workspace components in standard environments for `*.bit-app.*`, `*.app-root.*`, `*.node-app.*`, and `*.react-app.*` plugins, extracts app names, and renders a formatted table or a `list --json` array (or `no apps found` / `[]`); outside a workspace or in a bare scope, bare forms emit the released empty text result and `list` forms emit the released empty text or JSON result, and `run` or its `c` alias with zero or one safe name and supported execution flags (`--dev`, `--port`, `--watch`, `--no-browser`, `--args`, and `--verbose`) emits the released `no apps found` failure; the same `run` error is owned in standard workspaces with no discoverable apps, including CI output (which suppresses component-loading status) and the ordinary component-loading status for non-app components; bare and `list` forms also produce the released text table or empty result under CI, with the command loader line for text output and the same JSON output; bare `app[s]` sequences of `--json`, `-j`, and `--no-color` preserve the released ordered, deduplicated unknown-argument names; `app[s] list` flag-only JSON/color sequences reject `--no-color` even when repeated; exact `app[s] list --no-color` (alone or combined with `-j`, `--json`, inline JSON, or a separate literal `true` / `false` JSON value, including `--no-color` between the flag and value or a leading literal value before the JSON flag), `app[s]` or `app[s] list` with `--ssr`, `--dev`, `--verbose`, `--no-browser`, `--watch`, `--port[=value]`, or `--args[=value]` (including rejected short aliases and `=value` forms), and `app[s] run` or `app[s] c` with `--ssr`, `--json`, or `--no-color` (with or without a safe app name) emit the released command help and unknown-argument error before workspace discovery; exact `app[s] run` or `app[s] c` with `--help` or `-h` emit the released command help before workspace discovery; exact `app[s]` and `app[s] list` with `--help` or `-h` also emit their released command help before workspace discovery; help descriptions, option labels, links, and unknown-argument diagnostics retain the released terminal styling | none | unhandled `--no-color` combinations decline before workspace discovery; custom root application aspects in `workspace.jsonc` and other unsupported help-only flags or grammar decline to the canonical application aspect | +| `run ` with `-d` / `--dev`, `-p` / `--port `, `-v` / `--verbose`, `-w` / `--watch`, `--no-browser`, `--args ` | `bit-cli`, `bit-install`: validates supported flags, `--port` numeric values, and non-empty ``, and handles missing-app lookup outside a workspace, in a bare scope, or in standard workspaces with no discoverable apps, including CI output (which suppresses component-loading status) and the ordinary component-loading status before the released failure; exact `run --no-color`, `run --json`, `run --ssr`, and their `` forms emit the released command help and unknown-argument error before workspace discovery; exact `run --help` and `run -h`, with or without a safe app name, emit the released command help before workspace discovery; help links and unknown-argument diagnostics retain the released terminal styling | none | app execution in workspaces with discoverable apps or custom root aspects, other unsupported help-only flags, and unsupported grammar decline to the canonical application aspect | +| `show --help` / `-h`, optionally after a component name and supported boolean flags; missing-component argument errors; `show --no-color` rejection | `bit-cli`: released help, colors, diagnostics and exit codes before workspace loading | none | other help argument layouts and unsupported flag combinations delegate | +| `show ` with `-j` / `--json`, `-l` / `--legacy`, `-r` / `--remote` | `bit-cli`, `bit-install`, `bit-object-import`: preliminary rendering of eligible unversioned workspace components, scope-only or bare-scope components (including `@version` and `--json` / `--legacy`), and configured file-scope `Version` objects; workspace modern and legacy JSON still have known released-CLI parity gaps in their fields and aspect metadata | none | CI, versioned `.bitmap` workspace components, non-file `--remote`, `--versions`, `--compare`, `--browser`, unsupported historical inspection, or unknown flags delegate before effects | +| `schema ` / `api ` with `-j` / `--json`, `-r` / `--remote`; `schema diff ` with `-j` / `--json` | `bit-cli`, `bit-install`, `bit-object-import`: resolves literal IDs and glob, comma-separated, and `!` negation patterns in workspaces or bare scopes, reports missing or multi-match components, and extracts stored `teambit.semantics/schema` artifacts from unmodified `Version` objects (`--json`) | none | modified/untagged workspace component AST schema extraction, schema diff computation on valid single components, stateful (`$`), attribute (`:`), or `AND` patterns in bare scopes, or `--remote` schema queries decline to the canonical schema aspect | +| `graph [id]` with `-i` / `--image `, `--remote [scopeName]`, `--all-versions`, `--layout `, `--cycles`, `--png`, `--include-dependencies`, `-j` / `--json` | `bit-cli`, `bit-install`, `bit-object-import`: builds workspace and bare-scope component dependency graphs (`nodes` and `edges`) from `.bitmap`, stored `Version` objects (including `flattenedDependenciesRef` and `flattenedEdgesRef`), and scanned imports, supports seeder filtering, `--cycles`, and `--include-dependencies`, and renders `--json` or SVG output | none | `--png`, `-i` / `--image`, `--remote`, or unknown flags decline to the canonical graph aspect | +| `publish ` with `-d` / `--dry-run`, `-j` / `--json`, `--allow-staged` | `bit-cli`, `bit-install`, `bit-object-import`: validates workspace presence, verifies local component existence, enforces non-staged version tags unless `--allow-staged` and local scope tag/snap presence when `--allow-staged` is passed (text or `--json`), checks `teambit.pkg/pkg` `publishConfig`/`packageJson.private` configuration, and renders `--allow-staged --dry-run` output (text or `--json`) | none | non-dry-run registry publishing or complex custom pkg aspect setups decline to the canonical pkg aspect | +| `pack [scope-path]` with `-d` / `--out-dir `, `-o` / `--override`, `-k` / `--keep`, `-p` / `--prefix`, `-j` / `--json` | `bit-cli`, `bit-install`, `bit-object-import`: resolves workspace or bare-scope components, packages `package/package.json` and component files into a `.tgz` tarball with SHA-1 checksum, enforces `--override` on existing tarballs, and renders text or `--json` output | none | missing component IDs or unknown flags decline to the canonical pkg aspect | +| `update [package-patterns...]` / `up` with `-y` / `--yes`, `--patch`, `--minor`, `--major`, `--semver` | `bit-cli`, `bit-install`, `bit-workspace-config`: collects dependency policies from `workspace.jsonc`, root `package.json`, `variants`, `.bitmap`, and `component.json`, reports 0-matching-dependency invocations (with or without `-y`), queries the configured npm registry for matching latest/semver/patch/minor/major versions with `-y` / `--yes`, updates dependency policies in place preserving comments, and runs native `bit install` | none | interactive `update` with matching dependencies without `-y` / `--yes`, `env.jsonc` component policies, or unknown flags decline before effects | +| `resume-export ` | Rust validates configured HTTP(S) remotes, persists sequentially with bounded retries, and owns `--delete` probing, confirmation, deletion, partial failures, and race reports for HTTP(S) and file remotes in workspaces and bare scopes | HTTP needs published package version metadata only; file deletion needs no published runtime; neither executes Node or the JavaScript host | File persistence, custom-resolved remotes, configured startup callbacks, lane startup, and bitmap auto-sync decline before requests | +| `run-action ` | `bit-cli`, `bit-client-transfer`, `bit-workspace-config`: validates workspace or bare-scope startup and JSON object ``, dispatches remote actions to configured HTTP(S) scopes, and executes `PendingDirStatus` and `RemovePendingDir` on configured `file://` remotes without starting the JavaScript host | HTTP needs published package version metadata only; file pending-directory actions need no published runtime; neither executes Node or the JavaScript host | non-pending `file://` actions, custom-resolved remotes, configured startup callbacks, and unknown flags decline before requests | +| `completion` | `bit-cli`: outputs the standard yargs bash/zsh tab-completion script (`###-begin-bit-completions-###` ... `###-end-bit-completions-###`) based on `$SHELL` / `$ZSH_NAME` | none | unknown flags decline before effects | +| `server` with `-p` / `--port `, `-c` / `--compile` | `bit-cli`, `bit-workspace-config`: validates workspace presence (reporting `unable to run bit-server, the current directory is not a workspace` outside a workspace) and `--port` numeric range before delegating GraphQL/HTTP server startup | none | active GraphQL/HTTP server sessions inside a workspace decline to the canonical express/graphql aspect | +| `generate-preview [pattern]` with `-n` / `--name `; `serve-preview` with `-p` / `--port ` | `bit-cli`, `bit-workspace-config`, `bit-object-import`: validates workspace presence (reporting `unable to find components by the pattern: ` outside a workspace), handles empty-workspace and missing/invalid pattern invocations on `generate-preview`, and validates `--port` numeric range on `serve-preview` before delegating preview bundle generation or serving | none | preview bundle builds for non-empty matched workspace components and dev servers inside a workspace decline to the canonical preview aspect | + +Historical benchmark JSON reports linked below are preserved as files in the [benchmark evidence archive](https://github.com/zkochan/bit/releases/download/rust-benchmark-evidence-2026-10-18/bit-rust-pr10813-legacy-benchmarks-15ef7ef4e.tar.gz) (SHA-256 `ebefd1cde2b05b020e1524359b015187558a5a76c50de71840c26be5d1fb8e4d`). The small summary and checksum index remain in Git. + +Import adoption qualification covers `--track-only`, `--write-to-empty-dir`, noninteractive `--dependents` / `--dependents-via` with `--silent`, and `--write-deps package.json|workspace.jsonc`. Strict same-path fixtures cover existing source preservation, destination collisions and incompatible options, graph paths and via filtering (including modified live workspace dependencies and configured-provider source edits), HTTP capture, highest-range publication across existing and newly imported components, and actual imported-target onLoad failure after publication. All 25 new strict same-path cases pass locally, including default installation with supported JSON output, edited-source docs caches, live component insertion order through pnpm manifests, and actual onLoad callback receipts. Nine original acceptance mappings are recorded, including an explicit delegation control for the ordinary occupied destination. The initial Linux production source `a800673fc` passes the [complete import baseline](https://github.com/zkochan/bit/releases/download/rust-benchmark-evidence-2026-10-18/bit-rust-pr10813-legacy-benchmarks-15ef7ef4e.tar.gz): 450 correct pairs, 440 native pairs, and all 88 eligible native medians lower than Node. The subsequent session-owner heap allocation addresses Windows main-stack pressure. Import acknowledgments also compare native path components while preserving each proof path spelling, so equivalent Windows separators advance only the exact validated content hash. The [completed Windows-proof runtime qualification](https://github.com/zkochan/bit/releases/download/rust-benchmark-evidence-2026-10-18/bit-rust-pr10813-legacy-benchmarks-15ef7ef4e.tar.gz) covers 450 correct pairs (440 native), with all 88 eligible native medians lower than Node. Twenty workload results retain their original source/controller/helper identities; ten missing workloads use independently prepared fork history transcripts and a clean rebuilt, byte-identical release executable. The archive preserves failed preparations, CPU-invalid attempts and raw backup evidence. These are warm-metadata Linux file-remote measurements against Node 22.22.0 / Bit 2.2.94, without cross-platform speed claims. Interactive dependent selection remains a separate pre-effect delegation boundary: even `--dependents-all` still asks for final confirmation without `--silent`, and a strict EOF control exercises the released success-exit/no-import path. The ordinary nonempty, untracked destination without `--write-to-empty-dir` retains its existing pre-effect delegation boundary; its control fixture explicitly requires delegation. Valid adoption modes and semantic option-validation errors require native ownership. + +The [lane-adoption qualification](../scripts/rust/benchmarks/lane-adoption-completion-summary.json) covers 41 workloads and 615 correct comparison pairs, including 605 native pairs. All 121 eligible native workload/mode medians are lower than Node 22.22.0 / Bit 2.2.94. The clean release executable was built with pinned Rust 1.97.0. These are warm-metadata Linux file-remote measurements in ordinary, CI and colored noninteractive PTY (`TERM=dumb`) modes; they do not establish cross-platform or interactive-terminal speed. Ten add error pairs intentionally delegate and are excluded from native speed claims. + +Default installation retains its original first-valid 15 pairs. The remaining 40 workloads use the same exact native executable with a corrected helper that validates fresh command-details timestamps inside their command interval; unchanged baseline metadata stays byte exact. Every result keeps its original source, helper, controller and functional provenance. CPU-invalid runs and earlier failures remain archived, and no valid workload was rerun. Bulk reports are stored in the [release evidence bundle](https://github.com/zkochan/bit/releases/tag/rust-benchmark-evidence-2026-10-18), with a small [checksum index](../scripts/rust/benchmarks/lane-adoption-completion-evidence-index.json) in Git. The qualification archive indexes deduplicated provenance nodes that reconstruct the complete original measurement identity without dropping historical evidence. The [test-only continuation proof](https://github.com/zkochan/bit/releases/tag/rust-benchmark-evidence-2026-10-18) records the subsequent strict four-write scope-history fixture correction and a clean, byte-identical release rebuild; the original measured identities remain unchanged. + +The focused lane tests cover alias and subset selection, real default installation, physically compiled providers, mutable subscribers, CI completion status and independent bitmap-write transcripts. Root-manifest loading follows the root-components setting. Installation and compilation observe the previous lane until the retained Rust continuation publishes the destination lane and aliases. The correctness checkpoint in `native/plans/TEST_PORTING.md` is preserved with the measured native-source identity; the completed performance evidence is linked above. Broader/state selectors, Git branches and the released missing switch JSON handler remain explicit boundaries. Configured-provider active-lane imports retain startup through native fetch and object publication. + +Fork parity fixtures align released/native command launches to the same 50–100 ms wall-clock phase. Functional multi-copy fixtures additionally configure a physically compiled Node-composed environment through component-name variants, whose real onComponentAdd slot waits at least 1,100 ms after every target copy, separating second-resolution bitmap backups through the real callback lifecycle. The environment uses a separate fixture.tools scope outside source selectors. Per-target environment loading guarantees registration even for late-registered scope subcommands that do not bootstrap root-only providers; fixtures assert the real callback receipt exists. Clocks, backup bodies, counts and reasons remain unchanged and strictly compared. Single-copy fixtures use no callback barrier; benchmark fixtures and timings are unchanged. + +`confirm` prompts are asked by the Rust process itself, since it owns the terminal. + +Native commands append to the workspace's `command-history` file (in `.bit`, +or `.git/bit`) as the Node.js CLI does. A declined command writes nothing, so +the Node.js CLI's own lines are not duplicated. + +Scope-rename benchmark backups use second-resolution filenames, so independent +live clocks can retain different intermediate snapshots even with identical +writes. The preparation-only released-CLI oracle records disk preimages and +reasons before each actual backup. The timed commands remain uninstrumented: +every surviving backup, mode, timestamp group and metadata row must match its +last corresponding oracle write, with all raw artifacts retained. Overwritten +intermediate preimages are oracle-derived and are not claimed as observed timed +bytes. The original failed comparison and host-invalid timing remain preserved; +this method does not qualify those earlier samples. + +Ten-component fork batch benchmarks prepare independent released-CLI write transcripts before timing. Each timed run validates every surviving snapshot against the final writer for its actual timestamp, with exact bytes, modes, reasons, ordering and final bitmap checks. Overwritten intermediate preimages are oracle-derived. Single-copy and refactoring comparisons retain their existing checks. Earlier failed comparisons remain unqualified; already-qualified workloads keep their original source/helper identities and are not rerun. + +The [baseline compiler metadata correction](../scripts/rust/benchmarks/import-adoption-completion-provenance-correction.json) records the actual pinned Rust 1.97.0 compiler, also embedded in the measured ELF binary. The original builder queried Rust outside `native/` and recorded the unrelated host compiler. Raw baseline reports, source/binary hashes and measurements remain unchanged. + +Recovery of a remotely deleted component uses a case-specific independent released-CLI write transcript to validate its import/recover backups. Every surviving snapshot byte and mode, metadata row, reason and live timestamp group remains checked against the final writer for that ID. The fixture preserves raw evidence and compares final workspace state exactly; overwritten intermediate preimages are oracle-derived, not claimed as observed timed bytes. + +### Shared runtime qualification + +PR #281 completed fresh qualification of one executable across 49 broader client workloads +and eight graph-reset workloads: 855 correct alternating pairs, 825 native pairs and all +165 eligible native medians below released Node on warm-metadata Linux file remotes. +Thirty deliberately delegated CI-error pairs are excluded from native speed claims. +Historical partial and slower runtimes retain their original identities and do not qualify +later executables. The workflow-only final head rebuilt to byte-identical release bytes. +The verified [release evidence](https://github.com/zkochan/bit/releases/download/rust-benchmark-evidence-2026-10-18/bit-rust-client-vcs-full57-8d7c50245-cb53909a2.tar.gz) +has SHA-256 `447c4ca0812d5b77e174cc24c82b16efba202105489cd5d193698685ed00d940`. +This is historical qualification for that executable; changed code requires fresh evidence. + +### Component and scope fork qualification + +The [complete fork qualification](https://github.com/zkochan/bit/releases/download/rust-benchmark-evidence-2026-10-18/bit-rust-pr10813-legacy-benchmarks-15ef7ef4e.tar.gz) +measures source `4799396e8131b5c9e6c05cc440d014d598f05a7c` against Node 22.22.0 / +Bit 2.2.94. Its native-source SHA-256 is +`93b0889c80289bf790342910cbfb9cb35147f880a9788f33dcb163254f64c0af` and release-binary +SHA-256 is `cde14ad3e9e44e3a08d1d19d6c84d464c34fa07108f9f1d6c8908b3bf386316c`. +Five alternating fresh-copy pairs per non-TTY, CI and TTY mode pass all 360 +output, exit-code, source, control, cache, object, history, mode and link comparisons +across 24 workloads. Of those pairs, 350 run natively; ten add CI error pairs +intentionally delegate and are excluded from native speed claims. All 70 eligible +native workload/mode medians are lower. Non-TTY fork medians in milliseconds are: + +| Workload | Node | Rust | +| --- | ---: | ---: | +| Local fork, 11 source files | 541.434 | 472.238 | +| Local fork with dependent refactoring | 570.569 | 461.985 | +| Local fork, ten components | 881.900 | 798.708 | +| File-remote fork, 11 source files | 567.404 | 470.154 | +| File-remote fork, ten components | 686.725 | 541.130 | +| Scope fork, ten components | 864.747 | 733.166 | + +The [fork samples](https://github.com/zkochan/bit/releases/download/rust-benchmark-evidence-2026-10-18/bit-rust-pr10813-legacy-benchmarks-15ef7ef4e.tar.gz), +[identity](https://github.com/zkochan/bit/releases/download/rust-benchmark-evidence-2026-10-18/bit-rust-pr10813-legacy-benchmarks-15ef7ef4e.tar.gz), +[add](https://github.com/zkochan/bit/releases/download/rust-benchmark-evidence-2026-10-18/bit-rust-pr10813-legacy-benchmarks-15ef7ef4e.tar.gz), +[mutation](https://github.com/zkochan/bit/releases/download/rust-benchmark-evidence-2026-10-18/bit-rust-pr10813-legacy-benchmarks-15ef7ef4e.tar.gz) and +[status/checkout/export](https://github.com/zkochan/bit/releases/download/rust-benchmark-evidence-2026-10-18/bit-rust-pr10813-legacy-benchmarks-15ef7ef4e.tar.gz) +reports retain every sample. Each complete workload qualified on its first attempt +after 20 consecutive seconds below 30% aggregate CPU, with a closed 0.5-second +monitor staying below 80%. The archive embeds the driver, raw CPU counters, +helper fingerprints and immutable source identity. These are controlled warm-metadata +Linux file-remote results; HTTP behavior has functional coverage, without a network +or cross-platform performance claim. + +The [earlier fork baseline](https://github.com/zkochan/bit/releases/download/rust-benchmark-evidence-2026-10-18/bit-rust-pr10813-legacy-benchmarks-15ef7ef4e.tar.gz) +remains unchanged: all 360 correctness pairs passed, but scope rename CI measured +1021.802 ms in Rust versus 1014.847 ms in Node. The changed source reuses lazy +package matchers across generic planning phases and combines added-component cache +seeding with the following on-add host request. The new scope rename CI median is +1014.069 ms versus Node's 1041.988 ms; this small difference should not be treated +as a broad speedup claim. All earlier mutation archives remain unchanged too. + +### Component identity and location + +`move`, component `rename`, `deprecate` and `undeprecate` keep workspace mutation in Rust. Their planners retain the original bitmap and relevant source/configuration inputs, validate paths before materialization, and publish bitmap history through the shared native writer. Moving a directory does not change its component identity. Renaming an exported component creates a new component and either deletes or deprecates the source; it does not rewrite previously published Versions. + +Configured providers load before the authoritative mutation plan. A retained Harmony session supplies actual component classes, onLoad metadata, package transformations, onComponentAdd hooks and compiler tooling. Rust owns inventory, refactoring, file writes, package/link publication and bitmap changes. AST refactoring calls the published transformer on private source files rather than delegating the rename command. In-memory onLoad source changes are retained for the exported-component copy, while an unversioned rename follows the published target reload. + +Unknown grammar and unsupported inputs delegate only before provider startup or another observable prefix. Once providers, caches, links or source publication have produced effects, failures remain owned by the native command. These operations preserve partial publication and callback output rather than rerunning the command through Node. + +The Linux release measurements in [`identity.json`](https://github.com/zkochan/bit/releases/download/rust-benchmark-evidence-2026-10-18/bit-rust-pr10813-legacy-benchmarks-15ef7ef4e.tar.gz) +measure source `50ac1ea7c` against Node 22.22.0 / Bit 2.2.94. Subsequent Windows +fixture and doctor diagnostic changes affect tests only; production sources and +the measured release binary are unchanged. Five alternating pairs +per non-TTY, CI and TTY mode passed all 75 output, filesystem, cache, history and +file-remote comparisons. Non-TTY medians in milliseconds were: + +| Workload | Node | Rust | +| --- | ---: | ---: | +| Move 11 source files | 447.463 | 301.373 | +| Deprecate 100 tagged components | 299.932 | 262.639 | +| Rename a new component with 11 files | 549.241 | 521.201 | +| Rename an exported component with deprecation | 597.438 | 562.636 | +| Rename an exported component with dependent refactoring | 614.769 | 543.997 | + +All fifteen native identity medians were faster. Another 90 add pairs in +[`identity-add-regressions.json`](https://github.com/zkochan/bit/releases/download/rust-benchmark-evidence-2026-10-18/bit-rust-pr10813-legacy-benchmarks-15ef7ef4e.tar.gz) +and 45 status/checkout/export pairs in +[`identity-regressions.json`](https://github.com/zkochan/bit/releases/download/rust-benchmark-evidence-2026-10-18/bit-rust-pr10813-legacy-benchmarks-15ef7ef4e.tar.gz) +passed on the same source and binary; all 40 native workload/mode medians were +faster. Ten intentionally delegated CI error pairs are excluded from native speed +claims. Each complete workload was independently monitored after 20 consecutive +seconds below 30% host CPU use, with a closed 0.5-second monitor staying below 80%. +The [qualification evidence](https://github.com/zkochan/bit/releases/download/rust-benchmark-evidence-2026-10-18/bit-rust-pr10813-legacy-benchmarks-15ef7ef4e.tar.gz) +retains raw counters, the qualification driver and invalid attempts. Earlier +full-suite trials, including [`identity-contended.json`](https://github.com/zkochan/bit/releases/download/rust-benchmark-evidence-2026-10-18/bit-rust-pr10813-legacy-benchmarks-15ef7ef4e.tar.gz), +remain explicitly invalid for performance. These are controlled warm-metadata, +file-remote Linux fixtures, not first-use, network-remote or cross-platform claims. +`scripts/rust/bench-identity.py` prepares and restores fixtures outside command +timing. Rename launches use the same recorded clock phase on both implementations +because published backup filenames have second precision; clocks and snapshot +comparisons remain unchanged. + +### Workspace tracking and mutation qualification + +Scope/owner batch renaming and patterned remove/delete/recover complete the +workspace tracking and mutation milestone. Batch renaming shares a read-only +inventory and prepares independent plans on the bounded Rust worker pool, while +preserving every input proof, first-error order and serial provider/publication +lifecycle. Native documentation cache reads and conservative empty-documentation +AST proofs avoid redundant parsing; other sources retain the published parser. + +The [complete qualification evidence](https://github.com/zkochan/bit/releases/download/rust-benchmark-evidence-2026-10-18/bit-rust-pr10813-legacy-benchmarks-15ef7ef4e.tar.gz) +measures source `0f78ee808` against Node 22.22.0 / Bit 2.2.94. Five alternating +pairs per non-TTY, CI and TTY mode passed all 270 full-output, exit-code, +filesystem, cache, object, history, link/mode and file-remote comparisons. Of +these, 260 pairs ran natively; ten intentionally delegated add CI error pairs +are excluded from native speed claims. All 52 eligible native workload/mode +medians were lower. Non-TTY medians in milliseconds were: + +| Workload | Node | Rust | +| --- | ---: | ---: | +| Remove 20 tagged components | 559.608 | 290.632 | +| Delete 20 tagged components | 500.531 | 333.604 | +| Recover 20 deleted components | 289.245 | 227.931 | +| Rename a mixed new/exported scope with refactoring | 1046.598 | 999.571 | + +Scope gains were modest: 4.2–4.5% in non-TTY/CI, while TTY was effectively tied +(1018.503 ms Node versus 1017.679 ms Rust). Batch package refactoring reuses +lazily compiled matchers, and fully cached capture avoids seeding the same +classes twice; lifecycle callbacks, ordered errors and input proofs remain. +The [mutation samples](https://github.com/zkochan/bit/releases/download/rust-benchmark-evidence-2026-10-18/bit-rust-pr10813-legacy-benchmarks-15ef7ef4e.tar.gz), +[identity samples](https://github.com/zkochan/bit/releases/download/rust-benchmark-evidence-2026-10-18/bit-rust-pr10813-legacy-benchmarks-15ef7ef4e.tar.gz), +[add samples](https://github.com/zkochan/bit/releases/download/rust-benchmark-evidence-2026-10-18/bit-rust-pr10813-legacy-benchmarks-15ef7ef4e.tar.gz) and +[status/checkout/export regressions](https://github.com/zkochan/bit/releases/download/rust-benchmark-evidence-2026-10-18/bit-rust-pr10813-legacy-benchmarks-15ef7ef4e.tar.gz) +use the same measured source and binary. Each complete workload qualified after +20 consecutive seconds below 30% aggregate host CPU, with a closed 0.5-second +monitor remaining below 80%. Every workload qualified on its first attempt; +no samples or rows were removed or remeasured. + +The [earlier qualification](https://github.com/zkochan/bit/releases/download/rust-benchmark-evidence-2026-10-18/bit-rust-pr10813-legacy-benchmarks-15ef7ef4e.tar.gz) +retains source `a3393697c` and its preceding slower trials. The separate +[numeric-target qualification](https://github.com/zkochan/bit/releases/download/rust-benchmark-evidence-2026-10-18/bit-rust-pr10813-legacy-benchmarks-15ef7ef4e.tar.gz) +retains source `3d404801a`: all 270 correctness pairs passed, but scope TTY was +slower (1030.642 ms Rust versus 1018.141 ms Node). These reports retain their +original fingerprints and samples. The current archive also includes 78 actual +released-parser checks of checkout/revert positional coercion (`38938e0` becomes +`38938`), supplementing command parity without counting as performance pairs. +Raw workload reports retain their unqualified flags; the outer closed-monitor +evidence qualifies eligible rows. These are controlled warm-metadata Linux +file-remote fixtures, with no first-use, network-remote or cross-platform speed +claim. Fixture preparation and restoration are outside command timing. + +Ten direct published-parser oracle checks supplement the empty-documentation +proof tests and are not counted as command pairs. The separate installation +e2e masks completion durations and one exact optional pnpm startup timer redraw +immediately before its hard-link banner; final progress, package summaries, +lifecycle order and filesystem state remain exact. Focused checks retain +changed counters, controls, positions and duplicate redraws. + +### Ignore rules + +The workspace inventory follows `.gitignore`, `.bitignore` and Bit's own lists +through `ignore` 3.3.10, the npm package `@teambit/legacy.bit-map` uses. The +`bit-ignore` crate (`native/ignore`) ports its rule compiler. Each replacement +step keeps the package's original JavaScript regular expression and runs it +with `regress`, so the compiled rule sources are the ones Node would produce. +Its tests compare them with sources the package itself produced. + +The port includes the package's module-level cache. The cache is keyed by the +pattern alone, so a pattern compiled once as positive stays positive when it +is later negated, as in Node. Patterns named like `Object.prototype` members, +such as `constructor`, break the package and are declined. + +### Status inputs + +`status` reproduces `StatusMain.status` over the workspace engine. The engine reads the workspace, the models and the dependency caches. The CLI computes what Node computed: + +- **Dependency policy.** The Node environment's policy, the workspace policy and the root `package.json`, merged as `statusPolicy` merges them. +- **Semver facts.** npm `semver`'s `valid`, `validRange` and `coerce`, from the `npm` module of pnpm's `node-semver` fork, a port of semver 7.7.1 tested against the package's own output. +- **Issues.** The supported issue classes, including dependencies on locally removed components, are formatted like `IssuesList.toObjectIncludeDataAsString()`, with cycles found by Tarjan's algorithm. +- **Bitmap state.** Local-only, soft-tagged, locally soft-removed, and remotely soft-removed components are included in native JSON and text status reports. A scoped component with no local model is reported as pending import and invalid, matching Node's two result sections. +- **Fresh detection.** The default dev-file patterns, as `minimatch` 3.0.5 matches them; the core aspect packages from `scopes/harmony/bit/core-aspect-ids.json`; the bundled TypeScript version, read from the Node.js CLI's install. Node's built-in module list comes from the Node.js CLI's own Node. It is asked once per binary and cached in `/native-cli`. +- **Dependency cache.** Fresh detections are written with `cacache`'s format once the command has validated them, after the invalidation check `FsCache` runs. +- **Ordering.** Result rows are sorted like `localeCompare` sorts them under the root collation. +- **Current lane.** The engine uses the lane's component heads for version comparisons and its remote refs for staged status. Components outside the lane keep their main heads. Bitmap rows unavailable on the current lane are excluded from ordinary and quick status; on main, rows with a model head appear under `unavailableOnMain` in JSON and text status. Empty, unexported and exported ordinary lanes are supported. With `--lanes`, Rust fetches current main history into a retained import, computes pending main updates, then publishes the fetched objects after status preflight. Forked lanes also fetch their parent lane and report updates from its remote head. Soft-deleted lane components, stale remote refs left by physical removal, and hidden lane update-dependents are supported. Hidden update-dependents appear separately from staged workspace components in JSON and text status. + +### Diff output + +`diff --json` follows `rust-diff.ts` and `rust-diff-metadata.ts`, and formats like `@teambit/legacy.component-diff`: + +- **File diffs.** The engine runs `git diff`. The CLI relabels the headers like `formatFileDiff`, including JavaScript's `$` replacement patterns in file names. +- **Lane baseline.** On a current lane, the bitmap's lane version is the baseline for file and metadata comparisons, including changes made after a local snap. +- **Metadata.** `diffMetadata` is ported field by field. That covers `object-diff` and `array-difference` for the printable fields, `ComponentID` equality for dependencies, and npm `semver` loose comparison through `node-semver`'s `npm` module. Extension configurations are written to temporary files and compared with `git diff --no-index --color`, as `getOneFileDiff` does. +- **Colors.** Metadata text is colored like chalk 4.1.2 when `supports-color` 7.2.0 would color stdout: flags such as `--no-color`, `FORCE_COLOR`, a terminal, `TERM`, CI variables. +- **Dependency cache bypass.** With `BIT_FEATURES=no-fs-cache`, native status, diff and workspace commands detect dependencies afresh and validate their read set before committing, without reading, deleting or writing the dependency cache. Other documented fallback boundaries still apply. +- **File order.** A component's files follow the published `globby` filesystem walk: each directory's entries use libuv's byte ordering, while independent directory batches can complete in different orders. Platform-specific path separators and loader ordering are qualified against the published CLI on Windows too. + +The Node.js CLI reports rows in a varying order, since components are processed concurrently. The native order is the bitmap's. + +### Import + +`bit import ` follows `ImportComponents.importSpecificComponents`: + +1. **Selection.** Each wildcard pattern is listed on its remote with the GraphQL query `Http.list()` sends, matched by the remote. Deprecated components are left out unless `--include-deprecated` is given. The ids are sorted like `sortListScopeResults` sorts them, `localeCompare` on the full id. +2. **Transfer.** The engine fetches the selected ids with their dependencies. A dependency of another scope is taken from the dependent's remote, which serves it from its cache, and merged as not from its origin, as `mergeOne` does. +3. **Write.** Component directories are fixed like `fixDirsIfEqual` and `fixDirsIfNested` fix them, so `webhooks` moves to `webhooks_1` when `webhooks/exceptions/...` is imported too. The bitmap is sorted by key. +4. **Install.** Unless `-x` is given, the install runs with the options `installPackagesGracefully` passes: dedupe, no import, config files unless `--skip-write-config-files`, and the stored dependency graphs when the `deps-graph` feature is on. The compile runs inside the install, so the second compile `compileGracefully` starts is not repeated. An installation error is reported in the import report, as `installationErrorOutput` formats it. + +Outside Linux the engine writes `.bit/index.json` in place, as `fs.writeJson` does, so the file keeps its owner, mode, ACL and extended attributes. On Linux it replaces the file and copies them. + +HTTP requests carry `User-Agent: node-fetch`, the default of `@pnpm/node-fetch`. bit.cloud answers requests without a `User-Agent` with a 403 challenge page. + +### Transfer ownership + +Fetch responses stream through a bounded channel into the Rust tar reader. The engine validates +object hashes and the stream terminator before publishing received objects. Authentication, +proxy and TLS settings use the effective global, scope, workspace and environment configuration; +HTTP clients support client certificates and keys. Transport cancellation belongs to the calling +session and closes pending HTTP requests without reverting an acknowledged remote write. + +Export retains one operation through remote acknowledgement, local model/history updates, +package linking and final bitmap reconciliation. Configured Harmony providers, package callbacks, +post-export hooks and class metadata run through the installed published CLI adapter. Component +Source bodies stay in Rust. During remote communication, Ctrl+C warns and permits publication +to finish, as `ExportMain` does; the previous signal handler is restored afterward. A failure after +remote communication belongs to the native command and never delegates a second export. + +Release qualification against Node 22.22.0 / Bit 2.2.94 verified 105 fresh-copy pairs across seven +transfer workloads in non-TTY, real TTY and CI modes, including generated package bytes and links. +The six workloads without configured callbacks measured 2.1–27× lower complete-command medians. +Configured callbacks measured 653 ms versus Node's 682 ms off a terminal, 653 ms versus 673 ms in +CI, and 669 ms versus 677 ms on a TTY. An earlier full run had effectively tied callback TTY medians. These are local fixture measurements; +the actual Harmony/extension bootstrap dominates the callback path. The +[complete samples and fingerprints](https://github.com/zkochan/bit/releases/download/rust-benchmark-evidence-2026-10-18/bit-rust-pr10813-legacy-benchmarks-15ef7ef4e.tar.gz) include both slower +intermediate implementations and the final artifact. Reproduce the fixtures with +`scripts/rust/prepare-transfer-benchmark.py` and measure all seven with +`scripts/rust/bench-transfer-suite.py`; both require disposable remotes. + +### Provisioning, configuration and diagnostics + +`doctor` runs its fixed eight read-only diagnoses concurrently, retains registration order in +results, and gathers runtime metadata after the checks. Named diagnoses retain their individual +execution path. Workspace command history uses the resolved scope, including invocations from +component subdirectories. + +Release qualification checked 120 fresh-copy pairs across eight init/config/doctor workloads: +five alternating pairs each in non-TTY, real TTY and CI modes. Every pair compares output, +status, files, directories, modes, global stores, HOME state and native ownership. These Linux +fixtures use Node 22.22.0 / Bit 2.2.94, disabled analytics and `log_level=fatal`. + +| Complete command | Node median, non-TTY | Rust release median, non-TTY | +| --- | ---: | ---: | +| `init --standalone` | 208 ms | 1.8 ms | +| `config get benchmark.value` | 340 ms | 3.1 ms | +| `doctor --list` | 228 ms | 1.5 ms | +| `doctor "validate git exec" --json` | 397 ms | 157 ms | +| `doctor --json` | 507 ms | 275 ms | + +The full doctor command measured 1.8× lower medians across the three modes; the published +BVM lookup and external npm/yarn metadata remain part of the command. These are local fixture +measurements, without a general or cross-platform speed guarantee. The +[samples, fingerprints and settings](https://github.com/zkochan/bit/releases/download/rust-benchmark-evidence-2026-10-18/bit-rust-pr10813-legacy-benchmarks-15ef7ef4e.tar.gz) are reproduced +by `scripts/rust/bench-provisioning.py` with explicit pinned Node/CLI and release binary paths. +An additional 45 pairs verified status, checkout and export against the same release artifact; +the [regression measurements](https://github.com/zkochan/bit/releases/download/rust-benchmark-evidence-2026-10-18/bit-rust-pr10813-legacy-benchmarks-15ef7ef4e.tar.gz) include +the exact commands, fixture fingerprints and complete-state verifier. + +### Component tracking + +Native `add` retains one tracking plan through component loading and linking. Rust +owns selectors, ignores, component identities and scopes, main-file selection, +root ownership, package/source links and bitmap/history publication. Directory +variants reuse the installer’s scope resolver. Re-adding a component preserves the +published history write even when the rendered bitmap is unchanged. + +Environment resolution, semantic documentation parsing, component-load hooks and +package transforms use the published Harmony providers. Dependency receipts are +validated before caches are published. All package transforms finish before +manifests are written; source links precede bitmap publication. A failure preserves +the published output and filesystem prefix, including earlier cache and manifest +writes. Root components skip dependency auto-detection at the same early boundary +as the published loader while retaining explicit dependency policy. + +Unsupported inputs delegate before effects. CI errors whose published diagnostic +includes a JavaScript stack also delegate before providers or publication; +successful CI adds remain native. These delegated error cases are covered for +parity and excluded from native performance claims. + +The Linux release qualification in [`add.json`](https://github.com/zkochan/bit/releases/download/rust-benchmark-evidence-2026-10-18/bit-rust-pr10813-legacy-benchmarks-15ef7ef4e.tar.gz) +compares six complete commands against Node 22.22.0 / Bit 2.2.94 with five +alternating pairs per non-TTY, TTY and CI mode (90 pairs: 80 native and 10 delegated +CI errors). Output, exit status, workspace/repository files, modes, links, +checksum-validated caches, bitmap histories and the actual file remote matched. +Non-TTY medians in milliseconds were: + +| Workload | Node | Rust | +| --- | ---: | ---: | +| Add one component | 441.148 | 269.232 | +| Add 100 components / 1,100 files | 1565.424 | 1161.509 | +| Add workspace root | 353.013 | 280.176 | +| Re-add with a new main file | 442.237 | 274.692 | +| Fully ignored input (native error) | 225.821 | 2.049 | +| Blocked package link (native error) | 414.086 | 262.987 | + +Every native median was faster across the three modes; delegated CI error rows +are excluded from speed claims. These timing records identify measured commit +`e3ae55f49`. The subsequent outside-workspace diagnostics, filesystem-aware main-file casing +and platform-specific test changes passed another 135 parity pairs; their +[`add-final-contended.json`](https://github.com/zkochan/bit/releases/download/rust-benchmark-evidence-2026-10-18/bit-rust-pr10813-legacy-benchmarks-15ef7ef4e.tar.gz) and +[`add-regressions-final-contended.json`](https://github.com/zkochan/bit/releases/download/rust-benchmark-evidence-2026-10-18/bit-rust-pr10813-legacy-benchmarks-15ef7ef4e.tar.gz) +records are excluded from timing claims because peak host load exceeded 80%. These are controlled file-remote, warm builtin +metadata fixtures, not first-use, network-remote or cross-platform performance +claims. Records include source, binary, helper and fixture fingerprints and exact +normalizations. A further 45 pairs in +[`add-regressions.json`](https://github.com/zkochan/bit/releases/download/rust-benchmark-evidence-2026-10-18/bit-rust-pr10813-legacy-benchmarks-15ef7ef4e.tar.gz) qualify +status, checkout and export using the same release binary. Peak aggregate CPU use +was 16.5%, sampled every 0.5 seconds after a quiet-minute start gate. A representative +contended trial is retained with `performance_valid: false` and excluded from timing +evidence. Reproduce with `scripts/rust/bench-add.py`; fixture preparation and remote +restoration happen outside each measured command. + +### Stash publication and restoration + +Stash first populates missing existing version histories while checking component status, +including when no components need stashing. It saves detached Versions and file Sources, +then the stash manifest, before resetting +modified components and removing included new components. Component models, heads and bitmap +versions do not advance. Metadata uses the published four-space JSON and lexical stash filename +selection, including its double-digit numbering behavior. Graph references retain the +published pre-allocation component IDs and omitted graph Source behavior. + +Load restores new components at their saved roots before merging tracked components. The +three-way base is the detached Version's first parent, including after the current component +has advanced. Tracked bitmap versions remain unchanged. Registered add/compile callbacks run +after native materialization. The stash is consumed only after checkout succeeds; intermediate +failures retain the published persistence prefix. + +The Linux release qualification in +[`scripts/rust/benchmarks/stash.json`](https://github.com/zkochan/bit/releases/download/rust-benchmark-evidence-2026-10-18/bit-rust-pr10813-legacy-benchmarks-15ef7ef4e.tar.gz) +compares six complete workloads against Node 22.22.0 / Bit 2.2.94, with five +alternating pairs in each of non-TTY, TTY and CI modes (90 pairs). All compared +output, workspace, repository, package and retained global state matched. On these +controlled warm-metadata fixtures, non-TTY medians in milliseconds were: + +| Workload | Node | Rust | +| --- | ---: | ---: | +| Save modified | 422.519 | 22.623 | +| Save including new | 443.106 | 23.830 | +| List | 216.531 | 1.544 | +| Load tracked | 422.973 | 7.670 | +| Load with manual conflict | 427.957 | 8.541 | +| Restore new components | 517.330 | 477.524 | + +New-component restoration still invokes the published add/compile callbacks; its +measured gain was only 1.01–1.08× across modes, which is near parity. These results do not establish +first-use, cross-platform or large-workspace performance. The record includes +source, binary, helper and fixture fingerprints, exact normalizations and +validated builtin-metadata prewarming shared by both implementations. A further +45 pairs in [`stash-regressions.json`](https://github.com/zkochan/bit/releases/download/rust-benchmark-evidence-2026-10-18/bit-rust-pr10813-legacy-benchmarks-15ef7ef4e.tar.gz) +qualify status, checkout and export using the same release binary. Host load was sampled +every 0.5 seconds; peak aggregate CPU use was 17.3%. A separate contended trial +is retained with `performance_valid: false` and is excluded from speedup evidence. +Linux CI also repeats three lane-import regressions with a 1 MiB main-stack limit. + +### Write commands + +Write commands keep two phases. Until the commit starts, any failure declines, and the Node.js CLI runs the command from scratch. That covers an engine decline, a report that cannot be formatted and a progress receipt that does not validate. After the commit starts, failures belong to the command. They are printed in red on stderr, the command history records them, and the exit code is 1, as the Node.js CLI's error handler does. + +`checkout`, `switch` and `merge` print Bit's loader lines on stdout like `ora` 5.4.1 off a terminal (`- `, `✔ `), with timings in `pretty-time`'s format. The reports are ported from `checkoutOutput`, `mergeReport` and the `merge-output` helpers. Details hidden behind `bit details` are written to `/last-command-details` as `CommandRunner` writes them. + +`revert` uses the checkout planner's `revert` mode, so modified files are replaced without a three-way merge and bitmap versions are preserved. `scripts/rust/revert-parity.py` compares six complete Node/native commands on fresh copies, including modified files, a no-op, `main`, all components, and installation. The fixture fingerprint was `0d500c328bb45652db0114acda4a5c9d1677143c022da7abc2451e9397702542`; all six matched exit status, component files, bitmap, and stable report text. Five alternating fresh-copy `revert alpha 1.0.0 -x` runs measured a 392.028 ms Node median and 30.098 ms Rust debug median on this fixture. These are local complete-command results, not a release-build or cross-platform speedup claim. + +`switch` resolves local and supported remote lane heads before planning and reuses checkout's file materialization. Its first native publication writes the checked-out components under the existing lane header. Without `-x`, the same retained Host supplies native installation and the published compiler phases. A retained native continuation then publishes alias tracking, the destination lane header and component visibility. Graceful installation or compilation errors still reach this finalization; hard failures stop at their actual prefix. `--force-ours` and `--force-theirs` use the checkout conflict strategy. Tracked hidden components are selected again when the destination carries them. Actual pre/post-install subscribers receive the retained released installer, `ComponentMap`, `WorkspacePolicy` and argument objects. Rust consumes linked-dependency and supported package-manager mutations while retaining the released distinction between mutating an original options object and reassigning an argument-bundle property. Configured mutable installation subscribers remain on the actual published InstallMain in the retained operation session, preserving receiver replacement, function-valued options, actual options-object mutation versus argument reassignment, and InstallMain pre/post slots. On Linux, the stock pnpm package step can suspend that same class promise while Rust executes the engine with its final manifests, credentials and supported mutable options. Unsupported package inputs and customized package-manager classes use the original published package step before native engine effects; an accepted native engine failure resumes the original class error path without replay. The published install lifecycle and outer VCS finalization then continue in order. + +When a merge changes configuration, the engine asks for the built-in Node environment's defaults, which are constants of the environment (`commandDefaultNodeReceipt`), and for a dependency detection of the merged files. + +When a merge, `bit snap` or `bit tag` writes versions, the CLI supplies what the engine cannot know (`commandSnapshotReceipt`, `resolveSnapshotReceipt`): + +- **Snapshot identity.** New snap hashes (`newSnapHash`: the SHA-1 of a random UUID v4) and a batch id. +- **Log.** Identity is cached for the process as published `getIdentity()` does; every log gets a fresh date. The username comes from Bit Cloud's `/user` endpoint when a `user.token` is configured, else from `user.name`. The email comes from `user.email`. Both follow `getConfig`: environment, workspace, scope and global config, then `git config --list`. +- **Bit version.** The version of the `@teambit/bit` package the Node.js CLI runs from. +- **Dependency graph.** Each snapped component's `DependenciesGraph` (`calcDependenciesGraph`), cut from `pnpm-lock.yaml`. The lockfile is read once. For Bit 2.2.147 and newer, Rust copies only reachable packages and snapshots, and falls back to the component importer when the environment root or its component snapshot is absent. Older releases retain pnpm’s importer-root, depth-first filtering order; object hashes depend on this order. Workspace components are named by their versions (`createFileVersionReplacer`): the snapped ones by their new hashes as `0.0.0-`, the others by their workspace versions. The engine stores the graph as a Source and sets `dependenciesGraphRef`. + +A merge uses one authoritative capture and native merge plan. It materializes files +and policy changes, runs installation unless `-x` was requested, and then resolves +pending snapshots through the retained provider session. The snapshot phase reads +the newly materialized workspace without replaying providers; native Version and +graph publication preserves squash/unrelated ancestry and dependent snap cascades. +Reports use the hashes actually committed. Snapshot errors are included in the +merge report while preserving the canonical materialization/build prefix. + +Removal follows the published action: a fast-forward removed target can remove +its workspace entry immediately, while a diverged or forced no-snap merge retains +pending removal until snapshot resolution. Recovery restores lane controls before +hydration callbacks and keeps the same restored prefix when a callback fails. + +When an engine request fails, the engine now returns the reason, and `BIT_NATIVE_TRACE=1` prints it with the decline. + +### Component patterns + +`snap` and `tag` match component id patterns as `matchComponentPatterns` does, with `multimatch` 5 over `minimatch` 3.1.5: comma-separated patterns, `*`, `?`, `**`, and `!` negation (a list of negations starts from `**`). An id is kept when the last pattern that matches it is not negated, and ids keep the workspace order. A test checks the port against results `multimatch` produced. + +## Native release archives + +The Rust workflow's manual `build-distribution` input builds fat-LTO binaries for +Linux x64/ARM64, macOS Intel/ARM64, Windows x64, and Linux x64 musl. The five bvm +platforms also produce archives combining the native entry with the released +Node CLI pinned in `native/cli/tests/e2e/bit-version`. Artifact names include the +source commit. The musl artifact contains only the executable; it requires a +separately installed Node CLI and a Node runtime compatible with its host. + +`python3 scripts/rust/package-bvm.py --help` describes local archive assembly. +The packager preserves the Node package, writes the native entry with executable +permissions, and records both input hashes in `native-release.json`. Use a +distinct prerelease version so installation cannot replace a stable release. + +Native entry selection ships in [bvm 3.1.7](https://github.com/teambit/bvm/releases/tag/v3.1.7). +With bvm 3.1.7 or newer, install an artifact using +`bvm install --method tar --file /path/to/bit---.tar.gz` and select it +with `bvm use `. `BIT_NATIVE=off` retains the packaged Node fallback. +The workflow uploads artifacts; it does not change bvm's release index or the +default version installed by users. + +## Versions + +A native command reproduces the behavior of the Node.js CLI built from the same +commit. Delegated commands run whatever Node.js CLI discovery finds. Both are +shipped together, so they agree. A binary paired with an older Node.js CLI can +disagree on details the two versions changed in between. + +The JavaScript host uses only modules the published `@teambit/bit` package +ships: + +- the default node env's dependency policy and `package.json` props are + `NodeEnv.getDependencies()` and `TypescriptMain.getCjsPackageJsonProps()`, + called unbound: both return constants, so a workspace on the default env + installs without loading Harmony; +- custom dependency detectors (the env's `getDepDetectors()` and the + `registerDetector` hooks of `@teambit/dependency-resolver`'s `DetectorHook`) + run in the host for the files the native scanner leaves to them. + +`hostInfo` checks for these modules first. A Node.js CLI without them makes +native `install` decline before it writes anything, and the Node.js CLI runs +the install. + +The repeat-install skip needs the dist directories and config files +`afterInstall` wrote. The host computes them itself, the way the compiler's +`ComponentCompiler.distDirs()` and the install aspect's `tryWriteConfigFiles` +do, so it works with any Node.js CLI that ships the modules above. + +## Relation to the in-process integration + +Earlier work ran Rust inside the Node.js CLI, through a `bit-workspace.node` +addon and spawned helpers, behind `BIT_RUST_*` and `BIT_NATIVE_INSTALL` flags. +That integration is removed: the crates are libraries of this CLI, and nothing +outside `native/`, the `Rust` workflow and these docs belongs to the Rust CLI. +The Node.js CLI is unchanged, so it is the complete fallback for every command. +`scripts/rust/check-scope.sh` keeps it that way: the `Rust scope` workflow fails a +change outside those paths, or one that brings back the napi addon or a helper +binary. Agents working in `native/` follow `native/AGENTS.md`. + + +CI onboarding (`ci sync --init`) owns the generated files, configuration backup, +Git inspection and report rendering in Rust. It leaves existing workflow files alone, +preserves AGENTS.md content and hand edits, and fills an unresolved repository only +inside the generated sync section. Nested workspaces put workflows at the Git root. +The command prints the manual Git/GitHub steps without executing them. Read-only Git +commands remain external tools; supported onboarding runs with both the Node binary +and the published Bit entry unavailable. Configured startup callbacks, active lanes, +bitmap auto-sync and unsupported CI configuration still decline before any write. +The remaining CI reconciliation, verification and build/runtime work is unchanged. diff --git a/docs/rust/native-boundaries.md b/docs/rust/native-boundaries.md new file mode 100644 index 000000000000..769193198794 --- /dev/null +++ b/docs/rust/native-boundaries.md @@ -0,0 +1,549 @@ +# Native runtime boundary census + +Generated by `node scripts/rust/native-boundaries.mjs`; verify with `--check`. + +This is a **review queue, not an implementation percentage**. Lexical matches include +ordinary optional lookups, Rust trait calls and legitimate Git/tool processes. They +also include invalid-input guards. Conversely, indirect helpers and dynamic JavaScript +calls require manual review. Tests are excluded by directory/file convention; inline +test modules may appear. A missing match never proves a command is independent of Node. + +Review this alongside [command coverage](../rust-cli.md), the public command +registrations, and [the completion plan](https://github.com/zkochan/bit/issues/395). +For each supported command/mode, prove both that the dispatcher does not delegate +and that no published-Bit runtime adapter executes. Track third-party tool processes +separately. Yarn support is excluded from the completion plan. + +| Source | Decline / optional outcome | Published runtime discovery | Host calls / construction | Process launch candidates | JavaScript runtime calls | +| --- | --- | --- | --- | --- | --- | +| [native/cli/src/commands/add/diagnostics.rs](../../native/cli/src/commands/add/diagnostics.rs) | [89](../../native/cli/src/commands/add/diagnostics.rs#L89) | — | — | — | — | +| [native/cli/src/commands/add/docs/cache_hits.rs](../../native/cli/src/commands/add/docs/cache_hits.rs) | [116](../../native/cli/src/commands/add/docs/cache_hits.rs#L116), [124](../../native/cli/src/commands/add/docs/cache_hits.rs#L124), [128](../../native/cli/src/commands/add/docs/cache_hits.rs#L128), [135](../../native/cli/src/commands/add/docs/cache_hits.rs#L135) | — | — | — | — | +| [native/cli/src/commands/add/docs/cat.rs](../../native/cli/src/commands/add/docs/cat.rs) | [108](../../native/cli/src/commands/add/docs/cat.rs#L108) | — | — | — | — | +| [native/cli/src/commands/add/execution.rs](../../native/cli/src/commands/add/execution.rs) | — | — | [140](../../native/cli/src/commands/add/execution.rs#L140) | — | — | +| [native/cli/src/commands/add/mutation/installation.rs](../../native/cli/src/commands/add/mutation/installation.rs) | — | — | [31](../../native/cli/src/commands/add/mutation/installation.rs#L31) | — | — | +| [native/cli/src/commands/add/mutation/load.rs](../../native/cli/src/commands/add/mutation/load.rs) | — | — | [303](../../native/cli/src/commands/add/mutation/load.rs#L303) | — | — | +| [native/cli/src/commands/add/mutation/selection.rs](../../native/cli/src/commands/add/mutation/selection.rs) | — | — | [83](../../native/cli/src/commands/add/mutation/selection.rs#L83) | — | — | +| [native/cli/src/commands/add/mutation/selectors/code.rs](../../native/cli/src/commands/add/mutation/selectors/code.rs) | [55](../../native/cli/src/commands/add/mutation/selectors/code.rs#L55) | — | — | — | — | +| [native/cli/src/commands/add/mutation.rs](../../native/cli/src/commands/add/mutation.rs) | — | — | [43](../../native/cli/src/commands/add/mutation.rs#L43) | — | — | +| [native/cli/src/commands/add/preparation.rs](../../native/cli/src/commands/add/preparation.rs) | [133](../../native/cli/src/commands/add/preparation.rs#L133), [145](../../native/cli/src/commands/add/preparation.rs#L145) | — | — | — | — | +| [native/cli/src/commands/add/remote.rs](../../native/cli/src/commands/add/remote.rs) | — | — | [22](../../native/cli/src/commands/add/remote.rs#L22) | — | — | +| [native/cli/src/commands/add.rs](../../native/cli/src/commands/add.rs) | [44](../../native/cli/src/commands/add.rs#L44), [47](../../native/cli/src/commands/add.rs#L47), [52](../../native/cli/src/commands/add.rs#L52), [71](../../native/cli/src/commands/add.rs#L71), [97](../../native/cli/src/commands/add.rs#L97), [104](../../native/cli/src/commands/add.rs#L104) | — | — | — | — | +| [native/cli/src/commands/app_cmd.rs](../../native/cli/src/commands/app_cmd.rs) | [51](../../native/cli/src/commands/app_cmd.rs#L51), [72](../../native/cli/src/commands/app_cmd.rs#L72), [81](../../native/cli/src/commands/app_cmd.rs#L81), [88](../../native/cli/src/commands/app_cmd.rs#L88), [163](../../native/cli/src/commands/app_cmd.rs#L163) | — | — | — | — | +| [native/cli/src/commands/artifacts_cmd/extractor.rs](../../native/cli/src/commands/artifacts_cmd/extractor.rs) | [272](../../native/cli/src/commands/artifacts_cmd/extractor.rs#L272), [275](../../native/cli/src/commands/artifacts_cmd/extractor.rs#L275) | — | — | — | — | +| [native/cli/src/commands/artifacts_cmd.rs](../../native/cli/src/commands/artifacts_cmd.rs) | [39](../../native/cli/src/commands/artifacts_cmd.rs#L39), [42](../../native/cli/src/commands/artifacts_cmd.rs#L42), [45](../../native/cli/src/commands/artifacts_cmd.rs#L45), [93](../../native/cli/src/commands/artifacts_cmd.rs#L93), [114](../../native/cli/src/commands/artifacts_cmd.rs#L114) | — | — | — | — | +| [native/cli/src/commands/aspect.rs](../../native/cli/src/commands/aspect.rs) | [72](../../native/cli/src/commands/aspect.rs#L72), [75](../../native/cli/src/commands/aspect.rs#L75), [116](../../native/cli/src/commands/aspect.rs#L116), [125](../../native/cli/src/commands/aspect.rs#L125), [149](../../native/cli/src/commands/aspect.rs#L149), [155](../../native/cli/src/commands/aspect.rs#L155) | — | — | — | — | +| [native/cli/src/commands/build_cmd/runner.rs](../../native/cli/src/commands/build_cmd/runner.rs) | [70](../../native/cli/src/commands/build_cmd/runner.rs#L70), [74](../../native/cli/src/commands/build_cmd/runner.rs#L74), [89](../../native/cli/src/commands/build_cmd/runner.rs#L89), [96](../../native/cli/src/commands/build_cmd/runner.rs#L96) | — | — | — | — | +| [native/cli/src/commands/build_cmd.rs](../../native/cli/src/commands/build_cmd.rs) | [61](../../native/cli/src/commands/build_cmd.rs#L61), [64](../../native/cli/src/commands/build_cmd.rs#L64), [67](../../native/cli/src/commands/build_cmd.rs#L67), [112](../../native/cli/src/commands/build_cmd.rs#L112) | — | — | — | — | +| [native/cli/src/commands/capsule/create/files.rs](../../native/cli/src/commands/capsule/create/files.rs) | [122](../../native/cli/src/commands/capsule/create/files.rs#L122), [127](../../native/cli/src/commands/capsule/create/files.rs#L127), [138](../../native/cli/src/commands/capsule/create/files.rs#L138), [147](../../native/cli/src/commands/capsule/create/files.rs#L147), [155](../../native/cli/src/commands/capsule/create/files.rs#L155), [158](../../native/cli/src/commands/capsule/create/files.rs#L158) | — | — | — | — | +| [native/cli/src/commands/capsule/create.rs](../../native/cli/src/commands/capsule/create.rs) | [77](../../native/cli/src/commands/capsule/create.rs#L77) | — | — | — | — | +| [native/cli/src/commands/capsule/outside.rs](../../native/cli/src/commands/capsule/outside.rs) | [44](../../native/cli/src/commands/capsule/outside.rs#L44), [65](../../native/cli/src/commands/capsule/outside.rs#L65), [68](../../native/cli/src/commands/capsule/outside.rs#L68), [75](../../native/cli/src/commands/capsule/outside.rs#L75), [167](../../native/cli/src/commands/capsule/outside.rs#L167), [178](../../native/cli/src/commands/capsule/outside.rs#L178), [200](../../native/cli/src/commands/capsule/outside.rs#L200) | — | — | — | — | +| [native/cli/src/commands/capsule/prune.rs](../../native/cli/src/commands/capsule/prune.rs) | [104](../../native/cli/src/commands/capsule/prune.rs#L104), [129](../../native/cli/src/commands/capsule/prune.rs#L129) | — | — | — | — | +| [native/cli/src/commands/capsule.rs](../../native/cli/src/commands/capsule.rs) | [71](../../native/cli/src/commands/capsule.rs#L71), [74](../../native/cli/src/commands/capsule.rs#L74), [80](../../native/cli/src/commands/capsule.rs#L80), [81](../../native/cli/src/commands/capsule.rs#L81), [91](../../native/cli/src/commands/capsule.rs#L91), [111](../../native/cli/src/commands/capsule.rs#L111), [133](../../native/cli/src/commands/capsule.rs#L133) | — | — | — | — | +| [native/cli/src/commands/cat/report.rs](../../native/cli/src/commands/cat/report.rs) | [15](../../native/cli/src/commands/cat/report.rs#L15), [67](../../native/cli/src/commands/cat/report.rs#L67), [82](../../native/cli/src/commands/cat/report.rs#L82), [89](../../native/cli/src/commands/cat/report.rs#L89), [90](../../native/cli/src/commands/cat/report.rs#L90), [131](../../native/cli/src/commands/cat/report.rs#L131), [140](../../native/cli/src/commands/cat/report.rs#L140), [146](../../native/cli/src/commands/cat/report.rs#L146), [155](../../native/cli/src/commands/cat/report.rs#L155), [160](../../native/cli/src/commands/cat/report.rs#L160) | — | — | — | — | +| [native/cli/src/commands/cat_scope.rs](../../native/cli/src/commands/cat_scope.rs) | [114](../../native/cli/src/commands/cat_scope.rs#L114), [117](../../native/cli/src/commands/cat_scope.rs#L117), [133](../../native/cli/src/commands/cat_scope.rs#L133), [136](../../native/cli/src/commands/cat_scope.rs#L136), [174](../../native/cli/src/commands/cat_scope.rs#L174) | — | — | — | — | +| [native/cli/src/commands/cat.rs](../../native/cli/src/commands/cat.rs) | [43](../../native/cli/src/commands/cat.rs#L43), [46](../../native/cli/src/commands/cat.rs#L46), [57](../../native/cli/src/commands/cat.rs#L57), [79](../../native/cli/src/commands/cat.rs#L79), [103](../../native/cli/src/commands/cat.rs#L103), [121](../../native/cli/src/commands/cat.rs#L121), [184](../../native/cli/src/commands/cat.rs#L184), [189](../../native/cli/src/commands/cat.rs#L189), [199](../../native/cli/src/commands/cat.rs#L199), [251](../../native/cli/src/commands/cat.rs#L251), [259](../../native/cli/src/commands/cat.rs#L259), [302](../../native/cli/src/commands/cat.rs#L302), [304](../../native/cli/src/commands/cat.rs#L304), [321](../../native/cli/src/commands/cat.rs#L321), [332](../../native/cli/src/commands/cat.rs#L332), [339](../../native/cli/src/commands/cat.rs#L339), [367](../../native/cli/src/commands/cat.rs#L367), [378](../../native/cli/src/commands/cat.rs#L378), [380](../../native/cli/src/commands/cat.rs#L380) | [18](../../native/cli/src/commands/cat.rs#L18), [250](../../native/cli/src/commands/cat.rs#L250), [331](../../native/cli/src/commands/cat.rs#L331) | [253](../../native/cli/src/commands/cat.rs#L253), [255](../../native/cli/src/commands/cat.rs#L255), [262](../../native/cli/src/commands/cat.rs#L262), [334](../../native/cli/src/commands/cat.rs#L334), [336](../../native/cli/src/commands/cat.rs#L336), [342](../../native/cli/src/commands/cat.rs#L342) | — | — | +| [native/cli/src/commands/check_types/checker.rs](../../native/cli/src/commands/check_types/checker.rs) | [38](../../native/cli/src/commands/check_types/checker.rs#L38), [41](../../native/cli/src/commands/check_types/checker.rs#L41), [49](../../native/cli/src/commands/check_types/checker.rs#L49) | — | — | — | — | +| [native/cli/src/commands/check_types.rs](../../native/cli/src/commands/check_types.rs) | [37](../../native/cli/src/commands/check_types.rs#L37), [40](../../native/cli/src/commands/check_types.rs#L40), [49](../../native/cli/src/commands/check_types.rs#L49), [61](../../native/cli/src/commands/check_types.rs#L61) | — | — | — | — | +| [native/cli/src/commands/checkout/report/policy.rs](../../native/cli/src/commands/checkout/report/policy.rs) | [38](../../native/cli/src/commands/checkout/report/policy.rs#L38), [55](../../native/cli/src/commands/checkout/report/policy.rs#L55), [87](../../native/cli/src/commands/checkout/report/policy.rs#L87), [92](../../native/cli/src/commands/checkout/report/policy.rs#L92) | — | — | — | — | +| [native/cli/src/commands/checkout/report.rs](../../native/cli/src/commands/checkout/report.rs) | [34](../../native/cli/src/commands/checkout/report.rs#L34) | — | — | — | — | +| [native/cli/src/commands/checkout.rs](../../native/cli/src/commands/checkout.rs) | [30](../../native/cli/src/commands/checkout.rs#L30), [50](../../native/cli/src/commands/checkout.rs#L50), [53](../../native/cli/src/commands/checkout.rs#L53), [58](../../native/cli/src/commands/checkout.rs#L58), [65](../../native/cli/src/commands/checkout.rs#L65), [68](../../native/cli/src/commands/checkout.rs#L68), [86](../../native/cli/src/commands/checkout.rs#L86), [91](../../native/cli/src/commands/checkout.rs#L91), [102](../../native/cli/src/commands/checkout.rs#L102), [109](../../native/cli/src/commands/checkout.rs#L109) | — | [224](../../native/cli/src/commands/checkout.rs#L224) | — | — | +| [native/cli/src/commands/ci/init/paths.rs](../../native/cli/src/commands/ci/init/paths.rs) | [55](../../native/cli/src/commands/ci/init/paths.rs#L55) | — | — | — | — | +| [native/cli/src/commands/ci/init.rs](../../native/cli/src/commands/ci/init.rs) | [33](../../native/cli/src/commands/ci/init.rs#L33), [34](../../native/cli/src/commands/ci/init.rs#L34), [38](../../native/cli/src/commands/ci/init.rs#L38), [57](../../native/cli/src/commands/ci/init.rs#L57) | — | — | — | — | +| [native/cli/src/commands/ci.rs](../../native/cli/src/commands/ci.rs) | [82](../../native/cli/src/commands/ci.rs#L82), [85](../../native/cli/src/commands/ci.rs#L85), [106](../../native/cli/src/commands/ci.rs#L106), [119](../../native/cli/src/commands/ci.rs#L119), [148](../../native/cli/src/commands/ci.rs#L148), [167](../../native/cli/src/commands/ci.rs#L167) | — | — | [302](../../native/cli/src/commands/ci.rs#L302), [319](../../native/cli/src/commands/ci.rs#L319) | — | +| [native/cli/src/commands/clear_cache.rs](../../native/cli/src/commands/clear_cache.rs) | [31](../../native/cli/src/commands/clear_cache.rs#L31), [54](../../native/cli/src/commands/clear_cache.rs#L54) | — | — | — | — | +| [native/cli/src/commands/cli_cmd.rs](../../native/cli/src/commands/cli_cmd.rs) | [26](../../native/cli/src/commands/cli_cmd.rs#L26), [36](../../native/cli/src/commands/cli_cmd.rs#L36), [41](../../native/cli/src/commands/cli_cmd.rs#L41), [44](../../native/cli/src/commands/cli_cmd.rs#L44), [48](../../native/cli/src/commands/cli_cmd.rs#L48) | [10](../../native/cli/src/commands/cli_cmd.rs#L10), [43](../../native/cli/src/commands/cli_cmd.rs#L43) | — | — | — | +| [native/cli/src/commands/clone.rs](../../native/cli/src/commands/clone.rs) | [32](../../native/cli/src/commands/clone.rs#L32), [35](../../native/cli/src/commands/clone.rs#L35), [44](../../native/cli/src/commands/clone.rs#L44), [66](../../native/cli/src/commands/clone.rs#L66), [169](../../native/cli/src/commands/clone.rs#L169), [268](../../native/cli/src/commands/clone.rs#L268), [289](../../native/cli/src/commands/clone.rs#L289) | — | — | — | — | +| [native/cli/src/commands/compile_cmd/runner/tool.rs](../../native/cli/src/commands/compile_cmd/runner/tool.rs) | — | [4](../../native/cli/src/commands/compile_cmd/runner/tool.rs#L4), [44](../../native/cli/src/commands/compile_cmd/runner/tool.rs#L44) | — | [21](../../native/cli/src/commands/compile_cmd/runner/tool.rs#L21) | — | +| [native/cli/src/commands/compile_cmd/runner.rs](../../native/cli/src/commands/compile_cmd/runner.rs) | [49](../../native/cli/src/commands/compile_cmd/runner.rs#L49), [53](../../native/cli/src/commands/compile_cmd/runner.rs#L53) | — | — | — | — | +| [native/cli/src/commands/compile_cmd/syntax.rs](../../native/cli/src/commands/compile_cmd/syntax.rs) | — | [4](../../native/cli/src/commands/compile_cmd/syntax.rs#L4), [78](../../native/cli/src/commands/compile_cmd/syntax.rs#L78) | — | [105](../../native/cli/src/commands/compile_cmd/syntax.rs#L105) | — | +| [native/cli/src/commands/compile_cmd.rs](../../native/cli/src/commands/compile_cmd.rs) | [44](../../native/cli/src/commands/compile_cmd.rs#L44), [47](../../native/cli/src/commands/compile_cmd.rs#L47), [52](../../native/cli/src/commands/compile_cmd.rs#L52), [67](../../native/cli/src/commands/compile_cmd.rs#L67) | — | — | — | — | +| [native/cli/src/commands/completion_cmd.rs](../../native/cli/src/commands/completion_cmd.rs) | [74](../../native/cli/src/commands/completion_cmd.rs#L74), [77](../../native/cli/src/commands/completion_cmd.rs#L77) | — | — | — | — | +| [native/cli/src/commands/component_issues.rs](../../native/cli/src/commands/component_issues.rs) | [189](../../native/cli/src/commands/component_issues.rs#L189), [192](../../native/cli/src/commands/component_issues.rs#L192) | — | — | — | — | +| [native/cli/src/commands/config/report.rs](../../native/cli/src/commands/config/report.rs) | — | — | — | [49](../../native/cli/src/commands/config/report.rs#L49) | — | +| [native/cli/src/commands/config.rs](../../native/cli/src/commands/config.rs) | [25](../../native/cli/src/commands/config.rs#L25), [28](../../native/cli/src/commands/config.rs#L28), [32](../../native/cli/src/commands/config.rs#L32), [38](../../native/cli/src/commands/config.rs#L38) | — | — | — | — | +| [native/cli/src/commands/create.rs](../../native/cli/src/commands/create.rs) | [33](../../native/cli/src/commands/create.rs#L33), [36](../../native/cli/src/commands/create.rs#L36), [45](../../native/cli/src/commands/create.rs#L45), [61](../../native/cli/src/commands/create.rs#L61) | — | — | — | — | +| [native/cli/src/commands/debug_cat.rs](../../native/cli/src/commands/debug_cat.rs) | [119](../../native/cli/src/commands/debug_cat.rs#L119), [125](../../native/cli/src/commands/debug_cat.rs#L125), [134](../../native/cli/src/commands/debug_cat.rs#L134), [139](../../native/cli/src/commands/debug_cat.rs#L139), [183](../../native/cli/src/commands/debug_cat.rs#L183), [186](../../native/cli/src/commands/debug_cat.rs#L186), [198](../../native/cli/src/commands/debug_cat.rs#L198), [201](../../native/cli/src/commands/debug_cat.rs#L201), [204](../../native/cli/src/commands/debug_cat.rs#L204), [208](../../native/cli/src/commands/debug_cat.rs#L208), [215](../../native/cli/src/commands/debug_cat.rs#L215), [218](../../native/cli/src/commands/debug_cat.rs#L218), [230](../../native/cli/src/commands/debug_cat.rs#L230), [235](../../native/cli/src/commands/debug_cat.rs#L235), [236](../../native/cli/src/commands/debug_cat.rs#L236), [239](../../native/cli/src/commands/debug_cat.rs#L239), [246](../../native/cli/src/commands/debug_cat.rs#L246), [249](../../native/cli/src/commands/debug_cat.rs#L249), [265](../../native/cli/src/commands/debug_cat.rs#L265), [270](../../native/cli/src/commands/debug_cat.rs#L270), [271](../../native/cli/src/commands/debug_cat.rs#L271), [274](../../native/cli/src/commands/debug_cat.rs#L274), [282](../../native/cli/src/commands/debug_cat.rs#L282), [285](../../native/cli/src/commands/debug_cat.rs#L285), [298](../../native/cli/src/commands/debug_cat.rs#L298), [318](../../native/cli/src/commands/debug_cat.rs#L318) | — | — | — | — | +| [native/cli/src/commands/debug_load.rs](../../native/cli/src/commands/debug_load.rs) | [28](../../native/cli/src/commands/debug_load.rs#L28), [31](../../native/cli/src/commands/debug_load.rs#L31), [34](../../native/cli/src/commands/debug_load.rs#L34), [53](../../native/cli/src/commands/debug_load.rs#L53), [164](../../native/cli/src/commands/debug_load.rs#L164), [185](../../native/cli/src/commands/debug_load.rs#L185) | — | — | — | — | +| [native/cli/src/commands/delete/hard.rs](../../native/cli/src/commands/delete/hard.rs) | [43](../../native/cli/src/commands/delete/hard.rs#L43), [53](../../native/cli/src/commands/delete/hard.rs#L53), [58](../../native/cli/src/commands/delete/hard.rs#L58), [64](../../native/cli/src/commands/delete/hard.rs#L64), [85](../../native/cli/src/commands/delete/hard.rs#L85), [93](../../native/cli/src/commands/delete/hard.rs#L93), [99](../../native/cli/src/commands/delete/hard.rs#L99), [118](../../native/cli/src/commands/delete/hard.rs#L118), [123](../../native/cli/src/commands/delete/hard.rs#L123), [140](../../native/cli/src/commands/delete/hard.rs#L140), [144](../../native/cli/src/commands/delete/hard.rs#L144) | [340](../../native/cli/src/commands/delete/hard.rs#L340) | — | — | — | +| [native/cli/src/commands/delete/policy.rs](../../native/cli/src/commands/delete/policy.rs) | [29](../../native/cli/src/commands/delete/policy.rs#L29) | — | — | — | — | +| [native/cli/src/commands/delete.rs](../../native/cli/src/commands/delete.rs) | [56](../../native/cli/src/commands/delete.rs#L56), [59](../../native/cli/src/commands/delete.rs#L59), [65](../../native/cli/src/commands/delete.rs#L65), [74](../../native/cli/src/commands/delete.rs#L74), [103](../../native/cli/src/commands/delete.rs#L103), [137](../../native/cli/src/commands/delete.rs#L137) | — | — | — | — | +| [native/cli/src/commands/dependents.rs](../../native/cli/src/commands/dependents.rs) | [38](../../native/cli/src/commands/dependents.rs#L38), [41](../../native/cli/src/commands/dependents.rs#L41), [50](../../native/cli/src/commands/dependents.rs#L50), [86](../../native/cli/src/commands/dependents.rs#L86), [144](../../native/cli/src/commands/dependents.rs#L144), [152](../../native/cli/src/commands/dependents.rs#L152) | — | — | — | — | +| [native/cli/src/commands/deprecate.rs](../../native/cli/src/commands/deprecate.rs) | [18](../../native/cli/src/commands/deprecate.rs#L18), [21](../../native/cli/src/commands/deprecate.rs#L21), [25](../../native/cli/src/commands/deprecate.rs#L25), [30](../../native/cli/src/commands/deprecate.rs#L30), [55](../../native/cli/src/commands/deprecate.rs#L55) | — | — | — | — | +| [native/cli/src/commands/deps/blame.rs](../../native/cli/src/commands/deps/blame.rs) | [46](../../native/cli/src/commands/deps/blame.rs#L46), [53](../../native/cli/src/commands/deps/blame.rs#L53), [152](../../native/cli/src/commands/deps/blame.rs#L152) | — | — | — | — | +| [native/cli/src/commands/deps/debug/versioned.rs](../../native/cli/src/commands/deps/debug/versioned.rs) | [186](../../native/cli/src/commands/deps/debug/versioned.rs#L186), [191](../../native/cli/src/commands/deps/debug/versioned.rs#L191), [209](../../native/cli/src/commands/deps/debug/versioned.rs#L209), [215](../../native/cli/src/commands/deps/debug/versioned.rs#L215) | — | — | — | — | +| [native/cli/src/commands/deps/diagnose/report.rs](../../native/cli/src/commands/deps/diagnose/report.rs) | [58](../../native/cli/src/commands/deps/diagnose/report.rs#L58), [65](../../native/cli/src/commands/deps/diagnose/report.rs#L65) | — | — | — | — | +| [native/cli/src/commands/deps/diagnose.rs](../../native/cli/src/commands/deps/diagnose.rs) | [48](../../native/cli/src/commands/deps/diagnose.rs#L48), [55](../../native/cli/src/commands/deps/diagnose.rs#L55) | — | — | — | — | +| [native/cli/src/commands/deps/get.rs](../../native/cli/src/commands/deps/get.rs) | [46](../../native/cli/src/commands/deps/get.rs#L46), [51](../../native/cli/src/commands/deps/get.rs#L51), [54](../../native/cli/src/commands/deps/get.rs#L54), [121](../../native/cli/src/commands/deps/get.rs#L121) | — | — | — | — | +| [native/cli/src/commands/deps/mutation.rs](../../native/cli/src/commands/deps/mutation.rs) | [166](../../native/cli/src/commands/deps/mutation.rs#L166) | — | — | — | — | +| [native/cli/src/commands/deps/remove.rs](../../native/cli/src/commands/deps/remove.rs) | [129](../../native/cli/src/commands/deps/remove.rs#L129) | — | — | — | — | +| [native/cli/src/commands/deps/usage.rs](../../native/cli/src/commands/deps/usage.rs) | [98](../../native/cli/src/commands/deps/usage.rs#L98), [109](../../native/cli/src/commands/deps/usage.rs#L109) | — | — | — | — | +| [native/cli/src/commands/deps.rs](../../native/cli/src/commands/deps.rs) | [109](../../native/cli/src/commands/deps.rs#L109), [112](../../native/cli/src/commands/deps.rs#L112), [138](../../native/cli/src/commands/deps.rs#L138), [141](../../native/cli/src/commands/deps.rs#L141), [184](../../native/cli/src/commands/deps.rs#L184), [187](../../native/cli/src/commands/deps.rs#L187), [210](../../native/cli/src/commands/deps.rs#L210), [216](../../native/cli/src/commands/deps.rs#L216), [230](../../native/cli/src/commands/deps.rs#L230) | — | — | — | — | +| [native/cli/src/commands/details.rs](../../native/cli/src/commands/details.rs) | [22](../../native/cli/src/commands/details.rs#L22) | — | — | — | — | +| [native/cli/src/commands/diff/files.rs](../../native/cli/src/commands/diff/files.rs) | — | — | — | [92](../../native/cli/src/commands/diff/files.rs#L92) | — | +| [native/cli/src/commands/diff/metadata/dependencies.rs](../../native/cli/src/commands/diff/metadata/dependencies.rs) | [29](../../native/cli/src/commands/diff/metadata/dependencies.rs#L29), [46](../../native/cli/src/commands/diff/metadata/dependencies.rs#L46), [73](../../native/cli/src/commands/diff/metadata/dependencies.rs#L73), [95](../../native/cli/src/commands/diff/metadata/dependencies.rs#L95) | — | — | — | — | +| [native/cli/src/commands/diff/metadata/side.rs](../../native/cli/src/commands/diff/metadata/side.rs) | [127](../../native/cli/src/commands/diff/metadata/side.rs#L127), [130](../../native/cli/src/commands/diff/metadata/side.rs#L130), [211](../../native/cli/src/commands/diff/metadata/side.rs#L211), [229](../../native/cli/src/commands/diff/metadata/side.rs#L229) | — | — | — | — | +| [native/cli/src/commands/diff/metadata.rs](../../native/cli/src/commands/diff/metadata.rs) | [27](../../native/cli/src/commands/diff/metadata.rs#L27), [136](../../native/cli/src/commands/diff/metadata.rs#L136) | — | — | — | — | +| [native/cli/src/commands/diff/order.rs](../../native/cli/src/commands/diff/order.rs) | [73](../../native/cli/src/commands/diff/order.rs#L73), [94](../../native/cli/src/commands/diff/order.rs#L94) | — | — | — | — | +| [native/cli/src/commands/diff/parent.rs](../../native/cli/src/commands/diff/parent.rs) | [39](../../native/cli/src/commands/diff/parent.rs#L39), [46](../../native/cli/src/commands/diff/parent.rs#L46), [89](../../native/cli/src/commands/diff/parent.rs#L89), [117](../../native/cli/src/commands/diff/parent.rs#L117) | — | — | — | — | +| [native/cli/src/commands/diff/presentation.rs](../../native/cli/src/commands/diff/presentation.rs) | [81](../../native/cli/src/commands/diff/presentation.rs#L81), [84](../../native/cli/src/commands/diff/presentation.rs#L84) | — | — | — | — | +| [native/cli/src/commands/diff/remote.rs](../../native/cli/src/commands/diff/remote.rs) | [39](../../native/cli/src/commands/diff/remote.rs#L39), [56](../../native/cli/src/commands/diff/remote.rs#L56), [64](../../native/cli/src/commands/diff/remote.rs#L64) | — | — | — | — | +| [native/cli/src/commands/diff/report/tables.rs](../../native/cli/src/commands/diff/report/tables.rs) | [62](../../native/cli/src/commands/diff/report/tables.rs#L62), [65](../../native/cli/src/commands/diff/report/tables.rs#L65), [116](../../native/cli/src/commands/diff/report/tables.rs#L116) | — | — | — | — | +| [native/cli/src/commands/diff/report.rs](../../native/cli/src/commands/diff/report.rs) | [69](../../native/cli/src/commands/diff/report.rs#L69), [75](../../native/cli/src/commands/diff/report.rs#L75), [113](../../native/cli/src/commands/diff/report.rs#L113), [114](../../native/cli/src/commands/diff/report.rs#L114), [195](../../native/cli/src/commands/diff/report.rs#L195), [199](../../native/cli/src/commands/diff/report.rs#L199), [200](../../native/cli/src/commands/diff/report.rs#L200) | — | — | — | — | +| [native/cli/src/commands/diff/result_files.rs](../../native/cli/src/commands/diff/result_files.rs) | [36](../../native/cli/src/commands/diff/result_files.rs#L36), [45](../../native/cli/src/commands/diff/result_files.rs#L45), [58](../../native/cli/src/commands/diff/result_files.rs#L58) | — | — | — | — | +| [native/cli/src/commands/diff/selection/states.rs](../../native/cli/src/commands/diff/selection/states.rs) | [89](../../native/cli/src/commands/diff/selection/states.rs#L89), [193](../../native/cli/src/commands/diff/selection/states.rs#L193) | — | — | — | — | +| [native/cli/src/commands/diff/selection.rs](../../native/cli/src/commands/diff/selection.rs) | [43](../../native/cli/src/commands/diff/selection.rs#L43), [70](../../native/cli/src/commands/diff/selection.rs#L70), [100](../../native/cli/src/commands/diff/selection.rs#L100) | — | — | — | — | +| [native/cli/src/commands/diff/validation.rs](../../native/cli/src/commands/diff/validation.rs) | — | — | — | [98](../../native/cli/src/commands/diff/validation.rs#L98) | — | +| [native/cli/src/commands/diff.rs](../../native/cli/src/commands/diff.rs) | [52](../../native/cli/src/commands/diff.rs#L52), [55](../../native/cli/src/commands/diff.rs#L55), [58](../../native/cli/src/commands/diff.rs#L58), [85](../../native/cli/src/commands/diff.rs#L85), [287](../../native/cli/src/commands/diff.rs#L287), [325](../../native/cli/src/commands/diff.rs#L325), [339](../../native/cli/src/commands/diff.rs#L339) | — | — | — | — | +| [native/cli/src/commands/doctor/archive.rs](../../native/cli/src/commands/doctor/archive.rs) | [19](../../native/cli/src/commands/doctor/archive.rs#L19) | — | — | — | — | +| [native/cli/src/commands/doctor/checks.rs](../../native/cli/src/commands/doctor/checks.rs) | — | — | — | [78](../../native/cli/src/commands/doctor/checks.rs#L78) | — | +| [native/cli/src/commands/doctor/context.rs](../../native/cli/src/commands/doctor/context.rs) | — | [3](../../native/cli/src/commands/doctor/context.rs#L3), [14](../../native/cli/src/commands/doctor/context.rs#L14), [37](../../native/cli/src/commands/doctor/context.rs#L37) | — | [49](../../native/cli/src/commands/doctor/context.rs#L49) | — | +| [native/cli/src/commands/doctor/remote.rs](../../native/cli/src/commands/doctor/remote.rs) | [32](../../native/cli/src/commands/doctor/remote.rs#L32) | — | — | — | — | +| [native/cli/src/commands/doctor/vendors.rs](../../native/cli/src/commands/doctor/vendors.rs) | — | — | — | [8](../../native/cli/src/commands/doctor/vendors.rs#L8), [37](../../native/cli/src/commands/doctor/vendors.rs#L37) | — | +| [native/cli/src/commands/doctor.rs](../../native/cli/src/commands/doctor.rs) | [26](../../native/cli/src/commands/doctor.rs#L26), [29](../../native/cli/src/commands/doctor.rs#L29), [38](../../native/cli/src/commands/doctor.rs#L38), [42](../../native/cli/src/commands/doctor.rs#L42), [46](../../native/cli/src/commands/doctor.rs#L46), [50](../../native/cli/src/commands/doctor.rs#L50), [83](../../native/cli/src/commands/doctor.rs#L83), [95](../../native/cli/src/commands/doctor.rs#L95) | — | — | — | — | +| [native/cli/src/commands/eject_conf.rs](../../native/cli/src/commands/eject_conf.rs) | [133](../../native/cli/src/commands/eject_conf.rs#L133), [136](../../native/cli/src/commands/eject_conf.rs#L136), [167](../../native/cli/src/commands/eject_conf.rs#L167), [277](../../native/cli/src/commands/eject_conf.rs#L277) | — | — | — | — | +| [native/cli/src/commands/eject.rs](../../native/cli/src/commands/eject.rs) | [48](../../native/cli/src/commands/eject.rs#L48), [51](../../native/cli/src/commands/eject.rs#L51), [54](../../native/cli/src/commands/eject.rs#L54), [71](../../native/cli/src/commands/eject.rs#L71), [75](../../native/cli/src/commands/eject.rs#L75), [161](../../native/cli/src/commands/eject.rs#L161) | — | — | — | — | +| [native/cli/src/commands/envs/inspect.rs](../../native/cli/src/commands/envs/inspect.rs) | [230](../../native/cli/src/commands/envs/inspect.rs#L230), [244](../../native/cli/src/commands/envs/inspect.rs#L244), [259](../../native/cli/src/commands/envs/inspect.rs#L259), [271](../../native/cli/src/commands/envs/inspect.rs#L271) | — | — | — | — | +| [native/cli/src/commands/envs/mutation.rs](../../native/cli/src/commands/envs/mutation.rs) | [193](../../native/cli/src/commands/envs/mutation.rs#L193), [199](../../native/cli/src/commands/envs/mutation.rs#L199), [277](../../native/cli/src/commands/envs/mutation.rs#L277), [287](../../native/cli/src/commands/envs/mutation.rs#L287) | — | — | — | — | +| [native/cli/src/commands/envs.rs](../../native/cli/src/commands/envs.rs) | [51](../../native/cli/src/commands/envs.rs#L51), [54](../../native/cli/src/commands/envs.rs#L54), [76](../../native/cli/src/commands/envs.rs#L76), [86](../../native/cli/src/commands/envs.rs#L86), [114](../../native/cli/src/commands/envs.rs#L114), [165](../../native/cli/src/commands/envs.rs#L165), [171](../../native/cli/src/commands/envs.rs#L171) | — | — | — | — | +| [native/cli/src/commands/export/lifecycle.rs](../../native/cli/src/commands/export/lifecycle.rs) | — | [8](../../native/cli/src/commands/export/lifecycle.rs#L8), [51](../../native/cli/src/commands/export/lifecycle.rs#L51) | [28](../../native/cli/src/commands/export/lifecycle.rs#L28), [33](../../native/cli/src/commands/export/lifecycle.rs#L33), [54](../../native/cli/src/commands/export/lifecycle.rs#L54), [60](../../native/cli/src/commands/export/lifecycle.rs#L60), [77](../../native/cli/src/commands/export/lifecycle.rs#L77), [98](../../native/cli/src/commands/export/lifecycle.rs#L98), [114](../../native/cli/src/commands/export/lifecycle.rs#L114), [136](../../native/cli/src/commands/export/lifecycle.rs#L136), [145](../../native/cli/src/commands/export/lifecycle.rs#L145) | — | — | +| [native/cli/src/commands/export/report.rs](../../native/cli/src/commands/export/report.rs) | [49](../../native/cli/src/commands/export/report.rs#L49), [53](../../native/cli/src/commands/export/report.rs#L53), [59](../../native/cli/src/commands/export/report.rs#L59), [66](../../native/cli/src/commands/export/report.rs#L66), [87](../../native/cli/src/commands/export/report.rs#L87), [96](../../native/cli/src/commands/export/report.rs#L96), [113](../../native/cli/src/commands/export/report.rs#L113), [119](../../native/cli/src/commands/export/report.rs#L119), [121](../../native/cli/src/commands/export/report.rs#L121), [125](../../native/cli/src/commands/export/report.rs#L125), [137](../../native/cli/src/commands/export/report.rs#L137), [159](../../native/cli/src/commands/export/report.rs#L159), [179](../../native/cli/src/commands/export/report.rs#L179), [189](../../native/cli/src/commands/export/report.rs#L189), [193](../../native/cli/src/commands/export/report.rs#L193), [208](../../native/cli/src/commands/export/report.rs#L208), [214](../../native/cli/src/commands/export/report.rs#L214) | — | — | — | — | +| [native/cli/src/commands/export.rs](../../native/cli/src/commands/export.rs) | [39](../../native/cli/src/commands/export.rs#L39), [42](../../native/cli/src/commands/export.rs#L42), [47](../../native/cli/src/commands/export.rs#L47), [78](../../native/cli/src/commands/export.rs#L78) | [18](../../native/cli/src/commands/export.rs#L18), [304](../../native/cli/src/commands/export.rs#L304) | — | — | — | +| [native/cli/src/commands/file_history/blame.rs](../../native/cli/src/commands/file_history/blame.rs) | [65](../../native/cli/src/commands/file_history/blame.rs#L65), [68](../../native/cli/src/commands/file_history/blame.rs#L68), [73](../../native/cli/src/commands/file_history/blame.rs#L73), [109](../../native/cli/src/commands/file_history/blame.rs#L109), [121](../../native/cli/src/commands/file_history/blame.rs#L121), [193](../../native/cli/src/commands/file_history/blame.rs#L193) | — | — | — | — | +| [native/cli/src/commands/file_history/log_file.rs](../../native/cli/src/commands/file_history/log_file.rs) | [72](../../native/cli/src/commands/file_history/log_file.rs#L72), [75](../../native/cli/src/commands/file_history/log_file.rs#L75), [80](../../native/cli/src/commands/file_history/log_file.rs#L80), [122](../../native/cli/src/commands/file_history/log_file.rs#L122), [126](../../native/cli/src/commands/file_history/log_file.rs#L126), [153](../../native/cli/src/commands/file_history/log_file.rs#L153), [185](../../native/cli/src/commands/file_history/log_file.rs#L185), [278](../../native/cli/src/commands/file_history/log_file.rs#L278) | [14](../../native/cli/src/commands/file_history/log_file.rs#L14), [152](../../native/cli/src/commands/file_history/log_file.rs#L152) | [155](../../native/cli/src/commands/file_history/log_file.rs#L155), [157](../../native/cli/src/commands/file_history/log_file.rs#L157) | — | — | +| [native/cli/src/commands/file_history.rs](../../native/cli/src/commands/file_history.rs) | [34](../../native/cli/src/commands/file_history.rs#L34), [64](../../native/cli/src/commands/file_history.rs#L64) | — | — | — | — | +| [native/cli/src/commands/fork/execution/copy.rs](../../native/cli/src/commands/fork/execution/copy.rs) | — | — | [29](../../native/cli/src/commands/fork/execution/copy.rs#L29), [36](../../native/cli/src/commands/fork/execution/copy.rs#L36), [90](../../native/cli/src/commands/fork/execution/copy.rs#L90) | — | — | +| [native/cli/src/commands/fork/execution/remote.rs](../../native/cli/src/commands/fork/execution/remote.rs) | — | [46](../../native/cli/src/commands/fork/execution/remote.rs#L46) | [61](../../native/cli/src/commands/fork/execution/remote.rs#L61) | — | — | +| [native/cli/src/commands/fork/execution/transform.rs](../../native/cli/src/commands/fork/execution/transform.rs) | — | — | [184](../../native/cli/src/commands/fork/execution/transform.rs#L184) | — | — | +| [native/cli/src/commands/fork/execution.rs](../../native/cli/src/commands/fork/execution.rs) | — | — | [148](../../native/cli/src/commands/fork/execution.rs#L148) | — | — | +| [native/cli/src/commands/fork/policy.rs](../../native/cli/src/commands/fork/policy.rs) | — | — | [20](../../native/cli/src/commands/fork/policy.rs#L20), [24](../../native/cli/src/commands/fork/policy.rs#L24) | — | — | +| [native/cli/src/commands/fork/selection/remotes.rs](../../native/cli/src/commands/fork/selection/remotes.rs) | — | [6](../../native/cli/src/commands/fork/selection/remotes.rs#L6), [85](../../native/cli/src/commands/fork/selection/remotes.rs#L85) | — | — | — | +| [native/cli/src/commands/fork.rs](../../native/cli/src/commands/fork.rs) | [25](../../native/cli/src/commands/fork.rs#L25), [32](../../native/cli/src/commands/fork.rs#L32), [40](../../native/cli/src/commands/fork.rs#L40), [43](../../native/cli/src/commands/fork.rs#L43), [59](../../native/cli/src/commands/fork.rs#L59) | — | — | — | — | +| [native/cli/src/commands/format_cmd/runner/tool.rs](../../native/cli/src/commands/format_cmd/runner/tool.rs) | — | [3](../../native/cli/src/commands/format_cmd/runner/tool.rs#L3), [15](../../native/cli/src/commands/format_cmd/runner/tool.rs#L15) | — | [27](../../native/cli/src/commands/format_cmd/runner/tool.rs#L27) | — | +| [native/cli/src/commands/format_cmd/runner/write.rs](../../native/cli/src/commands/format_cmd/runner/write.rs) | [55](../../native/cli/src/commands/format_cmd/runner/write.rs#L55), [90](../../native/cli/src/commands/format_cmd/runner/write.rs#L90) | — | — | — | — | +| [native/cli/src/commands/format_cmd/runner.rs](../../native/cli/src/commands/format_cmd/runner.rs) | [23](../../native/cli/src/commands/format_cmd/runner.rs#L23), [28](../../native/cli/src/commands/format_cmd/runner.rs#L28), [31](../../native/cli/src/commands/format_cmd/runner.rs#L31) | — | — | — | — | +| [native/cli/src/commands/format_cmd.rs](../../native/cli/src/commands/format_cmd.rs) | [32](../../native/cli/src/commands/format_cmd.rs#L32), [35](../../native/cli/src/commands/format_cmd.rs#L35), [49](../../native/cli/src/commands/format_cmd.rs#L49) | — | — | — | — | +| [native/cli/src/commands/gc/bare/files.rs](../../native/cli/src/commands/gc/bare/files.rs) | [84](../../native/cli/src/commands/gc/bare/files.rs#L84) | — | — | — | — | +| [native/cli/src/commands/gc/bare/index.rs](../../native/cli/src/commands/gc/bare/index.rs) | [14](../../native/cli/src/commands/gc/bare/index.rs#L14), [27](../../native/cli/src/commands/gc/bare/index.rs#L27) | — | — | — | — | +| [native/cli/src/commands/gc/bare/repository.rs](../../native/cli/src/commands/gc/bare/repository.rs) | [46](../../native/cli/src/commands/gc/bare/repository.rs#L46), [49](../../native/cli/src/commands/gc/bare/repository.rs#L49), [53](../../native/cli/src/commands/gc/bare/repository.rs#L53), [103](../../native/cli/src/commands/gc/bare/repository.rs#L103) | — | — | — | — | +| [native/cli/src/commands/gc/bare.rs](../../native/cli/src/commands/gc/bare.rs) | [34](../../native/cli/src/commands/gc/bare.rs#L34), [40](../../native/cli/src/commands/gc/bare.rs#L40), [184](../../native/cli/src/commands/gc/bare.rs#L184), [210](../../native/cli/src/commands/gc/bare.rs#L210) | — | — | — | — | +| [native/cli/src/commands/gc/inventory.rs](../../native/cli/src/commands/gc/inventory.rs) | [180](../../native/cli/src/commands/gc/inventory.rs#L180), [182](../../native/cli/src/commands/gc/inventory.rs#L182), [190](../../native/cli/src/commands/gc/inventory.rs#L190) | — | — | — | — | +| [native/cli/src/commands/gc/restore/copy.rs](../../native/cli/src/commands/gc/restore/copy.rs) | [127](../../native/cli/src/commands/gc/restore/copy.rs#L127) | — | — | — | — | +| [native/cli/src/commands/gc/restore.rs](../../native/cli/src/commands/gc/restore.rs) | [150](../../native/cli/src/commands/gc/restore.rs#L150) | — | — | — | — | +| [native/cli/src/commands/gc.rs](../../native/cli/src/commands/gc.rs) | [58](../../native/cli/src/commands/gc.rs#L58), [61](../../native/cli/src/commands/gc.rs#L61), [66](../../native/cli/src/commands/gc.rs#L66), [91](../../native/cli/src/commands/gc.rs#L91), [98](../../native/cli/src/commands/gc.rs#L98), [104](../../native/cli/src/commands/gc.rs#L104), [106](../../native/cli/src/commands/gc.rs#L106), [110](../../native/cli/src/commands/gc.rs#L110), [113](../../native/cli/src/commands/gc.rs#L113), [330](../../native/cli/src/commands/gc.rs#L330) | — | — | — | — | +| [native/cli/src/commands/git.rs](../../native/cli/src/commands/git.rs) | [43](../../native/cli/src/commands/git.rs#L43), [122](../../native/cli/src/commands/git.rs#L122), [129](../../native/cli/src/commands/git.rs#L129), [134](../../native/cli/src/commands/git.rs#L134), [159](../../native/cli/src/commands/git.rs#L159), [270](../../native/cli/src/commands/git.rs#L270), [290](../../native/cli/src/commands/git.rs#L290) | — | — | [352](../../native/cli/src/commands/git.rs#L352), [365](../../native/cli/src/commands/git.rs#L365) | — | +| [native/cli/src/commands/globals.rs](../../native/cli/src/commands/globals.rs) | [32](../../native/cli/src/commands/globals.rs#L32), [37](../../native/cli/src/commands/globals.rs#L37) | — | — | — | — | +| [native/cli/src/commands/graph_cmd/scope.rs](../../native/cli/src/commands/graph_cmd/scope.rs) | [208](../../native/cli/src/commands/graph_cmd/scope.rs#L208) | — | — | — | — | +| [native/cli/src/commands/graph_cmd.rs](../../native/cli/src/commands/graph_cmd.rs) | [43](../../native/cli/src/commands/graph_cmd.rs#L43), [46](../../native/cli/src/commands/graph_cmd.rs#L46), [49](../../native/cli/src/commands/graph_cmd.rs#L49), [56](../../native/cli/src/commands/graph_cmd.rs#L56), [66](../../native/cli/src/commands/graph_cmd.rs#L66), [103](../../native/cli/src/commands/graph_cmd.rs#L103), [128](../../native/cli/src/commands/graph_cmd.rs#L128) | — | — | — | — | +| [native/cli/src/commands/ids.rs](../../native/cli/src/commands/ids.rs) | [30](../../native/cli/src/commands/ids.rs#L30), [33](../../native/cli/src/commands/ids.rs#L33), [50](../../native/cli/src/commands/ids.rs#L50) | — | — | — | — | +| [native/cli/src/commands/import/cache.rs](../../native/cli/src/commands/import/cache.rs) | [29](../../native/cli/src/commands/import/cache.rs#L29), [38](../../native/cli/src/commands/import/cache.rs#L38), [50](../../native/cli/src/commands/import/cache.rs#L50) | [128](../../native/cli/src/commands/import/cache.rs#L128) | [131](../../native/cli/src/commands/import/cache.rs#L131), [132](../../native/cli/src/commands/import/cache.rs#L132) | — | — | +| [native/cli/src/commands/import/execution.rs](../../native/cli/src/commands/import/execution.rs) | [15](../../native/cli/src/commands/import/execution.rs#L15), [20](../../native/cli/src/commands/import/execution.rs#L20), [51](../../native/cli/src/commands/import/execution.rs#L51), [227](../../native/cli/src/commands/import/execution.rs#L227) | — | — | — | — | +| [native/cli/src/commands/import/lifecycle.rs](../../native/cli/src/commands/import/lifecycle.rs) | — | — | [88](../../native/cli/src/commands/import/lifecycle.rs#L88), [133](../../native/cli/src/commands/import/lifecycle.rs#L133), [158](../../native/cli/src/commands/import/lifecycle.rs#L158), [198](../../native/cli/src/commands/import/lifecycle.rs#L198), [238](../../native/cli/src/commands/import/lifecycle.rs#L238), [241](../../native/cli/src/commands/import/lifecycle.rs#L241), [245](../../native/cli/src/commands/import/lifecycle.rs#L245) | — | — | +| [native/cli/src/commands/import/patterns.rs](../../native/cli/src/commands/import/patterns.rs) | [36](../../native/cli/src/commands/import/patterns.rs#L36), [45](../../native/cli/src/commands/import/patterns.rs#L45), [73](../../native/cli/src/commands/import/patterns.rs#L73), [96](../../native/cli/src/commands/import/patterns.rs#L96), [109](../../native/cli/src/commands/import/patterns.rs#L109), [129](../../native/cli/src/commands/import/patterns.rs#L129), [138](../../native/cli/src/commands/import/patterns.rs#L138), [153](../../native/cli/src/commands/import/patterns.rs#L153), [156](../../native/cli/src/commands/import/patterns.rs#L156) | [15](../../native/cli/src/commands/import/patterns.rs#L15), [82](../../native/cli/src/commands/import/patterns.rs#L82) | — | — | — | +| [native/cli/src/commands/import/preparation.rs](../../native/cli/src/commands/import/preparation.rs) | [71](../../native/cli/src/commands/import/preparation.rs#L71), [135](../../native/cli/src/commands/import/preparation.rs#L135), [175](../../native/cli/src/commands/import/preparation.rs#L175), [178](../../native/cli/src/commands/import/preparation.rs#L178), [195](../../native/cli/src/commands/import/preparation.rs#L195), [208](../../native/cli/src/commands/import/preparation.rs#L208), [211](../../native/cli/src/commands/import/preparation.rs#L211), [220](../../native/cli/src/commands/import/preparation.rs#L220), [286](../../native/cli/src/commands/import/preparation.rs#L286), [289](../../native/cli/src/commands/import/preparation.rs#L289), [299](../../native/cli/src/commands/import/preparation.rs#L299) | [6](../../native/cli/src/commands/import/preparation.rs#L6), [383](../../native/cli/src/commands/import/preparation.rs#L383) | — | — | — | +| [native/cli/src/commands/import/recover.rs](../../native/cli/src/commands/import/recover.rs) | — | [44](../../native/cli/src/commands/import/recover.rs#L44) | — | — | — | +| [native/cli/src/commands/import/report.rs](../../native/cli/src/commands/import/report.rs) | [51](../../native/cli/src/commands/import/report.rs#L51), [65](../../native/cli/src/commands/import/report.rs#L65), [90](../../native/cli/src/commands/import/report.rs#L90), [124](../../native/cli/src/commands/import/report.rs#L124), [159](../../native/cli/src/commands/import/report.rs#L159), [170](../../native/cli/src/commands/import/report.rs#L170) | — | — | — | — | +| [native/cli/src/commands/import.rs](../../native/cli/src/commands/import.rs) | [47](../../native/cli/src/commands/import.rs#L47), [54](../../native/cli/src/commands/import.rs#L54), [57](../../native/cli/src/commands/import.rs#L57), [89](../../native/cli/src/commands/import.rs#L89) | — | — | — | — | +| [native/cli/src/commands/init/assets.rs](../../native/cli/src/commands/init/assets.rs) | [74](../../native/cli/src/commands/init/assets.rs#L74), [77](../../native/cli/src/commands/init/assets.rs#L77) | — | — | — | — | +| [native/cli/src/commands/init/config.rs](../../native/cli/src/commands/init/config.rs) | [67](../../native/cli/src/commands/init/config.rs#L67) | — | — | — | — | +| [native/cli/src/commands/init/plan.rs](../../native/cli/src/commands/init/plan.rs) | — | [5](../../native/cli/src/commands/init/plan.rs#L5), [36](../../native/cli/src/commands/init/plan.rs#L36) | — | — | — | +| [native/cli/src/commands/init.rs](../../native/cli/src/commands/init.rs) | [32](../../native/cli/src/commands/init.rs#L32), [35](../../native/cli/src/commands/init.rs#L35), [50](../../native/cli/src/commands/init.rs#L50) | — | — | — | — | +| [native/cli/src/commands/insights.rs](../../native/cli/src/commands/insights.rs) | [47](../../native/cli/src/commands/insights.rs#L47), [50](../../native/cli/src/commands/insights.rs#L50), [128](../../native/cli/src/commands/insights.rs#L128), [136](../../native/cli/src/commands/insights.rs#L136), [229](../../native/cli/src/commands/insights.rs#L229) | — | — | — | — | +| [native/cli/src/commands/install/request.rs](../../native/cli/src/commands/install/request.rs) | — | — | [94](../../native/cli/src/commands/install/request.rs#L94) | — | — | +| [native/cli/src/commands/install.rs](../../native/cli/src/commands/install.rs) | [41](../../native/cli/src/commands/install.rs#L41), [50](../../native/cli/src/commands/install.rs#L50), [58](../../native/cli/src/commands/install.rs#L58), [59](../../native/cli/src/commands/install.rs#L59), [84](../../native/cli/src/commands/install.rs#L84), [91](../../native/cli/src/commands/install.rs#L91), [95](../../native/cli/src/commands/install.rs#L95), [98](../../native/cli/src/commands/install.rs#L98), [102](../../native/cli/src/commands/install.rs#L102), [268](../../native/cli/src/commands/install.rs#L268), [310](../../native/cli/src/commands/install.rs#L310) | [12](../../native/cli/src/commands/install.rs#L12), [76](../../native/cli/src/commands/install.rs#L76), [97](../../native/cli/src/commands/install.rs#L97) | [46](../../native/cli/src/commands/install.rs#L46), [128](../../native/cli/src/commands/install.rs#L128), [133](../../native/cli/src/commands/install.rs#L133), [258](../../native/cli/src/commands/install.rs#L258), [328](../../native/cli/src/commands/install.rs#L328), [336](../../native/cli/src/commands/install.rs#L336), [340](../../native/cli/src/commands/install.rs#L340), [344](../../native/cli/src/commands/install.rs#L344), [353](../../native/cli/src/commands/install.rs#L353), [357](../../native/cli/src/commands/install.rs#L357), [361](../../native/cli/src/commands/install.rs#L361), [365](../../native/cli/src/commands/install.rs#L365), [369](../../native/cli/src/commands/install.rs#L369), [375](../../native/cli/src/commands/install.rs#L375) | — | — | +| [native/cli/src/commands/installation/canonical.rs](../../native/cli/src/commands/installation/canonical.rs) | — | — | [32](../../native/cli/src/commands/installation/canonical.rs#L32), [36](../../native/cli/src/commands/installation/canonical.rs#L36), [54](../../native/cli/src/commands/installation/canonical.rs#L54) | — | — | +| [native/cli/src/commands/installation.rs](../../native/cli/src/commands/installation.rs) | — | [16](../../native/cli/src/commands/installation.rs#L16), [162](../../native/cli/src/commands/installation.rs#L162) | [170](../../native/cli/src/commands/installation.rs#L170), [266](../../native/cli/src/commands/installation.rs#L266), [351](../../native/cli/src/commands/installation.rs#L351) | — | — | +| [native/cli/src/commands/internalize.rs](../../native/cli/src/commands/internalize.rs) | [66](../../native/cli/src/commands/internalize.rs#L66), [69](../../native/cli/src/commands/internalize.rs#L69), [93](../../native/cli/src/commands/internalize.rs#L93), [113](../../native/cli/src/commands/internalize.rs#L113) | — | — | — | — | +| [native/cli/src/commands/lane/alias.rs](../../native/cli/src/commands/lane/alias.rs) | [19](../../native/cli/src/commands/lane/alias.rs#L19), [22](../../native/cli/src/commands/lane/alias.rs#L22), [26](../../native/cli/src/commands/lane/alias.rs#L26), [31](../../native/cli/src/commands/lane/alias.rs#L31), [62](../../native/cli/src/commands/lane/alias.rs#L62), [71](../../native/cli/src/commands/lane/alias.rs#L71) | — | — | — | — | +| [native/cli/src/commands/lane/create.rs](../../native/cli/src/commands/lane/create.rs) | [119](../../native/cli/src/commands/lane/create.rs#L119), [130](../../native/cli/src/commands/lane/create.rs#L130), [147](../../native/cli/src/commands/lane/create.rs#L147), [157](../../native/cli/src/commands/lane/create.rs#L157), [173](../../native/cli/src/commands/lane/create.rs#L173), [186](../../native/cli/src/commands/lane/create.rs#L186), [202](../../native/cli/src/commands/lane/create.rs#L202), [235](../../native/cli/src/commands/lane/create.rs#L235) | — | — | — | — | +| [native/cli/src/commands/lane/current.rs](../../native/cli/src/commands/lane/current.rs) | [33](../../native/cli/src/commands/lane/current.rs#L33), [50](../../native/cli/src/commands/lane/current.rs#L50) | — | — | — | — | +| [native/cli/src/commands/lane/diff.rs](../../native/cli/src/commands/lane/diff.rs) | [24](../../native/cli/src/commands/lane/diff.rs#L24), [38](../../native/cli/src/commands/lane/diff.rs#L38), [46](../../native/cli/src/commands/lane/diff.rs#L46), [55](../../native/cli/src/commands/lane/diff.rs#L55), [79](../../native/cli/src/commands/lane/diff.rs#L79), [169](../../native/cli/src/commands/lane/diff.rs#L169), [176](../../native/cli/src/commands/lane/diff.rs#L176), [314](../../native/cli/src/commands/lane/diff.rs#L314) | — | — | — | — | +| [native/cli/src/commands/lane/history.rs](../../native/cli/src/commands/lane/history.rs) | [88](../../native/cli/src/commands/lane/history.rs#L88), [100](../../native/cli/src/commands/lane/history.rs#L100), [111](../../native/cli/src/commands/lane/history.rs#L111), [122](../../native/cli/src/commands/lane/history.rs#L122), [136](../../native/cli/src/commands/lane/history.rs#L136), [138](../../native/cli/src/commands/lane/history.rs#L138), [154](../../native/cli/src/commands/lane/history.rs#L154), [205](../../native/cli/src/commands/lane/history.rs#L205), [226](../../native/cli/src/commands/lane/history.rs#L226) | [9](../../native/cli/src/commands/lane/history.rs#L9), [239](../../native/cli/src/commands/lane/history.rs#L239) | — | — | — | +| [native/cli/src/commands/lane/import.rs](../../native/cli/src/commands/lane/import.rs) | [27](../../native/cli/src/commands/lane/import.rs#L27), [43](../../native/cli/src/commands/lane/import.rs#L43), [62](../../native/cli/src/commands/lane/import.rs#L62), [73](../../native/cli/src/commands/lane/import.rs#L73), [147](../../native/cli/src/commands/lane/import.rs#L147) | — | — | — | — | +| [native/cli/src/commands/lane/list/metadata.rs](../../native/cli/src/commands/lane/list/metadata.rs) | [48](../../native/cli/src/commands/lane/list/metadata.rs#L48), [87](../../native/cli/src/commands/lane/list/metadata.rs#L87), [91](../../native/cli/src/commands/lane/list/metadata.rs#L91), [93](../../native/cli/src/commands/lane/list/metadata.rs#L93), [114](../../native/cli/src/commands/lane/list/metadata.rs#L114), [120](../../native/cli/src/commands/lane/list/metadata.rs#L120) | — | — | — | — | +| [native/cli/src/commands/lane/list/remote.rs](../../native/cli/src/commands/lane/list/remote.rs) | [42](../../native/cli/src/commands/lane/list/remote.rs#L42) | [11](../../native/cli/src/commands/lane/list/remote.rs#L11), [130](../../native/cli/src/commands/lane/list/remote.rs#L130) | — | — | — | +| [native/cli/src/commands/lane/list.rs](../../native/cli/src/commands/lane/list.rs) | [130](../../native/cli/src/commands/lane/list.rs#L130), [134](../../native/cli/src/commands/lane/list.rs#L134), [139](../../native/cli/src/commands/lane/list.rs#L139), [140](../../native/cli/src/commands/lane/list.rs#L140), [147](../../native/cli/src/commands/lane/list.rs#L147), [159](../../native/cli/src/commands/lane/list.rs#L159), [390](../../native/cli/src/commands/lane/list.rs#L390) | — | — | — | — | +| [native/cli/src/commands/lane/manage/query.rs](../../native/cli/src/commands/lane/manage/query.rs) | [25](../../native/cli/src/commands/lane/manage/query.rs#L25), [36](../../native/cli/src/commands/lane/manage/query.rs#L36), [50](../../native/cli/src/commands/lane/manage/query.rs#L50), [80](../../native/cli/src/commands/lane/manage/query.rs#L80), [118](../../native/cli/src/commands/lane/manage/query.rs#L118), [129](../../native/cli/src/commands/lane/manage/query.rs#L129), [146](../../native/cli/src/commands/lane/manage/query.rs#L146), [162](../../native/cli/src/commands/lane/manage/query.rs#L162) | — | — | — | — | +| [native/cli/src/commands/lane/manage.rs](../../native/cli/src/commands/lane/manage.rs) | [29](../../native/cli/src/commands/lane/manage.rs#L29), [43](../../native/cli/src/commands/lane/manage.rs#L43), [113](../../native/cli/src/commands/lane/manage.rs#L113), [118](../../native/cli/src/commands/lane/manage.rs#L118), [133](../../native/cli/src/commands/lane/manage.rs#L133), [161](../../native/cli/src/commands/lane/manage.rs#L161), [232](../../native/cli/src/commands/lane/manage.rs#L232), [237](../../native/cli/src/commands/lane/manage.rs#L237), [245](../../native/cli/src/commands/lane/manage.rs#L245), [257](../../native/cli/src/commands/lane/manage.rs#L257), [267](../../native/cli/src/commands/lane/manage.rs#L267), [313](../../native/cli/src/commands/lane/manage.rs#L313), [316](../../native/cli/src/commands/lane/manage.rs#L316), [332](../../native/cli/src/commands/lane/manage.rs#L332), [337](../../native/cli/src/commands/lane/manage.rs#L337), [350](../../native/cli/src/commands/lane/manage.rs#L350) | — | — | — | — | +| [native/cli/src/commands/lane/remove.rs](../../native/cli/src/commands/lane/remove.rs) | [19](../../native/cli/src/commands/lane/remove.rs#L19), [23](../../native/cli/src/commands/lane/remove.rs#L23), [33](../../native/cli/src/commands/lane/remove.rs#L33), [37](../../native/cli/src/commands/lane/remove.rs#L37), [41](../../native/cli/src/commands/lane/remove.rs#L41), [83](../../native/cli/src/commands/lane/remove.rs#L83) | — | — | — | — | +| [native/cli/src/commands/lane/restoration/installation.rs](../../native/cli/src/commands/lane/restoration/installation.rs) | [76](../../native/cli/src/commands/lane/restoration/installation.rs#L76), [81](../../native/cli/src/commands/lane/restoration/installation.rs#L81) | — | [60](../../native/cli/src/commands/lane/restoration/installation.rs#L60) | — | — | +| [native/cli/src/commands/lane/restoration/session.rs](../../native/cli/src/commands/lane/restoration/session.rs) | — | [14](../../native/cli/src/commands/lane/restoration/session.rs#L14), [37](../../native/cli/src/commands/lane/restoration/session.rs#L37), [166](../../native/cli/src/commands/lane/restoration/session.rs#L166) | — | — | — | +| [native/cli/src/commands/lane/restoration.rs](../../native/cli/src/commands/lane/restoration.rs) | [21](../../native/cli/src/commands/lane/restoration.rs#L21), [26](../../native/cli/src/commands/lane/restoration.rs#L26), [49](../../native/cli/src/commands/lane/restoration.rs#L49), [59](../../native/cli/src/commands/lane/restoration.rs#L59) | — | — | — | — | +| [native/cli/src/commands/lane/scope_root.rs](../../native/cli/src/commands/lane/scope_root.rs) | [33](../../native/cli/src/commands/lane/scope_root.rs#L33) | — | — | — | — | +| [native/cli/src/commands/lane/show.rs](../../native/cli/src/commands/lane/show.rs) | [30](../../native/cli/src/commands/lane/show.rs#L30), [34](../../native/cli/src/commands/lane/show.rs#L34), [35](../../native/cli/src/commands/lane/show.rs#L35), [42](../../native/cli/src/commands/lane/show.rs#L42), [72](../../native/cli/src/commands/lane/show.rs#L72), [125](../../native/cli/src/commands/lane/show.rs#L125), [132](../../native/cli/src/commands/lane/show.rs#L132), [136](../../native/cli/src/commands/lane/show.rs#L136), [362](../../native/cli/src/commands/lane/show.rs#L362), [364](../../native/cli/src/commands/lane/show.rs#L364), [366](../../native/cli/src/commands/lane/show.rs#L366) | — | — | — | — | +| [native/cli/src/commands/lane.rs](../../native/cli/src/commands/lane.rs) | [48](../../native/cli/src/commands/lane.rs#L48), [79](../../native/cli/src/commands/lane.rs#L79), [82](../../native/cli/src/commands/lane.rs#L82), [88](../../native/cli/src/commands/lane.rs#L88), [108](../../native/cli/src/commands/lane.rs#L108), [124](../../native/cli/src/commands/lane.rs#L124), [144](../../native/cli/src/commands/lane.rs#L144) | — | — | — | — | +| [native/cli/src/commands/link_cmd/rewire.rs](../../native/cli/src/commands/link_cmd/rewire.rs) | [83](../../native/cli/src/commands/link_cmd/rewire.rs#L83), [87](../../native/cli/src/commands/link_cmd/rewire.rs#L87) | — | — | — | — | +| [native/cli/src/commands/link_cmd/runner.rs](../../native/cli/src/commands/link_cmd/runner.rs) | [51](../../native/cli/src/commands/link_cmd/runner.rs#L51), [54](../../native/cli/src/commands/link_cmd/runner.rs#L54), [57](../../native/cli/src/commands/link_cmd/runner.rs#L57) | — | — | — | — | +| [native/cli/src/commands/link_cmd/target.rs](../../native/cli/src/commands/link_cmd/target.rs) | — | [6](../../native/cli/src/commands/link_cmd/target.rs#L6), [245](../../native/cli/src/commands/link_cmd/target.rs#L245) | — | — | — | +| [native/cli/src/commands/link_cmd.rs](../../native/cli/src/commands/link_cmd.rs) | [37](../../native/cli/src/commands/link_cmd.rs#L37), [40](../../native/cli/src/commands/link_cmd.rs#L40), [57](../../native/cli/src/commands/link_cmd.rs#L57), [66](../../native/cli/src/commands/link_cmd.rs#L66) | — | — | — | — | +| [native/cli/src/commands/lint_cmd/runner/config.rs](../../native/cli/src/commands/lint_cmd/runner/config.rs) | [63](../../native/cli/src/commands/lint_cmd/runner/config.rs#L63) | — | — | — | — | +| [native/cli/src/commands/lint_cmd/runner/plan.rs](../../native/cli/src/commands/lint_cmd/runner/plan.rs) | [178](../../native/cli/src/commands/lint_cmd/runner/plan.rs#L178) | — | — | — | — | +| [native/cli/src/commands/lint_cmd/runner/tool.rs](../../native/cli/src/commands/lint_cmd/runner/tool.rs) | — | [5](../../native/cli/src/commands/lint_cmd/runner/tool.rs#L5), [41](../../native/cli/src/commands/lint_cmd/runner/tool.rs#L41) | — | [50](../../native/cli/src/commands/lint_cmd/runner/tool.rs#L50) | — | +| [native/cli/src/commands/lint_cmd/runner.rs](../../native/cli/src/commands/lint_cmd/runner.rs) | [21](../../native/cli/src/commands/lint_cmd/runner.rs#L21) | — | — | — | — | +| [native/cli/src/commands/lint_cmd.rs](../../native/cli/src/commands/lint_cmd.rs) | [39](../../native/cli/src/commands/lint_cmd.rs#L39), [42](../../native/cli/src/commands/lint_cmd.rs#L42), [55](../../native/cli/src/commands/lint_cmd.rs#L55), [209](../../native/cli/src/commands/lint_cmd.rs#L209), [213](../../native/cli/src/commands/lint_cmd.rs#L213), [217](../../native/cli/src/commands/lint_cmd.rs#L217), [265](../../native/cli/src/commands/lint_cmd.rs#L265) | — | — | — | — | +| [native/cli/src/commands/list/bitmap.rs](../../native/cli/src/commands/list/bitmap.rs) | [52](../../native/cli/src/commands/list/bitmap.rs#L52), [53](../../native/cli/src/commands/list/bitmap.rs#L53), [95](../../native/cli/src/commands/list/bitmap.rs#L95), [118](../../native/cli/src/commands/list/bitmap.rs#L118), [138](../../native/cli/src/commands/list/bitmap.rs#L138), [142](../../native/cli/src/commands/list/bitmap.rs#L142) | — | — | — | — | +| [native/cli/src/commands/list/remote.rs](../../native/cli/src/commands/list/remote.rs) | [40](../../native/cli/src/commands/list/remote.rs#L40), [60](../../native/cli/src/commands/list/remote.rs#L60), [71](../../native/cli/src/commands/list/remote.rs#L71), [81](../../native/cli/src/commands/list/remote.rs#L81), [84](../../native/cli/src/commands/list/remote.rs#L84), [92](../../native/cli/src/commands/list/remote.rs#L92), [94](../../native/cli/src/commands/list/remote.rs#L94), [105](../../native/cli/src/commands/list/remote.rs#L105), [108](../../native/cli/src/commands/list/remote.rs#L108), [128](../../native/cli/src/commands/list/remote.rs#L128), [135](../../native/cli/src/commands/list/remote.rs#L135), [156](../../native/cli/src/commands/list/remote.rs#L156), [166](../../native/cli/src/commands/list/remote.rs#L166), [170](../../native/cli/src/commands/list/remote.rs#L170), [174](../../native/cli/src/commands/list/remote.rs#L174), [176](../../native/cli/src/commands/list/remote.rs#L176), [191](../../native/cli/src/commands/list/remote.rs#L191) | [8](../../native/cli/src/commands/list/remote.rs#L8), [20](../../native/cli/src/commands/list/remote.rs#L20) | — | — | — | +| [native/cli/src/commands/list.rs](../../native/cli/src/commands/list.rs) | [31](../../native/cli/src/commands/list.rs#L31), [34](../../native/cli/src/commands/list.rs#L34), [42](../../native/cli/src/commands/list.rs#L42), [59](../../native/cli/src/commands/list.rs#L59), [61](../../native/cli/src/commands/list.rs#L61), [88](../../native/cli/src/commands/list.rs#L88), [172](../../native/cli/src/commands/list.rs#L172), [186](../../native/cli/src/commands/list.rs#L186), [222](../../native/cli/src/commands/list.rs#L222), [228](../../native/cli/src/commands/list.rs#L228), [235](../../native/cli/src/commands/list.rs#L235), [310](../../native/cli/src/commands/list.rs#L310), [312](../../native/cli/src/commands/list.rs#L312), [324](../../native/cli/src/commands/list.rs#L324), [337](../../native/cli/src/commands/list.rs#L337), [344](../../native/cli/src/commands/list.rs#L344), [365](../../native/cli/src/commands/list.rs#L365), [399](../../native/cli/src/commands/list.rs#L399) | — | — | — | — | +| [native/cli/src/commands/local_only.rs](../../native/cli/src/commands/local_only.rs) | [46](../../native/cli/src/commands/local_only.rs#L46), [54](../../native/cli/src/commands/local_only.rs#L54), [72](../../native/cli/src/commands/local_only.rs#L72), [78](../../native/cli/src/commands/local_only.rs#L78) | — | — | — | — | +| [native/cli/src/commands/log/topology.rs](../../native/cli/src/commands/log/topology.rs) | [35](../../native/cli/src/commands/log/topology.rs#L35), [71](../../native/cli/src/commands/log/topology.rs#L71), [77](../../native/cli/src/commands/log/topology.rs#L77), [84](../../native/cli/src/commands/log/topology.rs#L84), [86](../../native/cli/src/commands/log/topology.rs#L86), [88](../../native/cli/src/commands/log/topology.rs#L88), [96](../../native/cli/src/commands/log/topology.rs#L96), [98](../../native/cli/src/commands/log/topology.rs#L98), [160](../../native/cli/src/commands/log/topology.rs#L160), [183](../../native/cli/src/commands/log/topology.rs#L183), [204](../../native/cli/src/commands/log/topology.rs#L204), [214](../../native/cli/src/commands/log/topology.rs#L214), [257](../../native/cli/src/commands/log/topology.rs#L257) | — | — | — | — | +| [native/cli/src/commands/log.rs](../../native/cli/src/commands/log.rs) | [47](../../native/cli/src/commands/log.rs#L47), [50](../../native/cli/src/commands/log.rs#L50), [69](../../native/cli/src/commands/log.rs#L69), [100](../../native/cli/src/commands/log.rs#L100), [103](../../native/cli/src/commands/log.rs#L103), [107](../../native/cli/src/commands/log.rs#L107), [141](../../native/cli/src/commands/log.rs#L141), [154](../../native/cli/src/commands/log.rs#L154), [166](../../native/cli/src/commands/log.rs#L166), [191](../../native/cli/src/commands/log.rs#L191), [200](../../native/cli/src/commands/log.rs#L200), [202](../../native/cli/src/commands/log.rs#L202), [299](../../native/cli/src/commands/log.rs#L299) | [24](../../native/cli/src/commands/log.rs#L24), [142](../../native/cli/src/commands/log.rs#L142) | — | — | — | +| [native/cli/src/commands/login.rs](../../native/cli/src/commands/login.rs) | [34](../../native/cli/src/commands/login.rs#L34), [37](../../native/cli/src/commands/login.rs#L37), [55](../../native/cli/src/commands/login.rs#L55) | — | — | — | — | +| [native/cli/src/commands/logout.rs](../../native/cli/src/commands/logout.rs) | [30](../../native/cli/src/commands/logout.rs#L30) | — | — | — | — | +| [native/cli/src/commands/mcp_server.rs](../../native/cli/src/commands/mcp_server.rs) | [47](../../native/cli/src/commands/mcp_server.rs#L47), [50](../../native/cli/src/commands/mcp_server.rs#L50), [77](../../native/cli/src/commands/mcp_server.rs#L77) | — | — | — | — | +| [native/cli/src/commands/merge/install.rs](../../native/cli/src/commands/merge/install.rs) | [21](../../native/cli/src/commands/merge/install.rs#L21) | — | — | — | — | +| [native/cli/src/commands/merge/lifecycle.rs](../../native/cli/src/commands/merge/lifecycle.rs) | [69](../../native/cli/src/commands/merge/lifecycle.rs#L69), [94](../../native/cli/src/commands/merge/lifecycle.rs#L94), [109](../../native/cli/src/commands/merge/lifecycle.rs#L109), [168](../../native/cli/src/commands/merge/lifecycle.rs#L168) | — | — | — | — | +| [native/cli/src/commands/merge/resolve.rs](../../native/cli/src/commands/merge/resolve.rs) | [185](../../native/cli/src/commands/merge/resolve.rs#L185), [200](../../native/cli/src/commands/merge/resolve.rs#L200), [277](../../native/cli/src/commands/merge/resolve.rs#L277), [288](../../native/cli/src/commands/merge/resolve.rs#L288) | — | — | — | — | +| [native/cli/src/commands/merge.rs](../../native/cli/src/commands/merge.rs) | [25](../../native/cli/src/commands/merge.rs#L25), [34](../../native/cli/src/commands/merge.rs#L34), [60](../../native/cli/src/commands/merge.rs#L60), [63](../../native/cli/src/commands/merge.rs#L63) | — | — | — | — | +| [native/cli/src/commands/mini_status.rs](../../native/cli/src/commands/mini_status.rs) | [34](../../native/cli/src/commands/mini_status.rs#L34), [95](../../native/cli/src/commands/mini_status.rs#L95), [98](../../native/cli/src/commands/mini_status.rs#L98), [102](../../native/cli/src/commands/mini_status.rs#L102), [130](../../native/cli/src/commands/mini_status.rs#L130), [134](../../native/cli/src/commands/mini_status.rs#L134), [220](../../native/cli/src/commands/mini_status.rs#L220) | — | — | — | — | +| [native/cli/src/commands/move_paths.rs](../../native/cli/src/commands/move_paths.rs) | [17](../../native/cli/src/commands/move_paths.rs#L17), [20](../../native/cli/src/commands/move_paths.rs#L20), [68](../../native/cli/src/commands/move_paths.rs#L68) | — | — | — | — | +| [native/cli/src/commands/new_cmd.rs](../../native/cli/src/commands/new_cmd.rs) | [37](../../native/cli/src/commands/new_cmd.rs#L37), [40](../../native/cli/src/commands/new_cmd.rs#L40), [56](../../native/cli/src/commands/new_cmd.rs#L56), [75](../../native/cli/src/commands/new_cmd.rs#L75) | — | — | — | — | +| [native/cli/src/commands/npmrc.rs](../../native/cli/src/commands/npmrc.rs) | [23](../../native/cli/src/commands/npmrc.rs#L23), [26](../../native/cli/src/commands/npmrc.rs#L26) | — | — | — | — | +| [native/cli/src/commands/pack_cmd/packer.rs](../../native/cli/src/commands/pack_cmd/packer.rs) | [50](../../native/cli/src/commands/pack_cmd/packer.rs#L50) | — | — | — | — | +| [native/cli/src/commands/pack_cmd.rs](../../native/cli/src/commands/pack_cmd.rs) | [33](../../native/cli/src/commands/pack_cmd.rs#L33), [36](../../native/cli/src/commands/pack_cmd.rs#L36), [52](../../native/cli/src/commands/pack_cmd.rs#L52), [61](../../native/cli/src/commands/pack_cmd.rs#L61) | — | — | — | — | +| [native/cli/src/commands/pattern.rs](../../native/cli/src/commands/pattern.rs) | [52](../../native/cli/src/commands/pattern.rs#L52), [65](../../native/cli/src/commands/pattern.rs#L65), [77](../../native/cli/src/commands/pattern.rs#L77), [84](../../native/cli/src/commands/pattern.rs#L84) | [16](../../native/cli/src/commands/pattern.rs#L16), [232](../../native/cli/src/commands/pattern.rs#L232), [266](../../native/cli/src/commands/pattern.rs#L266) | — | — | — | +| [native/cli/src/commands/patterns/extended/ranges.rs](../../native/cli/src/commands/patterns/extended/ranges.rs) | [14](../../native/cli/src/commands/patterns/extended/ranges.rs#L14), [25](../../native/cli/src/commands/patterns/extended/ranges.rs#L25), [63](../../native/cli/src/commands/patterns/extended/ranges.rs#L63), [76](../../native/cli/src/commands/patterns/extended/ranges.rs#L76), [88](../../native/cli/src/commands/patterns/extended/ranges.rs#L88), [91](../../native/cli/src/commands/patterns/extended/ranges.rs#L91), [94](../../native/cli/src/commands/patterns/extended/ranges.rs#L94), [99](../../native/cli/src/commands/patterns/extended/ranges.rs#L99), [110](../../native/cli/src/commands/patterns/extended/ranges.rs#L110), [131](../../native/cli/src/commands/patterns/extended/ranges.rs#L131), [133](../../native/cli/src/commands/patterns/extended/ranges.rs#L133) | — | — | — | — | +| [native/cli/src/commands/patterns.rs](../../native/cli/src/commands/patterns.rs) | [83](../../native/cli/src/commands/patterns.rs#L83) | — | — | — | — | +| [native/cli/src/commands/preview_cmd.rs](../../native/cli/src/commands/preview_cmd.rs) | [40](../../native/cli/src/commands/preview_cmd.rs#L40), [43](../../native/cli/src/commands/preview_cmd.rs#L43), [55](../../native/cli/src/commands/preview_cmd.rs#L55), [73](../../native/cli/src/commands/preview_cmd.rs#L73), [82](../../native/cli/src/commands/preview_cmd.rs#L82), [85](../../native/cli/src/commands/preview_cmd.rs#L85), [89](../../native/cli/src/commands/preview_cmd.rs#L89), [100](../../native/cli/src/commands/preview_cmd.rs#L100), [103](../../native/cli/src/commands/preview_cmd.rs#L103), [106](../../native/cli/src/commands/preview_cmd.rs#L106), [116](../../native/cli/src/commands/preview_cmd.rs#L116), [122](../../native/cli/src/commands/preview_cmd.rs#L122), [125](../../native/cli/src/commands/preview_cmd.rs#L125), [127](../../native/cli/src/commands/preview_cmd.rs#L127) | — | — | — | — | +| [native/cli/src/commands/publish_cmd.rs](../../native/cli/src/commands/publish_cmd.rs) | [50](../../native/cli/src/commands/publish_cmd.rs#L50), [53](../../native/cli/src/commands/publish_cmd.rs#L53), [69](../../native/cli/src/commands/publish_cmd.rs#L69), [129](../../native/cli/src/commands/publish_cmd.rs#L129), [150](../../native/cli/src/commands/publish_cmd.rs#L150), [172](../../native/cli/src/commands/publish_cmd.rs#L172) | — | — | — | — | +| [native/cli/src/commands/recover.rs](../../native/cli/src/commands/recover.rs) | [21](../../native/cli/src/commands/recover.rs#L21), [24](../../native/cli/src/commands/recover.rs#L24), [32](../../native/cli/src/commands/recover.rs#L32) | — | — | — | — | +| [native/cli/src/commands/recovery/failure.rs](../../native/cli/src/commands/recovery/failure.rs) | [17](../../native/cli/src/commands/recovery/failure.rs#L17), [23](../../native/cli/src/commands/recovery/failure.rs#L23) | — | — | — | — | +| [native/cli/src/commands/recovery/report.rs](../../native/cli/src/commands/recovery/report.rs) | [19](../../native/cli/src/commands/recovery/report.rs#L19), [42](../../native/cli/src/commands/recovery/report.rs#L42), [48](../../native/cli/src/commands/recovery/report.rs#L48), [63](../../native/cli/src/commands/recovery/report.rs#L63), [105](../../native/cli/src/commands/recovery/report.rs#L105), [206](../../native/cli/src/commands/recovery/report.rs#L206), [211](../../native/cli/src/commands/recovery/report.rs#L211), [215](../../native/cli/src/commands/recovery/report.rs#L215) | — | — | — | — | +| [native/cli/src/commands/recovery.rs](../../native/cli/src/commands/recovery.rs) | [126](../../native/cli/src/commands/recovery.rs#L126), [156](../../native/cli/src/commands/recovery.rs#L156), [160](../../native/cli/src/commands/recovery.rs#L160), [180](../../native/cli/src/commands/recovery.rs#L180), [262](../../native/cli/src/commands/recovery.rs#L262), [267](../../native/cli/src/commands/recovery.rs#L267), [269](../../native/cli/src/commands/recovery.rs#L269) | — | [328](../../native/cli/src/commands/recovery.rs#L328) | — | — | +| [native/cli/src/commands/refactor.rs](../../native/cli/src/commands/refactor.rs) | [59](../../native/cli/src/commands/refactor.rs#L59), [103](../../native/cli/src/commands/refactor.rs#L103), [109](../../native/cli/src/commands/refactor.rs#L109), [149](../../native/cli/src/commands/refactor.rs#L149), [154](../../native/cli/src/commands/refactor.rs#L154), [165](../../native/cli/src/commands/refactor.rs#L165), [176](../../native/cli/src/commands/refactor.rs#L176) | — | — | — | — | +| [native/cli/src/commands/remote_action/file.rs](../../native/cli/src/commands/remote_action/file.rs) | [31](../../native/cli/src/commands/remote_action/file.rs#L31), [32](../../native/cli/src/commands/remote_action/file.rs#L32), [38](../../native/cli/src/commands/remote_action/file.rs#L38) | — | — | — | — | +| [native/cli/src/commands/remote_action.rs](../../native/cli/src/commands/remote_action.rs) | [92](../../native/cli/src/commands/remote_action.rs#L92), [102](../../native/cli/src/commands/remote_action.rs#L102), [113](../../native/cli/src/commands/remote_action.rs#L113), [158](../../native/cli/src/commands/remote_action.rs#L158), [162](../../native/cli/src/commands/remote_action.rs#L162), [182](../../native/cli/src/commands/remote_action.rs#L182) | [9](../../native/cli/src/commands/remote_action.rs#L9), [184](../../native/cli/src/commands/remote_action.rs#L184) | — | — | — | +| [native/cli/src/commands/remote_resolution.rs](../../native/cli/src/commands/remote_resolution.rs) | [99](../../native/cli/src/commands/remote_resolution.rs#L99), [100](../../native/cli/src/commands/remote_resolution.rs#L100) | [6](../../native/cli/src/commands/remote_resolution.rs#L6), [183](../../native/cli/src/commands/remote_resolution.rs#L183) | [154](../../native/cli/src/commands/remote_resolution.rs#L154), [190](../../native/cli/src/commands/remote_resolution.rs#L190) | — | — | +| [native/cli/src/commands/remote.rs](../../native/cli/src/commands/remote.rs) | [31](../../native/cli/src/commands/remote.rs#L31), [38](../../native/cli/src/commands/remote.rs#L38), [43](../../native/cli/src/commands/remote.rs#L43), [59](../../native/cli/src/commands/remote.rs#L59) | — | — | — | — | +| [native/cli/src/commands/remove/load_free.rs](../../native/cli/src/commands/remove/load_free.rs) | [52](../../native/cli/src/commands/remove/load_free.rs#L52), [54](../../native/cli/src/commands/remove/load_free.rs#L54), [64](../../native/cli/src/commands/remove/load_free.rs#L64), [82](../../native/cli/src/commands/remove/load_free.rs#L82), [101](../../native/cli/src/commands/remove/load_free.rs#L101) | — | — | — | — | +| [native/cli/src/commands/remove.rs](../../native/cli/src/commands/remove.rs) | [21](../../native/cli/src/commands/remove.rs#L21), [24](../../native/cli/src/commands/remove.rs#L24), [31](../../native/cli/src/commands/remove.rs#L31) | — | [159](../../native/cli/src/commands/remove.rs#L159), [162](../../native/cli/src/commands/remove.rs#L162) | — | — | +| [native/cli/src/commands/rename/ast.rs](../../native/cli/src/commands/rename/ast.rs) | — | — | [53](../../native/cli/src/commands/rename/ast.rs#L53) | — | — | +| [native/cli/src/commands/rename/execution.rs](../../native/cli/src/commands/rename/execution.rs) | — | — | [131](../../native/cli/src/commands/rename/execution.rs#L131), [156](../../native/cli/src/commands/rename/execution.rs#L156), [216](../../native/cli/src/commands/rename/execution.rs#L216), [259](../../native/cli/src/commands/rename/execution.rs#L259), [278](../../native/cli/src/commands/rename/execution.rs#L278) | — | — | +| [native/cli/src/commands/rename.rs](../../native/cli/src/commands/rename.rs) | [19](../../native/cli/src/commands/rename.rs#L19), [22](../../native/cli/src/commands/rename.rs#L22), [31](../../native/cli/src/commands/rename.rs#L31), [36](../../native/cli/src/commands/rename.rs#L36), [76](../../native/cli/src/commands/rename.rs#L76) | — | — | — | — | +| [native/cli/src/commands/reset/main_ext.rs](../../native/cli/src/commands/reset/main_ext.rs) | [240](../../native/cli/src/commands/reset/main_ext.rs#L240), [364](../../native/cli/src/commands/reset/main_ext.rs#L364) | — | — | — | — | +| [native/cli/src/commands/reset/selection.rs](../../native/cli/src/commands/reset/selection.rs) | [64](../../native/cli/src/commands/reset/selection.rs#L64), [79](../../native/cli/src/commands/reset/selection.rs#L79), [165](../../native/cli/src/commands/reset/selection.rs#L165), [234](../../native/cli/src/commands/reset/selection.rs#L234) | — | — | — | — | +| [native/cli/src/commands/reset.rs](../../native/cli/src/commands/reset.rs) | [101](../../native/cli/src/commands/reset.rs#L101), [104](../../native/cli/src/commands/reset.rs#L104), [111](../../native/cli/src/commands/reset.rs#L111), [143](../../native/cli/src/commands/reset.rs#L143), [152](../../native/cli/src/commands/reset.rs#L152), [165](../../native/cli/src/commands/reset.rs#L165), [187](../../native/cli/src/commands/reset.rs#L187), [205](../../native/cli/src/commands/reset.rs#L205), [212](../../native/cli/src/commands/reset.rs#L212), [220](../../native/cli/src/commands/reset.rs#L220), [236](../../native/cli/src/commands/reset.rs#L236), [260](../../native/cli/src/commands/reset.rs#L260), [281](../../native/cli/src/commands/reset.rs#L281), [283](../../native/cli/src/commands/reset.rs#L283), [294](../../native/cli/src/commands/reset.rs#L294), [321](../../native/cli/src/commands/reset.rs#L321), [323](../../native/cli/src/commands/reset.rs#L323), [363](../../native/cli/src/commands/reset.rs#L363), [381](../../native/cli/src/commands/reset.rs#L381), [394](../../native/cli/src/commands/reset.rs#L394) | — | — | — | — | +| [native/cli/src/commands/resume_export/execute.rs](../../native/cli/src/commands/resume_export/execute.rs) | [35](../../native/cli/src/commands/resume_export/execute.rs#L35), [36](../../native/cli/src/commands/resume_export/execute.rs#L36), [41](../../native/cli/src/commands/resume_export/execute.rs#L41), [46](../../native/cli/src/commands/resume_export/execute.rs#L46) | — | — | — | — | +| [native/cli/src/commands/resume_export.rs](../../native/cli/src/commands/resume_export.rs) | [30](../../native/cli/src/commands/resume_export.rs#L30), [33](../../native/cli/src/commands/resume_export.rs#L33) | — | — | — | — | +| [native/cli/src/commands/ripple.rs](../../native/cli/src/commands/ripple.rs) | [41](../../native/cli/src/commands/ripple.rs#L41), [44](../../native/cli/src/commands/ripple.rs#L44), [93](../../native/cli/src/commands/ripple.rs#L93), [107](../../native/cli/src/commands/ripple.rs#L107), [110](../../native/cli/src/commands/ripple.rs#L110), [206](../../native/cli/src/commands/ripple.rs#L206), [209](../../native/cli/src/commands/ripple.rs#L209) | — | — | — | — | +| [native/cli/src/commands/run_action.rs](../../native/cli/src/commands/run_action.rs) | [33](../../native/cli/src/commands/run_action.rs#L33), [36](../../native/cli/src/commands/run_action.rs#L36), [47](../../native/cli/src/commands/run_action.rs#L47), [52](../../native/cli/src/commands/run_action.rs#L52), [77](../../native/cli/src/commands/run_action.rs#L77) | — | — | — | — | +| [native/cli/src/commands/run_cmd.rs](../../native/cli/src/commands/run_cmd.rs) | [42](../../native/cli/src/commands/run_cmd.rs#L42), [55](../../native/cli/src/commands/run_cmd.rs#L55), [66](../../native/cli/src/commands/run_cmd.rs#L66) | — | — | — | — | +| [native/cli/src/commands/schema_cmd/extractor.rs](../../native/cli/src/commands/schema_cmd/extractor.rs) | [35](../../native/cli/src/commands/schema_cmd/extractor.rs#L35), [40](../../native/cli/src/commands/schema_cmd/extractor.rs#L40), [43](../../native/cli/src/commands/schema_cmd/extractor.rs#L43), [49](../../native/cli/src/commands/schema_cmd/extractor.rs#L49) | — | — | — | — | +| [native/cli/src/commands/schema_cmd.rs](../../native/cli/src/commands/schema_cmd.rs) | [75](../../native/cli/src/commands/schema_cmd.rs#L75), [78](../../native/cli/src/commands/schema_cmd.rs#L78), [81](../../native/cli/src/commands/schema_cmd.rs#L81), [102](../../native/cli/src/commands/schema_cmd.rs#L102), [114](../../native/cli/src/commands/schema_cmd.rs#L114), [139](../../native/cli/src/commands/schema_cmd.rs#L139), [160](../../native/cli/src/commands/schema_cmd.rs#L160), [168](../../native/cli/src/commands/schema_cmd.rs#L168), [196](../../native/cli/src/commands/schema_cmd.rs#L196) | — | — | — | — | +| [native/cli/src/commands/scope_rename/execution/phases.rs](../../native/cli/src/commands/scope_rename/execution/phases.rs) | [61](../../native/cli/src/commands/scope_rename/execution/phases.rs#L61) | — | [33](../../native/cli/src/commands/scope_rename/execution/phases.rs#L33), [117](../../native/cli/src/commands/scope_rename/execution/phases.rs#L117), [128](../../native/cli/src/commands/scope_rename/execution/phases.rs#L128), [142](../../native/cli/src/commands/scope_rename/execution/phases.rs#L142), [247](../../native/cli/src/commands/scope_rename/execution/phases.rs#L247) | — | — | +| [native/cli/src/commands/scope_rename/execution.rs](../../native/cli/src/commands/scope_rename/execution.rs) | — | — | [32](../../native/cli/src/commands/scope_rename/execution.rs#L32), [95](../../native/cli/src/commands/scope_rename/execution.rs#L95), [123](../../native/cli/src/commands/scope_rename/execution.rs#L123) | — | — | +| [native/cli/src/commands/scope_rename.rs](../../native/cli/src/commands/scope_rename.rs) | [21](../../native/cli/src/commands/scope_rename.rs#L21), [27](../../native/cli/src/commands/scope_rename.rs#L27), [66](../../native/cli/src/commands/scope_rename.rs#L66), [119](../../native/cli/src/commands/scope_rename.rs#L119), [122](../../native/cli/src/commands/scope_rename.rs#L122), [131](../../native/cli/src/commands/scope_rename.rs#L131), [185](../../native/cli/src/commands/scope_rename.rs#L185) | — | — | — | — | +| [native/cli/src/commands/scope_set.rs](../../native/cli/src/commands/scope_set.rs) | [70](../../native/cli/src/commands/scope_set.rs#L70), [74](../../native/cli/src/commands/scope_set.rs#L74), [82](../../native/cli/src/commands/scope_set.rs#L82), [89](../../native/cli/src/commands/scope_set.rs#L89) | — | — | — | — | +| [native/cli/src/commands/scope_trust.rs](../../native/cli/src/commands/scope_trust.rs) | [60](../../native/cli/src/commands/scope_trust.rs#L60), [65](../../native/cli/src/commands/scope_trust.rs#L65), [84](../../native/cli/src/commands/scope_trust.rs#L84), [90](../../native/cli/src/commands/scope_trust.rs#L90) | — | — | — | — | +| [native/cli/src/commands/script/runner.rs](../../native/cli/src/commands/script/runner.rs) | — | — | — | [306](../../native/cli/src/commands/script/runner.rs#L306), [310](../../native/cli/src/commands/script/runner.rs#L310) | — | +| [native/cli/src/commands/script.rs](../../native/cli/src/commands/script.rs) | [43](../../native/cli/src/commands/script.rs#L43), [46](../../native/cli/src/commands/script.rs#L46), [75](../../native/cli/src/commands/script.rs#L75), [119](../../native/cli/src/commands/script.rs#L119), [125](../../native/cli/src/commands/script.rs#L125), [184](../../native/cli/src/commands/script.rs#L184), [193](../../native/cli/src/commands/script.rs#L193) | — | — | — | — | +| [native/cli/src/commands/search.rs](../../native/cli/src/commands/search.rs) | [40](../../native/cli/src/commands/search.rs#L40), [51](../../native/cli/src/commands/search.rs#L51) | — | — | — | — | +| [native/cli/src/commands/server_cmd.rs](../../native/cli/src/commands/server_cmd.rs) | [25](../../native/cli/src/commands/server_cmd.rs#L25), [28](../../native/cli/src/commands/server_cmd.rs#L28), [59](../../native/cli/src/commands/server_cmd.rs#L59) | — | — | — | — | +| [native/cli/src/commands/set_peer.rs](../../native/cli/src/commands/set_peer.rs) | [45](../../native/cli/src/commands/set_peer.rs#L45), [48](../../native/cli/src/commands/set_peer.rs#L48), [55](../../native/cli/src/commands/set_peer.rs#L55), [58](../../native/cli/src/commands/set_peer.rs#L58), [83](../../native/cli/src/commands/set_peer.rs#L83), [101](../../native/cli/src/commands/set_peer.rs#L101), [107](../../native/cli/src/commands/set_peer.rs#L107), [254](../../native/cli/src/commands/set_peer.rs#L254), [278](../../native/cli/src/commands/set_peer.rs#L278), [298](../../native/cli/src/commands/set_peer.rs#L298), [301](../../native/cli/src/commands/set_peer.rs#L301), [304](../../native/cli/src/commands/set_peer.rs#L304), [311](../../native/cli/src/commands/set_peer.rs#L311) | — | — | — | — | +| [native/cli/src/commands/show_cmd/help.rs](../../native/cli/src/commands/show_cmd/help.rs) | [18](../../native/cli/src/commands/show_cmd/help.rs#L18) | — | — | — | — | +| [native/cli/src/commands/show_cmd/remote_show.rs](../../native/cli/src/commands/show_cmd/remote_show.rs) | [38](../../native/cli/src/commands/show_cmd/remote_show.rs#L38), [41](../../native/cli/src/commands/show_cmd/remote_show.rs#L41), [44](../../native/cli/src/commands/show_cmd/remote_show.rs#L44), [48](../../native/cli/src/commands/show_cmd/remote_show.rs#L48) | — | — | — | — | +| [native/cli/src/commands/show_cmd/workspace_show.rs](../../native/cli/src/commands/show_cmd/workspace_show.rs) | [73](../../native/cli/src/commands/show_cmd/workspace_show.rs#L73), [82](../../native/cli/src/commands/show_cmd/workspace_show.rs#L82), [101](../../native/cli/src/commands/show_cmd/workspace_show.rs#L101), [140](../../native/cli/src/commands/show_cmd/workspace_show.rs#L140), [143](../../native/cli/src/commands/show_cmd/workspace_show.rs#L143), [146](../../native/cli/src/commands/show_cmd/workspace_show.rs#L146), [149](../../native/cli/src/commands/show_cmd/workspace_show.rs#L149), [152](../../native/cli/src/commands/show_cmd/workspace_show.rs#L152), [155](../../native/cli/src/commands/show_cmd/workspace_show.rs#L155), [172](../../native/cli/src/commands/show_cmd/workspace_show.rs#L172), [178](../../native/cli/src/commands/show_cmd/workspace_show.rs#L178), [200](../../native/cli/src/commands/show_cmd/workspace_show.rs#L200), [233](../../native/cli/src/commands/show_cmd/workspace_show.rs#L233), [238](../../native/cli/src/commands/show_cmd/workspace_show.rs#L238) | — | — | — | — | +| [native/cli/src/commands/show_cmd.rs](../../native/cli/src/commands/show_cmd.rs) | [57](../../native/cli/src/commands/show_cmd.rs#L57), [63](../../native/cli/src/commands/show_cmd.rs#L63), [66](../../native/cli/src/commands/show_cmd.rs#L66), [92](../../native/cli/src/commands/show_cmd.rs#L92) | — | — | — | — | +| [native/cli/src/commands/snap_distance/graph.rs](../../native/cli/src/commands/snap_distance/graph.rs) | [184](../../native/cli/src/commands/snap_distance/graph.rs#L184) | — | — | — | — | +| [native/cli/src/commands/snap_distance.rs](../../native/cli/src/commands/snap_distance.rs) | [80](../../native/cli/src/commands/snap_distance.rs#L80), [83](../../native/cli/src/commands/snap_distance.rs#L83), [92](../../native/cli/src/commands/snap_distance.rs#L92), [115](../../native/cli/src/commands/snap_distance.rs#L115), [149](../../native/cli/src/commands/snap_distance.rs#L149) | — | — | — | — | +| [native/cli/src/commands/snap.rs](../../native/cli/src/commands/snap.rs) | [30](../../native/cli/src/commands/snap.rs#L30), [35](../../native/cli/src/commands/snap.rs#L35), [38](../../native/cli/src/commands/snap.rs#L38), [41](../../native/cli/src/commands/snap.rs#L41), [71](../../native/cli/src/commands/snap.rs#L71), [105](../../native/cli/src/commands/snap.rs#L105) | — | — | — | — | +| [native/cli/src/commands/start_cmd.rs](../../native/cli/src/commands/start_cmd.rs) | [34](../../native/cli/src/commands/start_cmd.rs#L34), [37](../../native/cli/src/commands/start_cmd.rs#L37), [40](../../native/cli/src/commands/start_cmd.rs#L40), [48](../../native/cli/src/commands/start_cmd.rs#L48) | — | — | — | — | +| [native/cli/src/commands/stash.rs](../../native/cli/src/commands/stash.rs) | [29](../../native/cli/src/commands/stash.rs#L29), [32](../../native/cli/src/commands/stash.rs#L32), [51](../../native/cli/src/commands/stash.rs#L51), [101](../../native/cli/src/commands/stash.rs#L101), [137](../../native/cli/src/commands/stash.rs#L137), [160](../../native/cli/src/commands/stash.rs#L160), [169](../../native/cli/src/commands/stash.rs#L169), [172](../../native/cli/src/commands/stash.rs#L172), [214](../../native/cli/src/commands/stash.rs#L214) | — | — | — | — | +| [native/cli/src/commands/status/autosync.rs](../../native/cli/src/commands/status/autosync.rs) | [109](../../native/cli/src/commands/status/autosync.rs#L109) | — | — | [122](../../native/cli/src/commands/status/autosync.rs#L122) | — | +| [native/cli/src/commands/status/capture/callbacks.rs](../../native/cli/src/commands/status/capture/callbacks.rs) | — | — | [105](../../native/cli/src/commands/status/capture/callbacks.rs#L105) | — | — | +| [native/cli/src/commands/status/capture/commit.rs](../../native/cli/src/commands/status/capture/commit.rs) | [25](../../native/cli/src/commands/status/capture/commit.rs#L25), [40](../../native/cli/src/commands/status/capture/commit.rs#L40) | — | — | — | — | +| [native/cli/src/commands/status/capture/dependencies.rs](../../native/cli/src/commands/status/capture/dependencies.rs) | [31](../../native/cli/src/commands/status/capture/dependencies.rs#L31), [96](../../native/cli/src/commands/status/capture/dependencies.rs#L96), [132](../../native/cli/src/commands/status/capture/dependencies.rs#L132) | — | — | — | — | +| [native/cli/src/commands/status/capture/identities.rs](../../native/cli/src/commands/status/capture/identities.rs) | [85](../../native/cli/src/commands/status/capture/identities.rs#L85), [88](../../native/cli/src/commands/status/capture/identities.rs#L88) | — | — | — | — | +| [native/cli/src/commands/status/capture/missing.rs](../../native/cli/src/commands/status/capture/missing.rs) | [182](../../native/cli/src/commands/status/capture/missing.rs#L182) | — | — | — | — | +| [native/cli/src/commands/status/capture/order.rs](../../native/cli/src/commands/status/capture/order.rs) | [17](../../native/cli/src/commands/status/capture/order.rs#L17), [45](../../native/cli/src/commands/status/capture/order.rs#L45) | — | — | — | — | +| [native/cli/src/commands/status/capture/snapshot.rs](../../native/cli/src/commands/status/capture/snapshot.rs) | [17](../../native/cli/src/commands/status/capture/snapshot.rs#L17), [20](../../native/cli/src/commands/status/capture/snapshot.rs#L20), [59](../../native/cli/src/commands/status/capture/snapshot.rs#L59), [143](../../native/cli/src/commands/status/capture/snapshot.rs#L143), [165](../../native/cli/src/commands/status/capture/snapshot.rs#L165), [170](../../native/cli/src/commands/status/capture/snapshot.rs#L170), [189](../../native/cli/src/commands/status/capture/snapshot.rs#L189), [193](../../native/cli/src/commands/status/capture/snapshot.rs#L193), [205](../../native/cli/src/commands/status/capture/snapshot.rs#L205), [248](../../native/cli/src/commands/status/capture/snapshot.rs#L248) | [19](../../native/cli/src/commands/status/capture/snapshot.rs#L19), [141](../../native/cli/src/commands/status/capture/snapshot.rs#L141), [142](../../native/cli/src/commands/status/capture/snapshot.rs#L142), [164](../../native/cli/src/commands/status/capture/snapshot.rs#L164), [188](../../native/cli/src/commands/status/capture/snapshot.rs#L188) | [22](../../native/cli/src/commands/status/capture/snapshot.rs#L22), [65](../../native/cli/src/commands/status/capture/snapshot.rs#L65), [145](../../native/cli/src/commands/status/capture/snapshot.rs#L145), [147](../../native/cli/src/commands/status/capture/snapshot.rs#L147), [167](../../native/cli/src/commands/status/capture/snapshot.rs#L167), [171](../../native/cli/src/commands/status/capture/snapshot.rs#L171), [191](../../native/cli/src/commands/status/capture/snapshot.rs#L191), [194](../../native/cli/src/commands/status/capture/snapshot.rs#L194), [206](../../native/cli/src/commands/status/capture/snapshot.rs#L206), [226](../../native/cli/src/commands/status/capture/snapshot.rs#L226) | — | — | +| [native/cli/src/commands/status/capture/switch.rs](../../native/cli/src/commands/status/capture/switch.rs) | [64](../../native/cli/src/commands/status/capture/switch.rs#L64), [67](../../native/cli/src/commands/status/capture/switch.rs#L67), [88](../../native/cli/src/commands/status/capture/switch.rs#L88), [134](../../native/cli/src/commands/status/capture/switch.rs#L134), [139](../../native/cli/src/commands/status/capture/switch.rs#L139), [160](../../native/cli/src/commands/status/capture/switch.rs#L160), [197](../../native/cli/src/commands/status/capture/switch.rs#L197), [219](../../native/cli/src/commands/status/capture/switch.rs#L219) | — | — | — | — | +| [native/cli/src/commands/status/capture/target.rs](../../native/cli/src/commands/status/capture/target.rs) | [26](../../native/cli/src/commands/status/capture/target.rs#L26) | — | — | — | — | +| [native/cli/src/commands/status/capture.rs](../../native/cli/src/commands/status/capture.rs) | [166](../../native/cli/src/commands/status/capture.rs#L166), [385](../../native/cli/src/commands/status/capture.rs#L385), [386](../../native/cli/src/commands/status/capture.rs#L386) | — | — | — | — | +| [native/cli/src/commands/status/environments/callbacks.rs](../../native/cli/src/commands/status/environments/callbacks.rs) | [77](../../native/cli/src/commands/status/environments/callbacks.rs#L77), [96](../../native/cli/src/commands/status/environments/callbacks.rs#L96), [103](../../native/cli/src/commands/status/environments/callbacks.rs#L103), [114](../../native/cli/src/commands/status/environments/callbacks.rs#L114), [141](../../native/cli/src/commands/status/environments/callbacks.rs#L141), [160](../../native/cli/src/commands/status/environments/callbacks.rs#L160), [162](../../native/cli/src/commands/status/environments/callbacks.rs#L162), [176](../../native/cli/src/commands/status/environments/callbacks.rs#L176), [192](../../native/cli/src/commands/status/environments/callbacks.rs#L192), [262](../../native/cli/src/commands/status/environments/callbacks.rs#L262) | — | [30](../../native/cli/src/commands/status/environments/callbacks.rs#L30), [68](../../native/cli/src/commands/status/environments/callbacks.rs#L68), [75](../../native/cli/src/commands/status/environments/callbacks.rs#L75), [100](../../native/cli/src/commands/status/environments/callbacks.rs#L100), [125](../../native/cli/src/commands/status/environments/callbacks.rs#L125), [138](../../native/cli/src/commands/status/environments/callbacks.rs#L138), [148](../../native/cli/src/commands/status/environments/callbacks.rs#L148), [173](../../native/cli/src/commands/status/environments/callbacks.rs#L173), [320](../../native/cli/src/commands/status/environments/callbacks.rs#L320) | — | — | +| [native/cli/src/commands/status/environments/context.rs](../../native/cli/src/commands/status/environments/context.rs) | — | — | [98](../../native/cli/src/commands/status/environments/context.rs#L98), [159](../../native/cli/src/commands/status/environments/context.rs#L159), [162](../../native/cli/src/commands/status/environments/context.rs#L162), [167](../../native/cli/src/commands/status/environments/context.rs#L167) | — | — | +| [native/cli/src/commands/status/environments/dependencies.rs](../../native/cli/src/commands/status/environments/dependencies.rs) | [20](../../native/cli/src/commands/status/environments/dependencies.rs#L20), [28](../../native/cli/src/commands/status/environments/dependencies.rs#L28) | — | [24](../../native/cli/src/commands/status/environments/dependencies.rs#L24), [60](../../native/cli/src/commands/status/environments/dependencies.rs#L60) | — | — | +| [native/cli/src/commands/status/environments/diagnostics.rs](../../native/cli/src/commands/status/environments/diagnostics.rs) | [107](../../native/cli/src/commands/status/environments/diagnostics.rs#L107), [122](../../native/cli/src/commands/status/environments/diagnostics.rs#L122) | — | — | — | — | +| [native/cli/src/commands/status/environments/facts.rs](../../native/cli/src/commands/status/environments/facts.rs) | — | — | [28](../../native/cli/src/commands/status/environments/facts.rs#L28), [57](../../native/cli/src/commands/status/environments/facts.rs#L57), [122](../../native/cli/src/commands/status/environments/facts.rs#L122), [228](../../native/cli/src/commands/status/environments/facts.rs#L228) | — | — | +| [native/cli/src/commands/status/environments/modes.rs](../../native/cli/src/commands/status/environments/modes.rs) | — | — | [29](../../native/cli/src/commands/status/environments/modes.rs#L29), [42](../../native/cli/src/commands/status/environments/modes.rs#L42) | — | — | +| [native/cli/src/commands/status/environments/quick.rs](../../native/cli/src/commands/status/environments/quick.rs) | — | — | [36](../../native/cli/src/commands/status/environments/quick.rs#L36) | — | — | +| [native/cli/src/commands/status/environments/switch.rs](../../native/cli/src/commands/status/environments/switch.rs) | [20](../../native/cli/src/commands/status/environments/switch.rs#L20), [29](../../native/cli/src/commands/status/environments/switch.rs#L29), [127](../../native/cli/src/commands/status/environments/switch.rs#L127) | — | [106](../../native/cli/src/commands/status/environments/switch.rs#L106), [114](../../native/cli/src/commands/status/environments/switch.rs#L114) | — | — | +| [native/cli/src/commands/status/environments/tracking.rs](../../native/cli/src/commands/status/environments/tracking.rs) | — | — | [13](../../native/cli/src/commands/status/environments/tracking.rs#L13), [56](../../native/cli/src/commands/status/environments/tracking.rs#L56), [74](../../native/cli/src/commands/status/environments/tracking.rs#L74), [91](../../native/cli/src/commands/status/environments/tracking.rs#L91), [128](../../native/cli/src/commands/status/environments/tracking.rs#L128) | — | — | +| [native/cli/src/commands/status/environments.rs](../../native/cli/src/commands/status/environments.rs) | [84](../../native/cli/src/commands/status/environments.rs#L84), [93](../../native/cli/src/commands/status/environments.rs#L93), [183](../../native/cli/src/commands/status/environments.rs#L183), [261](../../native/cli/src/commands/status/environments.rs#L261) | [27](../../native/cli/src/commands/status/environments.rs#L27), [83](../../native/cli/src/commands/status/environments.rs#L83) | [86](../../native/cli/src/commands/status/environments.rs#L86), [111](../../native/cli/src/commands/status/environments.rs#L111) | — | — | +| [native/cli/src/commands/status/fresh/node.rs](../../native/cli/src/commands/status/fresh/node.rs) | [32](../../native/cli/src/commands/status/fresh/node.rs#L32), [61](../../native/cli/src/commands/status/fresh/node.rs#L61), [88](../../native/cli/src/commands/status/fresh/node.rs#L88), [94](../../native/cli/src/commands/status/fresh/node.rs#L94), [99](../../native/cli/src/commands/status/fresh/node.rs#L99), [102](../../native/cli/src/commands/status/fresh/node.rs#L102), [161](../../native/cli/src/commands/status/fresh/node.rs#L161) | [9](../../native/cli/src/commands/status/fresh/node.rs#L9), [24](../../native/cli/src/commands/status/fresh/node.rs#L24), [30](../../native/cli/src/commands/status/fresh/node.rs#L30) | — | [56](../../native/cli/src/commands/status/fresh/node.rs#L56) | — | +| [native/cli/src/commands/status/fresh.rs](../../native/cli/src/commands/status/fresh.rs) | [50](../../native/cli/src/commands/status/fresh.rs#L50), [54](../../native/cli/src/commands/status/fresh.rs#L54), [65](../../native/cli/src/commands/status/fresh.rs#L65), [230](../../native/cli/src/commands/status/fresh.rs#L230), [236](../../native/cli/src/commands/status/fresh.rs#L236) | — | — | — | — | +| [native/cli/src/commands/status/issues/callbacks.rs](../../native/cli/src/commands/status/issues/callbacks.rs) | [62](../../native/cli/src/commands/status/issues/callbacks.rs#L62), [66](../../native/cli/src/commands/status/issues/callbacks.rs#L66), [73](../../native/cli/src/commands/status/issues/callbacks.rs#L73) | — | — | — | — | +| [native/cli/src/commands/status/issues/deprecated.rs](../../native/cli/src/commands/status/issues/deprecated.rs) | [82](../../native/cli/src/commands/status/issues/deprecated.rs#L82), [110](../../native/cli/src/commands/status/issues/deprecated.rs#L110) | — | — | — | — | +| [native/cli/src/commands/status/issues/detected.rs](../../native/cli/src/commands/status/issues/detected.rs) | [44](../../native/cli/src/commands/status/issues/detected.rs#L44), [144](../../native/cli/src/commands/status/issues/detected.rs#L144), [148](../../native/cli/src/commands/status/issues/detected.rs#L148), [211](../../native/cli/src/commands/status/issues/detected.rs#L211), [220](../../native/cli/src/commands/status/issues/detected.rs#L220), [229](../../native/cli/src/commands/status/issues/detected.rs#L229) | — | — | — | — | +| [native/cli/src/commands/status/issues/kinds.rs](../../native/cli/src/commands/status/issues/kinds.rs) | [173](../../native/cli/src/commands/status/issues/kinds.rs#L173), [200](../../native/cli/src/commands/status/issues/kinds.rs#L200), [208](../../native/cli/src/commands/status/issues/kinds.rs#L208), [238](../../native/cli/src/commands/status/issues/kinds.rs#L238), [243](../../native/cli/src/commands/status/issues/kinds.rs#L243) | — | — | — | — | +| [native/cli/src/commands/status/issues.rs](../../native/cli/src/commands/status/issues.rs) | [60](../../native/cli/src/commands/status/issues.rs#L60), [76](../../native/cli/src/commands/status/issues.rs#L76), [82](../../native/cli/src/commands/status/issues.rs#L82), [106](../../native/cli/src/commands/status/issues.rs#L106), [180](../../native/cli/src/commands/status/issues.rs#L180), [256](../../native/cli/src/commands/status/issues.rs#L256) | — | — | — | — | +| [native/cli/src/commands/status/js.rs](../../native/cli/src/commands/status/js.rs) | [11](../../native/cli/src/commands/status/js.rs#L11), [49](../../native/cli/src/commands/status/js.rs#L49), [51](../../native/cli/src/commands/status/js.rs#L51), [60](../../native/cli/src/commands/status/js.rs#L60), [65](../../native/cli/src/commands/status/js.rs#L65), [84](../../native/cli/src/commands/status/js.rs#L84), [93](../../native/cli/src/commands/status/js.rs#L93), [117](../../native/cli/src/commands/status/js.rs#L117), [120](../../native/cli/src/commands/status/js.rs#L120) | — | — | — | — | +| [native/cli/src/commands/status/lane_refresh.rs](../../native/cli/src/commands/status/lane_refresh.rs) | [91](../../native/cli/src/commands/status/lane_refresh.rs#L91), [98](../../native/cli/src/commands/status/lane_refresh.rs#L98), [115](../../native/cli/src/commands/status/lane_refresh.rs#L115), [158](../../native/cli/src/commands/status/lane_refresh.rs#L158) | — | — | — | — | +| [native/cli/src/commands/status/policy/component.rs](../../native/cli/src/commands/status/policy/component.rs) | [40](../../native/cli/src/commands/status/policy/component.rs#L40), [194](../../native/cli/src/commands/status/policy/component.rs#L194), [196](../../native/cli/src/commands/status/policy/component.rs#L196), [208](../../native/cli/src/commands/status/policy/component.rs#L208) | — | — | — | — | +| [native/cli/src/commands/status/policy/extensions.rs](../../native/cli/src/commands/status/policy/extensions.rs) | [58](../../native/cli/src/commands/status/policy/extensions.rs#L58), [68](../../native/cli/src/commands/status/policy/extensions.rs#L68), [87](../../native/cli/src/commands/status/policy/extensions.rs#L87), [136](../../native/cli/src/commands/status/policy/extensions.rs#L136) | — | — | — | — | +| [native/cli/src/commands/status/policy/metadata.rs](../../native/cli/src/commands/status/policy/metadata.rs) | [63](../../native/cli/src/commands/status/policy/metadata.rs#L63), [68](../../native/cli/src/commands/status/policy/metadata.rs#L68), [90](../../native/cli/src/commands/status/policy/metadata.rs#L90), [125](../../native/cli/src/commands/status/policy/metadata.rs#L125) | — | — | — | — | +| [native/cli/src/commands/status/policy/model.rs](../../native/cli/src/commands/status/policy/model.rs) | [52](../../native/cli/src/commands/status/policy/model.rs#L52), [53](../../native/cli/src/commands/status/policy/model.rs#L53), [62](../../native/cli/src/commands/status/policy/model.rs#L62), [77](../../native/cli/src/commands/status/policy/model.rs#L77), [118](../../native/cli/src/commands/status/policy/model.rs#L118), [133](../../native/cli/src/commands/status/policy/model.rs#L133), [137](../../native/cli/src/commands/status/policy/model.rs#L137), [144](../../native/cli/src/commands/status/policy/model.rs#L144) | — | — | — | — | +| [native/cli/src/commands/status/policy/names.rs](../../native/cli/src/commands/status/policy/names.rs) | [127](../../native/cli/src/commands/status/policy/names.rs#L127), [135](../../native/cli/src/commands/status/policy/names.rs#L135), [151](../../native/cli/src/commands/status/policy/names.rs#L151), [158](../../native/cli/src/commands/status/policy/names.rs#L158), [179](../../native/cli/src/commands/status/policy/names.rs#L179) | — | — | — | — | +| [native/cli/src/commands/status/policy/pending.rs](../../native/cli/src/commands/status/policy/pending.rs) | [57](../../native/cli/src/commands/status/policy/pending.rs#L57), [81](../../native/cli/src/commands/status/policy/pending.rs#L81), [94](../../native/cli/src/commands/status/policy/pending.rs#L94), [98](../../native/cli/src/commands/status/policy/pending.rs#L98) | — | — | — | — | +| [native/cli/src/commands/status/policy/projection.rs](../../native/cli/src/commands/status/policy/projection.rs) | [158](../../native/cli/src/commands/status/policy/projection.rs#L158), [200](../../native/cli/src/commands/status/policy/projection.rs#L200) | — | — | — | — | +| [native/cli/src/commands/status/policy/workspace.rs](../../native/cli/src/commands/status/policy/workspace.rs) | [49](../../native/cli/src/commands/status/policy/workspace.rs#L49), [69](../../native/cli/src/commands/status/policy/workspace.rs#L69) | — | — | — | — | +| [native/cli/src/commands/status/policy.rs](../../native/cli/src/commands/status/policy.rs) | [110](../../native/cli/src/commands/status/policy.rs#L110), [125](../../native/cli/src/commands/status/policy.rs#L125), [132](../../native/cli/src/commands/status/policy.rs#L132), [154](../../native/cli/src/commands/status/policy.rs#L154), [218](../../native/cli/src/commands/status/policy.rs#L218), [335](../../native/cli/src/commands/status/policy.rs#L335) | — | — | — | — | +| [native/cli/src/commands/status/reading.rs](../../native/cli/src/commands/status/reading.rs) | [91](../../native/cli/src/commands/status/reading.rs#L91), [115](../../native/cli/src/commands/status/reading.rs#L115), [132](../../native/cli/src/commands/status/reading.rs#L132), [142](../../native/cli/src/commands/status/reading.rs#L142), [146](../../native/cli/src/commands/status/reading.rs#L146), [149](../../native/cli/src/commands/status/reading.rs#L149), [156](../../native/cli/src/commands/status/reading.rs#L156), [182](../../native/cli/src/commands/status/reading.rs#L182) | — | — | — | — | +| [native/cli/src/commands/status/report/lane_updates.rs](../../native/cli/src/commands/status/report/lane_updates.rs) | [32](../../native/cli/src/commands/status/report/lane_updates.rs#L32), [76](../../native/cli/src/commands/status/report/lane_updates.rs#L76), [85](../../native/cli/src/commands/status/report/lane_updates.rs#L85) | — | — | — | — | +| [native/cli/src/commands/status/report/render.rs](../../native/cli/src/commands/status/report/render.rs) | [22](../../native/cli/src/commands/status/report/render.rs#L22), [150](../../native/cli/src/commands/status/report/render.rs#L150) | — | — | — | — | +| [native/cli/src/commands/status/report/sections.rs](../../native/cli/src/commands/status/report/sections.rs) | [139](../../native/cli/src/commands/status/report/sections.rs#L139) | — | — | — | — | +| [native/cli/src/commands/status/report.rs](../../native/cli/src/commands/status/report.rs) | [243](../../native/cli/src/commands/status/report.rs#L243), [343](../../native/cli/src/commands/status/report.rs#L343) | — | — | — | — | +| [native/cli/src/commands/status/sorting.rs](../../native/cli/src/commands/status/sorting.rs) | [19](../../native/cli/src/commands/status/sorting.rs#L19), [58](../../native/cli/src/commands/status/sorting.rs#L58), [62](../../native/cli/src/commands/status/sorting.rs#L62), [69](../../native/cli/src/commands/status/sorting.rs#L69), [77](../../native/cli/src/commands/status/sorting.rs#L77), [87](../../native/cli/src/commands/status/sorting.rs#L87), [97](../../native/cli/src/commands/status/sorting.rs#L97), [110](../../native/cli/src/commands/status/sorting.rs#L110), [113](../../native/cli/src/commands/status/sorting.rs#L113) | [10](../../native/cli/src/commands/status/sorting.rs#L10), [57](../../native/cli/src/commands/status/sorting.rs#L57) | [60](../../native/cli/src/commands/status/sorting.rs#L60), [63](../../native/cli/src/commands/status/sorting.rs#L63) | — | — | +| [native/cli/src/commands/status.rs](../../native/cli/src/commands/status.rs) | [161](../../native/cli/src/commands/status.rs#L161), [164](../../native/cli/src/commands/status.rs#L164), [192](../../native/cli/src/commands/status.rs#L192), [220](../../native/cli/src/commands/status.rs#L220) | — | — | — | — | +| [native/cli/src/commands/switch/installation.rs](../../native/cli/src/commands/switch/installation.rs) | [65](../../native/cli/src/commands/switch/installation.rs#L65) | — | [49](../../native/cli/src/commands/switch/installation.rs#L49) | — | — | +| [native/cli/src/commands/switch/report.rs](../../native/cli/src/commands/switch/report.rs) | [23](../../native/cli/src/commands/switch/report.rs#L23), [30](../../native/cli/src/commands/switch/report.rs#L30) | — | — | — | — | +| [native/cli/src/commands/switch.rs](../../native/cli/src/commands/switch.rs) | [31](../../native/cli/src/commands/switch.rs#L31), [34](../../native/cli/src/commands/switch.rs#L34), [42](../../native/cli/src/commands/switch.rs#L42), [61](../../native/cli/src/commands/switch.rs#L61), [78](../../native/cli/src/commands/switch.rs#L78), [93](../../native/cli/src/commands/switch.rs#L93), [105](../../native/cli/src/commands/switch.rs#L105), [275](../../native/cli/src/commands/switch.rs#L275), [277](../../native/cli/src/commands/switch.rs#L277), [301](../../native/cli/src/commands/switch.rs#L301), [303](../../native/cli/src/commands/switch.rs#L303), [305](../../native/cli/src/commands/switch.rs#L305), [310](../../native/cli/src/commands/switch.rs#L310), [314](../../native/cli/src/commands/switch.rs#L314), [324](../../native/cli/src/commands/switch.rs#L324), [327](../../native/cli/src/commands/switch.rs#L327), [339](../../native/cli/src/commands/switch.rs#L339), [340](../../native/cli/src/commands/switch.rs#L340), [353](../../native/cli/src/commands/switch.rs#L353) | — | — | — | — | +| [native/cli/src/commands/system.rs](../../native/cli/src/commands/system.rs) | [32](../../native/cli/src/commands/system.rs#L32), [72](../../native/cli/src/commands/system.rs#L72) | — | — | — | — | +| [native/cli/src/commands/tag/diagnostics.rs](../../native/cli/src/commands/tag/diagnostics.rs) | [54](../../native/cli/src/commands/tag/diagnostics.rs#L54), [59](../../native/cli/src/commands/tag/diagnostics.rs#L59), [60](../../native/cli/src/commands/tag/diagnostics.rs#L60), [68](../../native/cli/src/commands/tag/diagnostics.rs#L68), [362](../../native/cli/src/commands/tag/diagnostics.rs#L362) | — | — | — | — | +| [native/cli/src/commands/tag/version_file.rs](../../native/cli/src/commands/tag/version_file.rs) | [23](../../native/cli/src/commands/tag/version_file.rs#L23), [28](../../native/cli/src/commands/tag/version_file.rs#L28), [36](../../native/cli/src/commands/tag/version_file.rs#L36), [38](../../native/cli/src/commands/tag/version_file.rs#L38), [54](../../native/cli/src/commands/tag/version_file.rs#L54), [57](../../native/cli/src/commands/tag/version_file.rs#L57), [64](../../native/cli/src/commands/tag/version_file.rs#L64) | — | — | — | — | +| [native/cli/src/commands/tag.rs](../../native/cli/src/commands/tag.rs) | [41](../../native/cli/src/commands/tag.rs#L41), [44](../../native/cli/src/commands/tag.rs#L44), [49](../../native/cli/src/commands/tag.rs#L49), [52](../../native/cli/src/commands/tag.rs#L52), [95](../../native/cli/src/commands/tag.rs#L95), [160](../../native/cli/src/commands/tag.rs#L160), [217](../../native/cli/src/commands/tag.rs#L217) | — | — | — | — | +| [native/cli/src/commands/templates.rs](../../native/cli/src/commands/templates.rs) | [47](../../native/cli/src/commands/templates.rs#L47), [50](../../native/cli/src/commands/templates.rs#L50), [53](../../native/cli/src/commands/templates.rs#L53), [72](../../native/cli/src/commands/templates.rs#L72), [94](../../native/cli/src/commands/templates.rs#L94) | — | — | — | — | +| [native/cli/src/commands/test_cmd/runner/files.rs](../../native/cli/src/commands/test_cmd/runner/files.rs) | [21](../../native/cli/src/commands/test_cmd/runner/files.rs#L21) | — | — | — | — | +| [native/cli/src/commands/test_cmd/runner/tool/stream.rs](../../native/cli/src/commands/test_cmd/runner/tool/stream.rs) | [143](../../native/cli/src/commands/test_cmd/runner/tool/stream.rs#L143) | [72](../../native/cli/src/commands/test_cmd/runner/tool/stream.rs#L72) | — | [85](../../native/cli/src/commands/test_cmd/runner/tool/stream.rs#L85) | — | +| [native/cli/src/commands/test_cmd/runner/tool.rs](../../native/cli/src/commands/test_cmd/runner/tool.rs) | [131](../../native/cli/src/commands/test_cmd/runner/tool.rs#L131) | [7](../../native/cli/src/commands/test_cmd/runner/tool.rs#L7), [83](../../native/cli/src/commands/test_cmd/runner/tool.rs#L83) | — | [95](../../native/cli/src/commands/test_cmd/runner/tool.rs#L95) | — | +| [native/cli/src/commands/test_cmd/runner.rs](../../native/cli/src/commands/test_cmd/runner.rs) | [35](../../native/cli/src/commands/test_cmd/runner.rs#L35) | — | — | — | — | +| [native/cli/src/commands/test_cmd.rs](../../native/cli/src/commands/test_cmd.rs) | [47](../../native/cli/src/commands/test_cmd.rs#L47), [50](../../native/cli/src/commands/test_cmd.rs#L50), [61](../../native/cli/src/commands/test_cmd.rs#L61), [62](../../native/cli/src/commands/test_cmd.rs#L62), [122](../../native/cli/src/commands/test_cmd.rs#L122) | — | — | — | — | +| [native/cli/src/commands/ui_build.rs](../../native/cli/src/commands/ui_build.rs) | [27](../../native/cli/src/commands/ui_build.rs#L27), [30](../../native/cli/src/commands/ui_build.rs#L30), [33](../../native/cli/src/commands/ui_build.rs#L33), [41](../../native/cli/src/commands/ui_build.rs#L41) | — | — | — | — | +| [native/cli/src/commands/uninstall.rs](../../native/cli/src/commands/uninstall.rs) | [38](../../native/cli/src/commands/uninstall.rs#L38), [41](../../native/cli/src/commands/uninstall.rs#L41), [50](../../native/cli/src/commands/uninstall.rs#L50), [60](../../native/cli/src/commands/uninstall.rs#L60), [61](../../native/cli/src/commands/uninstall.rs#L61) | [15](../../native/cli/src/commands/uninstall.rs#L15), [82](../../native/cli/src/commands/uninstall.rs#L82), [110](../../native/cli/src/commands/uninstall.rs#L110) | [47](../../native/cli/src/commands/uninstall.rs#L47) | — | — | +| [native/cli/src/commands/unuse_cmd.rs](../../native/cli/src/commands/unuse_cmd.rs) | [38](../../native/cli/src/commands/unuse_cmd.rs#L38), [41](../../native/cli/src/commands/unuse_cmd.rs#L41), [48](../../native/cli/src/commands/unuse_cmd.rs#L48), [54](../../native/cli/src/commands/unuse_cmd.rs#L54), [159](../../native/cli/src/commands/unuse_cmd.rs#L159) | — | — | — | — | +| [native/cli/src/commands/update_cmd/resolve.rs](../../native/cli/src/commands/update_cmd/resolve.rs) | — | — | — | [169](../../native/cli/src/commands/update_cmd/resolve.rs#L169) | — | +| [native/cli/src/commands/update_cmd.rs](../../native/cli/src/commands/update_cmd.rs) | [50](../../native/cli/src/commands/update_cmd.rs#L50), [53](../../native/cli/src/commands/update_cmd.rs#L53), [56](../../native/cli/src/commands/update_cmd.rs#L56), [69](../../native/cli/src/commands/update_cmd.rs#L69), [73](../../native/cli/src/commands/update_cmd.rs#L73), [82](../../native/cli/src/commands/update_cmd.rs#L82), [89](../../native/cli/src/commands/update_cmd.rs#L89) | — | — | — | — | +| [native/cli/src/commands/use_cmd.rs](../../native/cli/src/commands/use_cmd.rs) | [38](../../native/cli/src/commands/use_cmd.rs#L38), [41](../../native/cli/src/commands/use_cmd.rs#L41), [48](../../native/cli/src/commands/use_cmd.rs#L48), [54](../../native/cli/src/commands/use_cmd.rs#L54) | — | — | — | — | +| [native/cli/src/commands/validate/runner.rs](../../native/cli/src/commands/validate/runner.rs) | [37](../../native/cli/src/commands/validate/runner.rs#L37), [40](../../native/cli/src/commands/validate/runner.rs#L40), [43](../../native/cli/src/commands/validate/runner.rs#L43), [47](../../native/cli/src/commands/validate/runner.rs#L47) | — | — | — | — | +| [native/cli/src/commands/validate.rs](../../native/cli/src/commands/validate.rs) | [44](../../native/cli/src/commands/validate.rs#L44), [47](../../native/cli/src/commands/validate.rs#L47), [50](../../native/cli/src/commands/validate.rs#L50), [68](../../native/cli/src/commands/validate.rs#L68), [230](../../native/cli/src/commands/validate.rs#L230), [247](../../native/cli/src/commands/validate.rs#L247), [270](../../native/cli/src/commands/validate.rs#L270), [273](../../native/cli/src/commands/validate.rs#L273) | — | — | — | — | +| [native/cli/src/commands/version_history/build.rs](../../native/cli/src/commands/version_history/build.rs) | [57](../../native/cli/src/commands/version_history/build.rs#L57), [62](../../native/cli/src/commands/version_history/build.rs#L62) | — | — | — | — | +| [native/cli/src/commands/version_history/remote.rs](../../native/cli/src/commands/version_history/remote.rs) | [28](../../native/cli/src/commands/version_history/remote.rs#L28), [31](../../native/cli/src/commands/version_history/remote.rs#L31), [36](../../native/cli/src/commands/version_history/remote.rs#L36), [40](../../native/cli/src/commands/version_history/remote.rs#L40) | — | — | — | — | +| [native/cli/src/commands/version_history/text.rs](../../native/cli/src/commands/version_history/text.rs) | [35](../../native/cli/src/commands/version_history/text.rs#L35) | — | — | — | — | +| [native/cli/src/commands/version_history.rs](../../native/cli/src/commands/version_history.rs) | [171](../../native/cli/src/commands/version_history.rs#L171), [174](../../native/cli/src/commands/version_history.rs#L174), [203](../../native/cli/src/commands/version_history.rs#L203), [232](../../native/cli/src/commands/version_history.rs#L232), [248](../../native/cli/src/commands/version_history.rs#L248), [253](../../native/cli/src/commands/version_history.rs#L253), [259](../../native/cli/src/commands/version_history.rs#L259), [262](../../native/cli/src/commands/version_history.rs#L262) | — | — | — | — | +| [native/cli/src/commands/version.rs](../../native/cli/src/commands/version.rs) | [24](../../native/cli/src/commands/version.rs#L24), [27](../../native/cli/src/commands/version.rs#L27), [31](../../native/cli/src/commands/version.rs#L31) | [8](../../native/cli/src/commands/version.rs#L8), [26](../../native/cli/src/commands/version.rs#L26) | — | — | — | +| [native/cli/src/commands/watch_cmd.rs](../../native/cli/src/commands/watch_cmd.rs) | [36](../../native/cli/src/commands/watch_cmd.rs#L36), [39](../../native/cli/src/commands/watch_cmd.rs#L39), [42](../../native/cli/src/commands/watch_cmd.rs#L42), [52](../../native/cli/src/commands/watch_cmd.rs#L52) | — | — | — | — | +| [native/cli/src/commands/whoami.rs](../../native/cli/src/commands/whoami.rs) | [29](../../native/cli/src/commands/whoami.rs#L29), [110](../../native/cli/src/commands/whoami.rs#L110), [114](../../native/cli/src/commands/whoami.rs#L114) | — | — | — | — | +| [native/cli/src/commands/workspace_command/checkout_lifecycle.rs](../../native/cli/src/commands/workspace_command/checkout_lifecycle.rs) | [28](../../native/cli/src/commands/workspace_command/checkout_lifecycle.rs#L28) | — | — | — | — | +| [native/cli/src/commands/workspace_command/checkout_policy.rs](../../native/cli/src/commands/workspace_command/checkout_policy.rs) | [19](../../native/cli/src/commands/workspace_command/checkout_policy.rs#L19), [34](../../native/cli/src/commands/workspace_command/checkout_policy.rs#L34), [48](../../native/cli/src/commands/workspace_command/checkout_policy.rs#L48) | — | — | — | — | +| [native/cli/src/commands/workspace_command/checkout_scope.rs](../../native/cli/src/commands/workspace_command/checkout_scope.rs) | [56](../../native/cli/src/commands/workspace_command/checkout_scope.rs#L56) | [4](../../native/cli/src/commands/workspace_command/checkout_scope.rs#L4), [40](../../native/cli/src/commands/workspace_command/checkout_scope.rs#L40) | — | — | — | +| [native/cli/src/commands/workspace_command/checkout_selection.rs](../../native/cli/src/commands/workspace_command/checkout_selection.rs) | [79](../../native/cli/src/commands/workspace_command/checkout_selection.rs#L79), [137](../../native/cli/src/commands/workspace_command/checkout_selection.rs#L137), [189](../../native/cli/src/commands/workspace_command/checkout_selection.rs#L189) | — | — | — | — | +| [native/cli/src/commands/workspace_command/defaults.rs](../../native/cli/src/commands/workspace_command/defaults.rs) | [62](../../native/cli/src/commands/workspace_command/defaults.rs#L62), [68](../../native/cli/src/commands/workspace_command/defaults.rs#L68), [181](../../native/cli/src/commands/workspace_command/defaults.rs#L181), [187](../../native/cli/src/commands/workspace_command/defaults.rs#L187) | — | — | — | — | +| [native/cli/src/commands/workspace_command/execution.rs](../../native/cli/src/commands/workspace_command/execution.rs) | [96](../../native/cli/src/commands/workspace_command/execution.rs#L96) | — | — | — | — | +| [native/cli/src/commands/workspace_command/lane.rs](../../native/cli/src/commands/workspace_command/lane.rs) | [34](../../native/cli/src/commands/workspace_command/lane.rs#L34), [48](../../native/cli/src/commands/workspace_command/lane.rs#L48), [55](../../native/cli/src/commands/workspace_command/lane.rs#L55), [59](../../native/cli/src/commands/workspace_command/lane.rs#L59), [70](../../native/cli/src/commands/workspace_command/lane.rs#L70), [74](../../native/cli/src/commands/workspace_command/lane.rs#L74), [104](../../native/cli/src/commands/workspace_command/lane.rs#L104), [108](../../native/cli/src/commands/workspace_command/lane.rs#L108), [131](../../native/cli/src/commands/workspace_command/lane.rs#L131), [137](../../native/cli/src/commands/workspace_command/lane.rs#L137), [139](../../native/cli/src/commands/workspace_command/lane.rs#L139), [172](../../native/cli/src/commands/workspace_command/lane.rs#L172), [205](../../native/cli/src/commands/workspace_command/lane.rs#L205), [207](../../native/cli/src/commands/workspace_command/lane.rs#L207), [229](../../native/cli/src/commands/workspace_command/lane.rs#L229) | — | — | — | — | +| [native/cli/src/commands/workspace_command/policy.rs](../../native/cli/src/commands/workspace_command/policy.rs) | [120](../../native/cli/src/commands/workspace_command/policy.rs#L120), [134](../../native/cli/src/commands/workspace_command/policy.rs#L134), [178](../../native/cli/src/commands/workspace_command/policy.rs#L178), [201](../../native/cli/src/commands/workspace_command/policy.rs#L201), [227](../../native/cli/src/commands/workspace_command/policy.rs#L227), [232](../../native/cli/src/commands/workspace_command/policy.rs#L232), [237](../../native/cli/src/commands/workspace_command/policy.rs#L237), [242](../../native/cli/src/commands/workspace_command/policy.rs#L242), [284](../../native/cli/src/commands/workspace_command/policy.rs#L284), [321](../../native/cli/src/commands/workspace_command/policy.rs#L321), [324](../../native/cli/src/commands/workspace_command/policy.rs#L324), [330](../../native/cli/src/commands/workspace_command/policy.rs#L330), [339](../../native/cli/src/commands/workspace_command/policy.rs#L339) | — | — | — | — | +| [native/cli/src/commands/workspace_command/preparation.rs](../../native/cli/src/commands/workspace_command/preparation.rs) | [98](../../native/cli/src/commands/workspace_command/preparation.rs#L98), [101](../../native/cli/src/commands/workspace_command/preparation.rs#L101), [116](../../native/cli/src/commands/workspace_command/preparation.rs#L116), [135](../../native/cli/src/commands/workspace_command/preparation.rs#L135) | — | — | — | — | +| [native/cli/src/commands/workspace_command/progress/history.rs](../../native/cli/src/commands/workspace_command/progress/history.rs) | [18](../../native/cli/src/commands/workspace_command/progress/history.rs#L18), [25](../../native/cli/src/commands/workspace_command/progress/history.rs#L25), [28](../../native/cli/src/commands/workspace_command/progress/history.rs#L28), [39](../../native/cli/src/commands/workspace_command/progress/history.rs#L39) | — | — | — | — | +| [native/cli/src/commands/workspace_command/progress/inbound.rs](../../native/cli/src/commands/workspace_command/progress/inbound.rs) | [69](../../native/cli/src/commands/workspace_command/progress/inbound.rs#L69) | — | — | — | — | +| [native/cli/src/commands/workspace_command/progress.rs](../../native/cli/src/commands/workspace_command/progress.rs) | [61](../../native/cli/src/commands/workspace_command/progress.rs#L61), [63](../../native/cli/src/commands/workspace_command/progress.rs#L63), [244](../../native/cli/src/commands/workspace_command/progress.rs#L244), [255](../../native/cli/src/commands/workspace_command/progress.rs#L255), [264](../../native/cli/src/commands/workspace_command/progress.rs#L264), [269](../../native/cli/src/commands/workspace_command/progress.rs#L269), [274](../../native/cli/src/commands/workspace_command/progress.rs#L274), [380](../../native/cli/src/commands/workspace_command/progress.rs#L380), [385](../../native/cli/src/commands/workspace_command/progress.rs#L385), [387](../../native/cli/src/commands/workspace_command/progress.rs#L387) | — | — | — | — | +| [native/cli/src/commands/workspace_command/resolve/lifecycle/plus_ranges.rs](../../native/cli/src/commands/workspace_command/resolve/lifecycle/plus_ranges.rs) | [148](../../native/cli/src/commands/workspace_command/resolve/lifecycle/plus_ranges.rs#L148) | — | — | — | — | +| [native/cli/src/commands/workspace_command/resolve/lifecycle.rs](../../native/cli/src/commands/workspace_command/resolve/lifecycle.rs) | [259](../../native/cli/src/commands/workspace_command/resolve/lifecycle.rs#L259) | — | — | — | — | +| [native/cli/src/commands/workspace_command/resolve/parameters.rs](../../native/cli/src/commands/workspace_command/resolve/parameters.rs) | [143](../../native/cli/src/commands/workspace_command/resolve/parameters.rs#L143) | — | — | — | — | +| [native/cli/src/commands/workspace_command/resolve/published.rs](../../native/cli/src/commands/workspace_command/resolve/published.rs) | [24](../../native/cli/src/commands/workspace_command/resolve/published.rs#L24), [42](../../native/cli/src/commands/workspace_command/resolve/published.rs#L42), [90](../../native/cli/src/commands/workspace_command/resolve/published.rs#L90), [92](../../native/cli/src/commands/workspace_command/resolve/published.rs#L92), [97](../../native/cli/src/commands/workspace_command/resolve/published.rs#L97), [108](../../native/cli/src/commands/workspace_command/resolve/published.rs#L108), [131](../../native/cli/src/commands/workspace_command/resolve/published.rs#L131), [203](../../native/cli/src/commands/workspace_command/resolve/published.rs#L203), [212](../../native/cli/src/commands/workspace_command/resolve/published.rs#L212), [216](../../native/cli/src/commands/workspace_command/resolve/published.rs#L216), [217](../../native/cli/src/commands/workspace_command/resolve/published.rs#L217), [236](../../native/cli/src/commands/workspace_command/resolve/published.rs#L236) | — | — | — | — | +| [native/cli/src/commands/workspace_command/resolve/receipts.rs](../../native/cli/src/commands/workspace_command/resolve/receipts.rs) | [37](../../native/cli/src/commands/workspace_command/resolve/receipts.rs#L37), [50](../../native/cli/src/commands/workspace_command/resolve/receipts.rs#L50), [64](../../native/cli/src/commands/workspace_command/resolve/receipts.rs#L64), [75](../../native/cli/src/commands/workspace_command/resolve/receipts.rs#L75), [88](../../native/cli/src/commands/workspace_command/resolve/receipts.rs#L88), [96](../../native/cli/src/commands/workspace_command/resolve/receipts.rs#L96), [99](../../native/cli/src/commands/workspace_command/resolve/receipts.rs#L99), [110](../../native/cli/src/commands/workspace_command/resolve/receipts.rs#L110), [205](../../native/cli/src/commands/workspace_command/resolve/receipts.rs#L205), [209](../../native/cli/src/commands/workspace_command/resolve/receipts.rs#L209), [235](../../native/cli/src/commands/workspace_command/resolve/receipts.rs#L235), [238](../../native/cli/src/commands/workspace_command/resolve/receipts.rs#L238), [241](../../native/cli/src/commands/workspace_command/resolve/receipts.rs#L241), [252](../../native/cli/src/commands/workspace_command/resolve/receipts.rs#L252), [260](../../native/cli/src/commands/workspace_command/resolve/receipts.rs#L260), [284](../../native/cli/src/commands/workspace_command/resolve/receipts.rs#L284), [312](../../native/cli/src/commands/workspace_command/resolve/receipts.rs#L312), [321](../../native/cli/src/commands/workspace_command/resolve/receipts.rs#L321), [337](../../native/cli/src/commands/workspace_command/resolve/receipts.rs#L337), [340](../../native/cli/src/commands/workspace_command/resolve/receipts.rs#L340), [364](../../native/cli/src/commands/workspace_command/resolve/receipts.rs#L364), [372](../../native/cli/src/commands/workspace_command/resolve/receipts.rs#L372), [375](../../native/cli/src/commands/workspace_command/resolve/receipts.rs#L375), [389](../../native/cli/src/commands/workspace_command/resolve/receipts.rs#L389) | — | — | — | — | +| [native/cli/src/commands/workspace_command/resolve/tag.rs](../../native/cli/src/commands/workspace_command/resolve/tag.rs) | [26](../../native/cli/src/commands/workspace_command/resolve/tag.rs#L26), [37](../../native/cli/src/commands/workspace_command/resolve/tag.rs#L37), [43](../../native/cli/src/commands/workspace_command/resolve/tag.rs#L43), [91](../../native/cli/src/commands/workspace_command/resolve/tag.rs#L91), [94](../../native/cli/src/commands/workspace_command/resolve/tag.rs#L94), [112](../../native/cli/src/commands/workspace_command/resolve/tag.rs#L112), [118](../../native/cli/src/commands/workspace_command/resolve/tag.rs#L118), [132](../../native/cli/src/commands/workspace_command/resolve/tag.rs#L132), [145](../../native/cli/src/commands/workspace_command/resolve/tag.rs#L145), [191](../../native/cli/src/commands/workspace_command/resolve/tag.rs#L191), [200](../../native/cli/src/commands/workspace_command/resolve/tag.rs#L200) | — | — | — | — | +| [native/cli/src/commands/workspace_command/resolve.rs](../../native/cli/src/commands/workspace_command/resolve.rs) | [112](../../native/cli/src/commands/workspace_command/resolve.rs#L112), [142](../../native/cli/src/commands/workspace_command/resolve.rs#L142), [248](../../native/cli/src/commands/workspace_command/resolve.rs#L248), [269](../../native/cli/src/commands/workspace_command/resolve.rs#L269), [291](../../native/cli/src/commands/workspace_command/resolve.rs#L291) | — | — | — | — | +| [native/cli/src/commands/workspace_command/results/snaps.rs](../../native/cli/src/commands/workspace_command/results/snaps.rs) | [47](../../native/cli/src/commands/workspace_command/results/snaps.rs#L47), [55](../../native/cli/src/commands/workspace_command/results/snaps.rs#L55), [60](../../native/cli/src/commands/workspace_command/results/snaps.rs#L60), [66](../../native/cli/src/commands/workspace_command/results/snaps.rs#L66), [88](../../native/cli/src/commands/workspace_command/results/snaps.rs#L88), [94](../../native/cli/src/commands/workspace_command/results/snaps.rs#L94), [100](../../native/cli/src/commands/workspace_command/results/snaps.rs#L100), [111](../../native/cli/src/commands/workspace_command/results/snaps.rs#L111), [117](../../native/cli/src/commands/workspace_command/results/snaps.rs#L117), [131](../../native/cli/src/commands/workspace_command/results/snaps.rs#L131), [134](../../native/cli/src/commands/workspace_command/results/snaps.rs#L134), [140](../../native/cli/src/commands/workspace_command/results/snaps.rs#L140) | — | — | — | — | +| [native/cli/src/commands/workspace_command/results.rs](../../native/cli/src/commands/workspace_command/results.rs) | [83](../../native/cli/src/commands/workspace_command/results.rs#L83), [143](../../native/cli/src/commands/workspace_command/results.rs#L143), [155](../../native/cli/src/commands/workspace_command/results.rs#L155), [160](../../native/cli/src/commands/workspace_command/results.rs#L160), [184](../../native/cli/src/commands/workspace_command/results.rs#L184), [203](../../native/cli/src/commands/workspace_command/results.rs#L203), [206](../../native/cli/src/commands/workspace_command/results.rs#L206), [212](../../native/cli/src/commands/workspace_command/results.rs#L212), [235](../../native/cli/src/commands/workspace_command/results.rs#L235), [237](../../native/cli/src/commands/workspace_command/results.rs#L237) | — | — | — | — | +| [native/cli/src/commands/workspace_command/snapshot/graph/convert.rs](../../native/cli/src/commands/workspace_command/snapshot/graph/convert.rs) | [102](../../native/cli/src/commands/workspace_command/snapshot/graph/convert.rs#L102), [107](../../native/cli/src/commands/workspace_command/snapshot/graph/convert.rs#L107), [164](../../native/cli/src/commands/workspace_command/snapshot/graph/convert.rs#L164), [288](../../native/cli/src/commands/workspace_command/snapshot/graph/convert.rs#L288), [392](../../native/cli/src/commands/workspace_command/snapshot/graph/convert.rs#L392) | — | — | — | — | +| [native/cli/src/commands/workspace_command/snapshot/graph/reachable.rs](../../native/cli/src/commands/workspace_command/snapshot/graph/reachable.rs) | [21](../../native/cli/src/commands/workspace_command/snapshot/graph/reachable.rs#L21) | — | — | — | — | +| [native/cli/src/commands/workspace_command/snapshot/graph/replacer.rs](../../native/cli/src/commands/workspace_command/snapshot/graph/replacer.rs) | [49](../../native/cli/src/commands/workspace_command/snapshot/graph/replacer.rs#L49), [60](../../native/cli/src/commands/workspace_command/snapshot/graph/replacer.rs#L60), [68](../../native/cli/src/commands/workspace_command/snapshot/graph/replacer.rs#L68) | — | — | — | — | +| [native/cli/src/commands/workspace_command/snapshot/graph.rs](../../native/cli/src/commands/workspace_command/snapshot/graph.rs) | [43](../../native/cli/src/commands/workspace_command/snapshot/graph.rs#L43), [96](../../native/cli/src/commands/workspace_command/snapshot/graph.rs#L96), [106](../../native/cli/src/commands/workspace_command/snapshot/graph.rs#L106), [172](../../native/cli/src/commands/workspace_command/snapshot/graph.rs#L172), [185](../../native/cli/src/commands/workspace_command/snapshot/graph.rs#L185), [187](../../native/cli/src/commands/workspace_command/snapshot/graph.rs#L187), [301](../../native/cli/src/commands/workspace_command/snapshot/graph.rs#L301), [305](../../native/cli/src/commands/workspace_command/snapshot/graph.rs#L305) | [50](../../native/cli/src/commands/workspace_command/snapshot/graph.rs#L50) | — | — | — | +| [native/cli/src/commands/workspace_command/snapshot/issues.rs](../../native/cli/src/commands/workspace_command/snapshot/issues.rs) | [28](../../native/cli/src/commands/workspace_command/snapshot/issues.rs#L28), [32](../../native/cli/src/commands/workspace_command/snapshot/issues.rs#L32), [44](../../native/cli/src/commands/workspace_command/snapshot/issues.rs#L44), [120](../../native/cli/src/commands/workspace_command/snapshot/issues.rs#L120) | — | — | — | — | +| [native/cli/src/commands/workspace_command/snapshot/lockfile.rs](../../native/cli/src/commands/workspace_command/snapshot/lockfile.rs) | [101](../../native/cli/src/commands/workspace_command/snapshot/lockfile.rs#L101), [137](../../native/cli/src/commands/workspace_command/snapshot/lockfile.rs#L137), [163](../../native/cli/src/commands/workspace_command/snapshot/lockfile.rs#L163) | — | — | — | — | +| [native/cli/src/commands/workspace_command/snapshot/receipt.rs](../../native/cli/src/commands/workspace_command/snapshot/receipt.rs) | [239](../../native/cli/src/commands/workspace_command/snapshot/receipt.rs#L239), [241](../../native/cli/src/commands/workspace_command/snapshot/receipt.rs#L241), [250](../../native/cli/src/commands/workspace_command/snapshot/receipt.rs#L250), [251](../../native/cli/src/commands/workspace_command/snapshot/receipt.rs#L251) | [11](../../native/cli/src/commands/workspace_command/snapshot/receipt.rs#L11), [230](../../native/cli/src/commands/workspace_command/snapshot/receipt.rs#L230), [236](../../native/cli/src/commands/workspace_command/snapshot/receipt.rs#L236), [238](../../native/cli/src/commands/workspace_command/snapshot/receipt.rs#L238), [286](../../native/cli/src/commands/workspace_command/snapshot/receipt.rs#L286) | — | [168](../../native/cli/src/commands/workspace_command/snapshot/receipt.rs#L168) | — | +| [native/cli/src/commands/workspace_command/snapshot.rs](../../native/cli/src/commands/workspace_command/snapshot.rs) | [40](../../native/cli/src/commands/workspace_command/snapshot.rs#L40), [48](../../native/cli/src/commands/workspace_command/snapshot.rs#L48), [78](../../native/cli/src/commands/workspace_command/snapshot.rs#L78), [81](../../native/cli/src/commands/workspace_command/snapshot.rs#L81), [108](../../native/cli/src/commands/workspace_command/snapshot.rs#L108), [162](../../native/cli/src/commands/workspace_command/snapshot.rs#L162), [203](../../native/cli/src/commands/workspace_command/snapshot.rs#L203), [217](../../native/cli/src/commands/workspace_command/snapshot.rs#L217), [230](../../native/cli/src/commands/workspace_command/snapshot.rs#L230), [237](../../native/cli/src/commands/workspace_command/snapshot.rs#L237), [242](../../native/cli/src/commands/workspace_command/snapshot.rs#L242), [257](../../native/cli/src/commands/workspace_command/snapshot.rs#L257), [278](../../native/cli/src/commands/workspace_command/snapshot.rs#L278) | — | — | — | — | +| [native/cli/src/commands/workspace_command/stash.rs](../../native/cli/src/commands/workspace_command/stash.rs) | [42](../../native/cli/src/commands/workspace_command/stash.rs#L42), [91](../../native/cli/src/commands/workspace_command/stash.rs#L91), [103](../../native/cli/src/commands/workspace_command/stash.rs#L103), [137](../../native/cli/src/commands/workspace_command/stash.rs#L137) | [215](../../native/cli/src/commands/workspace_command/stash.rs#L215) | — | — | — | +| [native/cli/src/commands/workspace_command/versions.rs](../../native/cli/src/commands/workspace_command/versions.rs) | [60](../../native/cli/src/commands/workspace_command/versions.rs#L60), [99](../../native/cli/src/commands/workspace_command/versions.rs#L99), [101](../../native/cli/src/commands/workspace_command/versions.rs#L101), [108](../../native/cli/src/commands/workspace_command/versions.rs#L108), [127](../../native/cli/src/commands/workspace_command/versions.rs#L127), [133](../../native/cli/src/commands/workspace_command/versions.rs#L133), [145](../../native/cli/src/commands/workspace_command/versions.rs#L145), [172](../../native/cli/src/commands/workspace_command/versions.rs#L172), [174](../../native/cli/src/commands/workspace_command/versions.rs#L174), [182](../../native/cli/src/commands/workspace_command/versions.rs#L182), [191](../../native/cli/src/commands/workspace_command/versions.rs#L191), [198](../../native/cli/src/commands/workspace_command/versions.rs#L198), [225](../../native/cli/src/commands/workspace_command/versions.rs#L225), [230](../../native/cli/src/commands/workspace_command/versions.rs#L230) | — | — | — | — | +| [native/cli/src/commands/workspace_command.rs](../../native/cli/src/commands/workspace_command.rs) | [259](../../native/cli/src/commands/workspace_command.rs#L259), [289](../../native/cli/src/commands/workspace_command.rs#L289), [328](../../native/cli/src/commands/workspace_command.rs#L328) | [29](../../native/cli/src/commands/workspace_command.rs#L29) | — | — | — | +| [native/cli/src/commands/ws_config.rs](../../native/cli/src/commands/ws_config.rs) | [81](../../native/cli/src/commands/ws_config.rs#L81), [84](../../native/cli/src/commands/ws_config.rs#L84), [130](../../native/cli/src/commands/ws_config.rs#L130), [137](../../native/cli/src/commands/ws_config.rs#L137), [140](../../native/cli/src/commands/ws_config.rs#L140), [155](../../native/cli/src/commands/ws_config.rs#L155), [162](../../native/cli/src/commands/ws_config.rs#L162), [165](../../native/cli/src/commands/ws_config.rs#L165), [168](../../native/cli/src/commands/ws_config.rs#L168), [185](../../native/cli/src/commands/ws_config.rs#L185), [192](../../native/cli/src/commands/ws_config.rs#L192), [195](../../native/cli/src/commands/ws_config.rs#L195) | — | — | — | — | +| [native/cli/src/delegate.rs](../../native/cli/src/delegate.rs) | — | [13](../../native/cli/src/delegate.rs#L13), [49](../../native/cli/src/delegate.rs#L49) | — | [41](../../native/cli/src/delegate.rs#L41), [50](../../native/cli/src/delegate.rs#L50) | — | +| [native/cli/src/discovery.rs](../../native/cli/src/discovery.rs) | — | [31](../../native/cli/src/discovery.rs#L31), [38](../../native/cli/src/discovery.rs#L38), [130](../../native/cli/src/discovery.rs#L130), [136](../../native/cli/src/discovery.rs#L136), [145](../../native/cli/src/discovery.rs#L145), [163](../../native/cli/src/discovery.rs#L163), [209](../../native/cli/src/discovery.rs#L209), [215](../../native/cli/src/discovery.rs#L215) | — | — | — | +| [native/cli/src/host/cat.js](../../native/cli/src/host/cat.js) | — | — | — | — | [5](../../native/cli/src/host/cat.js#L5), [24](../../native/cli/src/host/cat.js#L24), [36](../../native/cli/src/host/cat.js#L36), [37](../../native/cli/src/host/cat.js#L37), [38](../../native/cli/src/host/cat.js#L38), [39](../../native/cli/src/host/cat.js#L39) | +| [native/cli/src/host/export.js](../../native/cli/src/host/export.js) | — | — | — | — | [9](../../native/cli/src/host/export.js#L9), [18](../../native/cli/src/host/export.js#L18), [24](../../native/cli/src/host/export.js#L24), [31](../../native/cli/src/host/export.js#L31), [33](../../native/cli/src/host/export.js#L33), [38](../../native/cli/src/host/export.js#L38), [42](../../native/cli/src/host/export.js#L42), [43](../../native/cli/src/host/export.js#L43), [50](../../native/cli/src/host/export.js#L50), [63](../../native/cli/src/host/export.js#L63), [78](../../native/cli/src/host/export.js#L78) | +| [native/cli/src/host/fork.js](../../native/cli/src/host/fork.js) | — | — | [136](../../native/cli/src/host/fork.js#L136) | — | [4](../../native/cli/src/host/fork.js#L4), [7](../../native/cli/src/host/fork.js#L7), [8](../../native/cli/src/host/fork.js#L8), [24](../../native/cli/src/host/fork.js#L24), [61](../../native/cli/src/host/fork.js#L61), [72](../../native/cli/src/host/fork.js#L72), [78](../../native/cli/src/host/fork.js#L78), [90](../../native/cli/src/host/fork.js#L90), [93](../../native/cli/src/host/fork.js#L93), [100](../../native/cli/src/host/fork.js#L100), [122](../../native/cli/src/host/fork.js#L122), [133](../../native/cli/src/host/fork.js#L133) | +| [native/cli/src/host/host.js](../../native/cli/src/host/host.js) | — | — | [483](../../native/cli/src/host/host.js#L483), [873](../../native/cli/src/host/host.js#L873) | — | [62](../../native/cli/src/host/host.js#L62), [66](../../native/cli/src/host/host.js#L66), [131](../../native/cli/src/host/host.js#L131), [158](../../native/cli/src/host/host.js#L158), [162](../../native/cli/src/host/host.js#L162), [174](../../native/cli/src/host/host.js#L174), [212](../../native/cli/src/host/host.js#L212), [225](../../native/cli/src/host/host.js#L225), [226](../../native/cli/src/host/host.js#L226), [227](../../native/cli/src/host/host.js#L227), [239](../../native/cli/src/host/host.js#L239), [240](../../native/cli/src/host/host.js#L240), [241](../../native/cli/src/host/host.js#L241), [260](../../native/cli/src/host/host.js#L260), [273](../../native/cli/src/host/host.js#L273), [274](../../native/cli/src/host/host.js#L274), [285](../../native/cli/src/host/host.js#L285), [286](../../native/cli/src/host/host.js#L286), [287](../../native/cli/src/host/host.js#L287), [327](../../native/cli/src/host/host.js#L327), [352](../../native/cli/src/host/host.js#L352), [376](../../native/cli/src/host/host.js#L376), [377](../../native/cli/src/host/host.js#L377), [378](../../native/cli/src/host/host.js#L378), [444](../../native/cli/src/host/host.js#L444), [482](../../native/cli/src/host/host.js#L482), [552](../../native/cli/src/host/host.js#L552), [569](../../native/cli/src/host/host.js#L569), [590](../../native/cli/src/host/host.js#L590), [614](../../native/cli/src/host/host.js#L614), [717](../../native/cli/src/host/host.js#L717), [730](../../native/cli/src/host/host.js#L730), [748](../../native/cli/src/host/host.js#L748), [752](../../native/cli/src/host/host.js#L752), [778](../../native/cli/src/host/host.js#L778), [865](../../native/cli/src/host/host.js#L865), [872](../../native/cli/src/host/host.js#L872), [883](../../native/cli/src/host/host.js#L883), [892](../../native/cli/src/host/host.js#L892), [906](../../native/cli/src/host/host.js#L906), [909](../../native/cli/src/host/host.js#L909), [927](../../native/cli/src/host/host.js#L927), [1026](../../native/cli/src/host/host.js#L1026), [1036](../../native/cli/src/host/host.js#L1036), [1058](../../native/cli/src/host/host.js#L1058), [1080](../../native/cli/src/host/host.js#L1080), [1081](../../native/cli/src/host/host.js#L1081), [1082](../../native/cli/src/host/host.js#L1082), [1083](../../native/cli/src/host/host.js#L1083), [1084](../../native/cli/src/host/host.js#L1084), [1085](../../native/cli/src/host/host.js#L1085), [1116](../../native/cli/src/host/host.js#L1116), [1131](../../native/cli/src/host/host.js#L1131), [1274](../../native/cli/src/host/host.js#L1274), [1275](../../native/cli/src/host/host.js#L1275), [1289](../../native/cli/src/host/host.js#L1289), [1290](../../native/cli/src/host/host.js#L1290) | +| [native/cli/src/host/import.js](../../native/cli/src/host/import.js) | — | — | [83](../../native/cli/src/host/import.js#L83) | — | [3](../../native/cli/src/host/import.js#L3), [52](../../native/cli/src/host/import.js#L52), [128](../../native/cli/src/host/import.js#L128), [129](../../native/cli/src/host/import.js#L129), [145](../../native/cli/src/host/import.js#L145), [162](../../native/cli/src/host/import.js#L162) | +| [native/cli/src/host/install_engine.js](../../native/cli/src/host/install_engine.js) | — | — | — | — | [13](../../native/cli/src/host/install_engine.js#L13), [14](../../native/cli/src/host/install_engine.js#L14), [15](../../native/cli/src/host/install_engine.js#L15), [16](../../native/cli/src/host/install_engine.js#L16), [17](../../native/cli/src/host/install_engine.js#L17), [18](../../native/cli/src/host/install_engine.js#L18), [19](../../native/cli/src/host/install_engine.js#L19), [155](../../native/cli/src/host/install_engine.js#L155), [307](../../native/cli/src/host/install_engine.js#L307) | +| [native/cli/src/host/install.js](../../native/cli/src/host/install.js) | — | — | — | — | [8](../../native/cli/src/host/install.js#L8), [16](../../native/cli/src/host/install.js#L16), [30](../../native/cli/src/host/install.js#L30), [63](../../native/cli/src/host/install.js#L63), [71](../../native/cli/src/host/install.js#L71), [90](../../native/cli/src/host/install.js#L90), [99](../../native/cli/src/host/install.js#L99), [105](../../native/cli/src/host/install.js#L105), [114](../../native/cli/src/host/install.js#L114), [130](../../native/cli/src/host/install.js#L130) | +| [native/cli/src/host/inventory.js](../../native/cli/src/host/inventory.js) | — | — | — | — | [5](../../native/cli/src/host/inventory.js#L5), [7](../../native/cli/src/host/inventory.js#L7), [12](../../native/cli/src/host/inventory.js#L12), [16](../../native/cli/src/host/inventory.js#L16) | +| [native/cli/src/host/log_file.js](../../native/cli/src/host/log_file.js) | — | — | — | — | [10](../../native/cli/src/host/log_file.js#L10) | +| [native/cli/src/host/rename/ast.js](../../native/cli/src/host/rename/ast.js) | — | — | — | — | [3](../../native/cli/src/host/rename/ast.js#L3), [4](../../native/cli/src/host/rename/ast.js#L4) | +| [native/cli/src/host/rename/cache.js](../../native/cli/src/host/rename/cache.js) | — | — | — | — | [6](../../native/cli/src/host/rename/cache.js#L6), [38](../../native/cli/src/host/rename/cache.js#L38), [66](../../native/cli/src/host/rename/cache.js#L66) | +| [native/cli/src/host/rename/config.js](../../native/cli/src/host/rename/config.js) | — | — | — | — | [3](../../native/cli/src/host/rename/config.js#L3) | +| [native/cli/src/host/rename/tooling.js](../../native/cli/src/host/rename/tooling.js) | — | — | — | — | [6](../../native/cli/src/host/rename/tooling.js#L6), [18](../../native/cli/src/host/rename/tooling.js#L18) | +| [native/cli/src/host/snapshot.js](../../native/cli/src/host/snapshot.js) | — | — | — | — | [3](../../native/cli/src/host/snapshot.js#L3), [4](../../native/cli/src/host/snapshot.js#L4), [40](../../native/cli/src/host/snapshot.js#L40), [41](../../native/cli/src/host/snapshot.js#L41), [42](../../native/cli/src/host/snapshot.js#L42), [63](../../native/cli/src/host/snapshot.js#L63), [67](../../native/cli/src/host/snapshot.js#L67), [68](../../native/cli/src/host/snapshot.js#L68), [77](../../native/cli/src/host/snapshot.js#L77), [133](../../native/cli/src/host/snapshot.js#L133), [139](../../native/cli/src/host/snapshot.js#L139), [143](../../native/cli/src/host/snapshot.js#L143), [150](../../native/cli/src/host/snapshot.js#L150), [151](../../native/cli/src/host/snapshot.js#L151), [152](../../native/cli/src/host/snapshot.js#L152), [153](../../native/cli/src/host/snapshot.js#L153), [188](../../native/cli/src/host/snapshot.js#L188), [189](../../native/cli/src/host/snapshot.js#L189), [190](../../native/cli/src/host/snapshot.js#L190), [194](../../native/cli/src/host/snapshot.js#L194) | +| [native/cli/src/host/switch.js](../../native/cli/src/host/switch.js) | — | — | [87](../../native/cli/src/host/switch.js#L87) | — | [8](../../native/cli/src/host/switch.js#L8), [16](../../native/cli/src/host/switch.js#L16), [20](../../native/cli/src/host/switch.js#L20), [183](../../native/cli/src/host/switch.js#L183), [186](../../native/cli/src/host/switch.js#L186), [209](../../native/cli/src/host/switch.js#L209), [227](../../native/cli/src/host/switch.js#L227), [242](../../native/cli/src/host/switch.js#L242), [280](../../native/cli/src/host/switch.js#L280), [295](../../native/cli/src/host/switch.js#L295), [309](../../native/cli/src/host/switch.js#L309), [310](../../native/cli/src/host/switch.js#L310), [311](../../native/cli/src/host/switch.js#L311), [398](../../native/cli/src/host/switch.js#L398) | +| [native/cli/src/host.rs](../../native/cli/src/host.rs) | — | [14](../../native/cli/src/host.rs#L14), [65](../../native/cli/src/host.rs#L65), [94](../../native/cli/src/host.rs#L94) | [112](../../native/cli/src/host.rs#L112) | [163](../../native/cli/src/host.rs#L163) | — | +| [native/cli/src/main.rs](../../native/cli/src/main.rs) | — | [51](../../native/cli/src/main.rs#L51) | — | — | — | +| [native/cli/src/native.rs](../../native/cli/src/native.rs) | [40](../../native/cli/src/native.rs#L40), [47](../../native/cli/src/native.rs#L47), [50](../../native/cli/src/native.rs#L50), [55](../../native/cli/src/native.rs#L55), [58](../../native/cli/src/native.rs#L58) | — | — | — | — | +| [native/client-transfer/src/resolver.rs](../../native/client-transfer/src/resolver.rs) | — | — | — | [110](../../native/client-transfer/src/resolver.rs#L110) | — | +| [native/dependency-scanner/src/embedded.rs](../../native/dependency-scanner/src/embedded.rs) | [59](../../native/dependency-scanner/src/embedded.rs#L59), [65](../../native/dependency-scanner/src/embedded.rs#L65) | — | — | — | — | +| [native/ignore/src/compile.rs](../../native/ignore/src/compile.rs) | [87](../../native/ignore/src/compile.rs#L87) | — | — | — | — | +| [native/install/src/command/modules/compile_stamp.rs](../../native/install/src/command/modules/compile_stamp.rs) | [81](../../native/install/src/command/modules/compile_stamp.rs#L81), [83](../../native/install/src/command/modules/compile_stamp.rs#L83), [85](../../native/install/src/command/modules/compile_stamp.rs#L85), [86](../../native/install/src/command/modules/compile_stamp.rs#L86), [134](../../native/install/src/command/modules/compile_stamp.rs#L134), [135](../../native/install/src/command/modules/compile_stamp.rs#L135) | — | — | — | — | +| [native/install/src/command/restore.rs](../../native/install/src/command/restore.rs) | [37](../../native/install/src/command/restore.rs#L37), [41](../../native/install/src/command/restore.rs#L41), [45](../../native/install/src/command/restore.rs#L45), [60](../../native/install/src/command/restore.rs#L60), [91](../../native/install/src/command/restore.rs#L91), [93](../../native/install/src/command/restore.rs#L93), [110](../../native/install/src/command/restore.rs#L110), [115](../../native/install/src/command/restore.rs#L115), [122](../../native/install/src/command/restore.rs#L122), [124](../../native/install/src/command/restore.rs#L124), [125](../../native/install/src/command/restore.rs#L125), [129](../../native/install/src/command/restore.rs#L129), [134](../../native/install/src/command/restore.rs#L134) | — | — | — | — | +| [native/install/src/config/global.rs](../../native/install/src/config/global.rs) | [158](../../native/install/src/config/global.rs#L158), [172](../../native/install/src/config/global.rs#L172) | — | — | — | — | +| [native/install/src/config/merge_config.rs](../../native/install/src/config/merge_config.rs) | [92](../../native/install/src/config/merge_config.rs#L92), [96](../../native/install/src/config/merge_config.rs#L96), [128](../../native/install/src/config/merge_config.rs#L128), [135](../../native/install/src/config/merge_config.rs#L135), [144](../../native/install/src/config/merge_config.rs#L144) | — | — | — | — | +| [native/install/src/dependencies/apply_late.rs](../../native/install/src/dependencies/apply_late.rs) | [241](../../native/install/src/dependencies/apply_late.rs#L241), [250](../../native/install/src/dependencies/apply_late.rs#L250) | — | — | — | — | +| [native/install/src/dependencies/cache/invalidation.rs](../../native/install/src/dependencies/cache/invalidation.rs) | [78](../../native/install/src/dependencies/cache/invalidation.rs#L78) | — | — | — | — | +| [native/install/src/dependencies/cache.rs](../../native/install/src/dependencies/cache.rs) | [39](../../native/install/src/dependencies/cache.rs#L39), [41](../../native/install/src/dependencies/cache.rs#L41), [50](../../native/install/src/dependencies/cache.rs#L50), [60](../../native/install/src/dependencies/cache.rs#L60) | — | — | — | — | +| [native/install/src/dependencies/resolve/manifests.rs](../../native/install/src/dependencies/resolve/manifests.rs) | [28](../../native/install/src/dependencies/resolve/manifests.rs#L28), [39](../../native/install/src/dependencies/resolve/manifests.rs#L39) | — | — | — | — | +| [native/install/src/dependencies/scan.rs](../../native/install/src/dependencies/scan.rs) | [47](../../native/install/src/dependencies/scan.rs#L47), [49](../../native/install/src/dependencies/scan.rs#L49) | — | — | — | — | +| [native/install/src/dependencies.rs](../../native/install/src/dependencies.rs) | [279](../../native/install/src/dependencies.rs#L279), [285](../../native/install/src/dependencies.rs#L285), [293](../../native/install/src/dependencies.rs#L293) | — | — | — | — | +| [native/install/src/engine/canonical/environment.rs](../../native/install/src/engine/canonical/environment.rs) | — | — | [143](../../native/install/src/engine/canonical/environment.rs#L143), [161](../../native/install/src/engine/canonical/environment.rs#L161) | — | — | +| [native/install/src/engine/hooks.rs](../../native/install/src/engine/hooks.rs) | [199](../../native/install/src/engine/hooks.rs#L199) | — | — | — | — | +| [native/install/src/engine/lockfile_attrs.rs](../../native/install/src/engine/lockfile_attrs.rs) | [79](../../native/install/src/engine/lockfile_attrs.rs#L79), [84](../../native/install/src/engine/lockfile_attrs.rs#L84) | — | — | — | — | +| [native/install/src/engine/network.rs](../../native/install/src/engine/network.rs) | [301](../../native/install/src/engine/network.rs#L301) | — | — | — | — | +| [native/install/src/engine/reporter/options.rs](../../native/install/src/engine/reporter/options.rs) | [12](../../native/install/src/engine/reporter/options.rs#L12) | — | — | — | — | +| [native/install/src/engine/restore/context/oracle.rs](../../native/install/src/engine/restore/context/oracle.rs) | — | — | — | [36](../../native/install/src/engine/restore/context/oracle.rs#L36) | — | +| [native/install/src/engine/restore.rs](../../native/install/src/engine/restore.rs) | [138](../../native/install/src/engine/restore.rs#L138), [296](../../native/install/src/engine/restore.rs#L296), [300](../../native/install/src/engine/restore.rs#L300) | — | — | — | — | +| [native/install/src/host.rs](../../native/install/src/host.rs) | [145](../../native/install/src/host.rs#L145), [161](../../native/install/src/host.rs#L161), [173](../../native/install/src/host.rs#L173), [209](../../native/install/src/host.rs#L209), [233](../../native/install/src/host.rs#L233), [247](../../native/install/src/host.rs#L247) | — | — | — | — | +| [native/install/src/import/fs_remote.rs](../../native/install/src/import/fs_remote.rs) | [136](../../native/install/src/import/fs_remote.rs#L136), [323](../../native/install/src/import/fs_remote.rs#L323), [359](../../native/install/src/import/fs_remote.rs#L359) | — | — | — | — | +| [native/install/src/import/lane/merge.rs](../../native/install/src/import/lane/merge.rs) | [70](../../native/install/src/import/lane/merge.rs#L70) | — | — | — | — | +| [native/install/src/import/lane.rs](../../native/install/src/import/lane.rs) | [118](../../native/install/src/import/lane.rs#L118), [123](../../native/install/src/import/lane.rs#L123), [138](../../native/install/src/import/lane.rs#L138) | — | — | — | — | +| [native/install/src/import/objects.rs](../../native/install/src/import/objects.rs) | [144](../../native/install/src/import/objects.rs#L144) | — | — | — | — | +| [native/install/src/import/plan.rs](../../native/install/src/import/plan.rs) | [120](../../native/install/src/import/plan.rs#L120), [169](../../native/install/src/import/plan.rs#L169), [174](../../native/install/src/import/plan.rs#L174) | — | — | — | — | +| [native/install/src/links/root.rs](../../native/install/src/links/root.rs) | [137](../../native/install/src/links/root.rs#L137), [167](../../native/install/src/links/root.rs#L167), [249](../../native/install/src/links/root.rs#L249), [253](../../native/install/src/links/root.rs#L253) | — | — | — | — | +| [native/install/src/manifests/intersect/single.rs](../../native/install/src/manifests/intersect/single.rs) | [116](../../native/install/src/manifests/intersect/single.rs#L116), [145](../../native/install/src/manifests/intersect/single.rs#L145), [159](../../native/install/src/manifests/intersect/single.rs#L159), [204](../../native/install/src/manifests/intersect/single.rs#L204) | — | — | — | — | +| [native/install/src/manifests/intersect.rs](../../native/install/src/manifests/intersect.rs) | [99](../../native/install/src/manifests/intersect.rs#L99), [102](../../native/install/src/manifests/intersect.rs#L102), [105](../../native/install/src/manifests/intersect.rs#L105), [109](../../native/install/src/manifests/intersect.rs#L109), [114](../../native/install/src/manifests/intersect.rs#L114), [118](../../native/install/src/manifests/intersect.rs#L118) | — | — | — | — | +| [native/install/src/policy/load.rs](../../native/install/src/policy/load.rs) | [132](../../native/install/src/policy/load.rs#L132), [169](../../native/install/src/policy/load.rs#L169), [171](../../native/install/src/policy/load.rs#L171), [176](../../native/install/src/policy/load.rs#L176), [210](../../native/install/src/policy/load.rs#L210) | — | — | — | — | +| [native/install/src/policy/scope.rs](../../native/install/src/policy/scope.rs) | [92](../../native/install/src/policy/scope.rs#L92), [105](../../native/install/src/policy/scope.rs#L105), [115](../../native/install/src/policy/scope.rs#L115), [142](../../native/install/src/policy/scope.rs#L142), [173](../../native/install/src/policy/scope.rs#L173), [189](../../native/install/src/policy/scope.rs#L189), [206](../../native/install/src/policy/scope.rs#L206), [286](../../native/install/src/policy/scope.rs#L286) | — | — | — | — | +| [native/install/src/workspace/first_env.rs](../../native/install/src/workspace/first_env.rs) | [63](../../native/install/src/workspace/first_env.rs#L63) | — | — | — | — | +| [native/install/src/workspace/scope_model.rs](../../native/install/src/workspace/scope_model.rs) | [178](../../native/install/src/workspace/scope_model.rs#L178), [185](../../native/install/src/workspace/scope_model.rs#L185), [201](../../native/install/src/workspace/scope_model.rs#L201), [210](../../native/install/src/workspace/scope_model.rs#L210), [241](../../native/install/src/workspace/scope_model.rs#L241), [260](../../native/install/src/workspace/scope_model.rs#L260) | — | — | — | — | +| [native/install/src/workspace.rs](../../native/install/src/workspace.rs) | [81](../../native/install/src/workspace.rs#L81), [99](../../native/install/src/workspace.rs#L99), [105](../../native/install/src/workspace.rs#L105) | — | — | — | — | +| [native/object-import/src/checkout/source_read.rs](../../native/object-import/src/checkout/source_read.rs) | [35](../../native/object-import/src/checkout/source_read.rs#L35), [57](../../native/object-import/src/checkout/source_read.rs#L57) | — | — | — | — | +| [native/object-import/src/checkout_transition/apply/effects.rs](../../native/object-import/src/checkout_transition/apply/effects.rs) | [16](../../native/object-import/src/checkout_transition/apply/effects.rs#L16), [39](../../native/object-import/src/checkout_transition/apply/effects.rs#L39), [57](../../native/object-import/src/checkout_transition/apply/effects.rs#L57), [65](../../native/object-import/src/checkout_transition/apply/effects.rs#L65), [69](../../native/object-import/src/checkout_transition/apply/effects.rs#L69), [110](../../native/object-import/src/checkout_transition/apply/effects.rs#L110), [166](../../native/object-import/src/checkout_transition/apply/effects.rs#L166) | — | — | — | — | +| [native/object-import/src/command_import/capture.rs](../../native/object-import/src/command_import/capture.rs) | [117](../../native/object-import/src/command_import/capture.rs#L117), [253](../../native/object-import/src/command_import/capture.rs#L253) | — | — | — | — | +| [native/object-import/src/command_import/lane/tracking.rs](../../native/object-import/src/command_import/lane/tracking.rs) | [69](../../native/object-import/src/command_import/lane/tracking.rs#L69) | — | — | — | — | +| [native/object-import/src/command_import/lane.rs](../../native/object-import/src/command_import/lane.rs) | [128](../../native/object-import/src/command_import/lane.rs#L128), [201](../../native/object-import/src/command_import/lane.rs#L201), [228](../../native/object-import/src/command_import/lane.rs#L228), [239](../../native/object-import/src/command_import/lane.rs#L239) | — | — | — | — | +| [native/object-import/src/command_import/prepare/dependencies.rs](../../native/object-import/src/command_import/prepare/dependencies.rs) | [299](../../native/object-import/src/command_import/prepare/dependencies.rs#L299), [300](../../native/object-import/src/command_import/prepare/dependencies.rs#L300) | — | — | — | — | +| [native/object-import/src/command_import/prepare/lane/fetch.rs](../../native/object-import/src/command_import/prepare/lane/fetch.rs) | [23](../../native/object-import/src/command_import/prepare/lane/fetch.rs#L23) | — | — | — | — | +| [native/object-import/src/component_fork/files.rs](../../native/object-import/src/component_fork/files.rs) | [137](../../native/object-import/src/component_fork/files.rs#L137) | — | — | — | — | +| [native/object-import/src/component_recover/batch/configuration.rs](../../native/object-import/src/component_recover/batch/configuration.rs) | [9](../../native/object-import/src/component_recover/batch/configuration.rs#L9), [11](../../native/object-import/src/component_recover/batch/configuration.rs#L11), [23](../../native/object-import/src/component_recover/batch/configuration.rs#L23) | — | — | — | — | +| [native/object-import/src/component_recover/batch/repository.rs](../../native/object-import/src/component_recover/batch/repository.rs) | [24](../../native/object-import/src/component_recover/batch/repository.rs#L24), [100](../../native/object-import/src/component_recover/batch/repository.rs#L100), [105](../../native/object-import/src/component_recover/batch/repository.rs#L105) | — | — | — | — | +| [native/object-import/src/component_recover/batch/selection.rs](../../native/object-import/src/component_recover/batch/selection.rs) | [150](../../native/object-import/src/component_recover/batch/selection.rs#L150), [164](../../native/object-import/src/component_recover/batch/selection.rs#L164) | — | — | — | — | +| [native/object-import/src/component_recover/probe/objects.rs](../../native/object-import/src/component_recover/probe/objects.rs) | [56](../../native/object-import/src/component_recover/probe/objects.rs#L56), [73](../../native/object-import/src/component_recover/probe/objects.rs#L73) | — | — | — | — | +| [native/object-import/src/component_remove/batch/cleanup/configuration.rs](../../native/object-import/src/component_remove/batch/cleanup/configuration.rs) | [17](../../native/object-import/src/component_remove/batch/cleanup/configuration.rs#L17), [26](../../native/object-import/src/component_remove/batch/cleanup/configuration.rs#L26) | — | — | — | — | +| [native/object-import/src/component_remove/batch/cleanup/pending.rs](../../native/object-import/src/component_remove/batch/cleanup/pending.rs) | [9](../../native/object-import/src/component_remove/batch/cleanup/pending.rs#L9), [15](../../native/object-import/src/component_remove/batch/cleanup/pending.rs#L15) | — | — | — | — | +| [native/object-import/src/component_remove/batch/cleanup.rs](../../native/object-import/src/component_remove/batch/cleanup.rs) | [125](../../native/object-import/src/component_remove/batch/cleanup.rs#L125), [129](../../native/object-import/src/component_remove/batch/cleanup.rs#L129), [138](../../native/object-import/src/component_remove/batch/cleanup.rs#L138), [152](../../native/object-import/src/component_remove/batch/cleanup.rs#L152), [170](../../native/object-import/src/component_remove/batch/cleanup.rs#L170), [184](../../native/object-import/src/component_remove/batch/cleanup.rs#L184) | — | — | — | — | +| [native/object-import/src/component_remove/versioned/history.rs](../../native/object-import/src/component_remove/versioned/history.rs) | [161](../../native/object-import/src/component_remove/versioned/history.rs#L161) | — | — | — | — | +| [native/object-import/src/component_remove/versioned.rs](../../native/object-import/src/component_remove/versioned.rs) | [189](../../native/object-import/src/component_remove/versioned.rs#L189) | — | — | — | — | +| [native/object-import/src/component_remove.rs](../../native/object-import/src/component_remove.rs) | [185](../../native/object-import/src/component_remove.rs#L185), [285](../../native/object-import/src/component_remove.rs#L285), [289](../../native/object-import/src/component_remove.rs#L289) | — | — | — | — | +| [native/object-import/src/component_tracking/identity.rs](../../native/object-import/src/component_tracking/identity.rs) | [188](../../native/object-import/src/component_tracking/identity.rs#L188) | — | — | — | — | +| [native/object-import/src/component_tracking/main_file.rs](../../native/object-import/src/component_tracking/main_file.rs) | [114](../../native/object-import/src/component_tracking/main_file.rs#L114) | — | — | — | — | +| [native/object-import/src/config_merge/component/data.rs](../../native/object-import/src/config_merge/component/data.rs) | [79](../../native/object-import/src/config_merge/component/data.rs#L79), [84](../../native/object-import/src/config_merge/component/data.rs#L84), [90](../../native/object-import/src/config_merge/component/data.rs#L90), [94](../../native/object-import/src/config_merge/component/data.rs#L94) | — | — | — | — | +| [native/object-import/src/config_merge/component/specific.rs](../../native/object-import/src/config_merge/component/specific.rs) | [23](../../native/object-import/src/config_merge/component/specific.rs#L23), [36](../../native/object-import/src/config_merge/component/specific.rs#L36) | — | — | — | — | +| [native/object-import/src/config_merge/component.rs](../../native/object-import/src/config_merge/component.rs) | [111](../../native/object-import/src/config_merge/component.rs#L111) | — | — | — | — | +| [native/object-import/src/history_query/bfs.rs](../../native/object-import/src/history_query/bfs.rs) | [234](../../native/object-import/src/history_query/bfs.rs#L234) | — | — | — | — | +| [native/object-import/src/history_query/files.rs](../../native/object-import/src/history_query/files.rs) | [57](../../native/object-import/src/history_query/files.rs#L57), [118](../../native/object-import/src/history_query/files.rs#L118) | — | — | — | — | +| [native/object-import/src/history_query/lines.rs](../../native/object-import/src/history_query/lines.rs) | [71](../../native/object-import/src/history_query/lines.rs#L71), [83](../../native/object-import/src/history_query/lines.rs#L83) | — | — | — | — | +| [native/object-import/src/history_query/populate.rs](../../native/object-import/src/history_query/populate.rs) | [42](../../native/object-import/src/history_query/populate.rs#L42), [226](../../native/object-import/src/history_query/populate.rs#L226), [234](../../native/object-import/src/history_query/populate.rs#L234) | — | — | — | — | +| [native/object-import/src/inspection/compare/fields.rs](../../native/object-import/src/inspection/compare/fields.rs) | [155](../../native/object-import/src/inspection/compare/fields.rs#L155), [165](../../native/object-import/src/inspection/compare/fields.rs#L165) | — | — | — | — | +| [native/object-import/src/js_json.rs](../../native/object-import/src/js_json.rs) | [92](../../native/object-import/src/js_json.rs#L92) | — | — | — | — | +| [native/object-import/src/lane_create/staged.rs](../../native/object-import/src/lane_create/staged.rs) | [152](../../native/object-import/src/lane_create/staged.rs#L152) | — | — | — | — | +| [native/object-import/src/lane_manage.rs](../../native/object-import/src/lane_manage.rs) | [177](../../native/object-import/src/lane_manage.rs#L177) | — | — | — | — | +| [native/object-import/src/merge_history/graph.rs](../../native/object-import/src/merge_history/graph.rs) | [289](../../native/object-import/src/merge_history/graph.rs#L289) | — | — | — | — | +| [native/object-import/src/merge_history/merge/git.rs](../../native/object-import/src/merge_history/merge/git.rs) | — | — | — | [167](../../native/object-import/src/merge_history/merge/git.rs#L167) | — | +| [native/object-import/src/merge_transaction/decision.rs](../../native/object-import/src/merge_transaction/decision.rs) | [49](../../native/object-import/src/merge_transaction/decision.rs#L49), [53](../../native/object-import/src/merge_transaction/decision.rs#L53) | — | — | — | — | +| [native/object-import/src/remote_delete/readset.rs](../../native/object-import/src/remote_delete/readset.rs) | [107](../../native/object-import/src/remote_delete/readset.rs#L107), [113](../../native/object-import/src/remote_delete/readset.rs#L113) | — | — | — | — | +| [native/object-import/src/remote_delete.rs](../../native/object-import/src/remote_delete.rs) | [356](../../native/object-import/src/remote_delete.rs#L356), [374](../../native/object-import/src/remote_delete.rs#L374) | — | — | — | — | +| [native/object-import/src/repository.rs](../../native/object-import/src/repository.rs) | [25](../../native/object-import/src/repository.rs#L25), [59](../../native/object-import/src/repository.rs#L59) | — | — | — | — | +| [native/object-import/src/reset_config.rs](../../native/object-import/src/reset_config.rs) | [16](../../native/object-import/src/reset_config.rs#L16), [63](../../native/object-import/src/reset_config.rs#L63) | — | — | — | — | +| [native/object-import/src/reset_lane/batch/candidates.rs](../../native/object-import/src/reset_lane/batch/candidates.rs) | [155](../../native/object-import/src/reset_lane/batch/candidates.rs#L155) | — | — | — | — | +| [native/object-import/src/reset_lane/batch.rs](../../native/object-import/src/reset_lane/batch.rs) | [319](../../native/object-import/src/reset_lane/batch.rs#L319) | — | — | — | — | +| [native/object-import/src/reset_lane/history.rs](../../native/object-import/src/reset_lane/history.rs) | [120](../../native/object-import/src/reset_lane/history.rs#L120), [141](../../native/object-import/src/reset_lane/history.rs#L141) | — | — | — | — | +| [native/object-import/src/reset_lane/membership.rs](../../native/object-import/src/reset_lane/membership.rs) | [9](../../native/object-import/src/reset_lane/membership.rs#L9) | — | — | — | — | +| [native/object-import/src/reset_lane/remote.rs](../../native/object-import/src/reset_lane/remote.rs) | [37](../../native/object-import/src/reset_lane/remote.rs#L37), [77](../../native/object-import/src/reset_lane/remote.rs#L77), [103](../../native/object-import/src/reset_lane/remote.rs#L103), [117](../../native/object-import/src/reset_lane/remote.rs#L117), [131](../../native/object-import/src/reset_lane/remote.rs#L131) | — | — | — | — | +| [native/object-import/src/reset_lane/selection.rs](../../native/object-import/src/reset_lane/selection.rs) | [125](../../native/object-import/src/reset_lane/selection.rs#L125) | — | — | — | — | +| [native/object-import/src/reset_lane/state.rs](../../native/object-import/src/reset_lane/state.rs) | [137](../../native/object-import/src/reset_lane/state.rs#L137) | — | — | — | — | +| [native/object-import/src/reset_main/batch.rs](../../native/object-import/src/reset_main/batch.rs) | [195](../../native/object-import/src/reset_main/batch.rs#L195) | — | — | — | — | +| [native/object-import/src/reset_main/dependents.rs](../../native/object-import/src/reset_main/dependents.rs) | [59](../../native/object-import/src/reset_main/dependents.rs#L59) | — | — | — | — | +| [native/object-import/src/reset_main/history.rs](../../native/object-import/src/reset_main/history.rs) | [38](../../native/object-import/src/reset_main/history.rs#L38) | — | — | — | — | +| [native/object-import/src/reset_main/selection.rs](../../native/object-import/src/reset_main/selection.rs) | [112](../../native/object-import/src/reset_main/selection.rs#L112) | — | — | — | — | +| [native/object-import/src/reset_never_exported.rs](../../native/object-import/src/reset_never_exported.rs) | [96](../../native/object-import/src/reset_never_exported.rs#L96) | — | — | — | — | +| [native/object-import/src/stash_store/observation.rs](../../native/object-import/src/stash_store/observation.rs) | [82](../../native/object-import/src/stash_store/observation.rs#L82), [89](../../native/object-import/src/stash_store/observation.rs#L89) | — | — | — | — | +| [native/object-import/src/store.rs](../../native/object-import/src/store.rs) | [283](../../native/object-import/src/store.rs#L283) | — | — | — | — | +| [native/object-import/src/transfer/collection/shape.rs](../../native/object-import/src/transfer/collection/shape.rs) | [75](../../native/object-import/src/transfer/collection/shape.rs#L75) | — | — | — | — | +| [native/object-import/src/transfer/collection/version.rs](../../native/object-import/src/transfer/collection/version.rs) | [108](../../native/object-import/src/transfer/collection/version.rs#L108), [109](../../native/object-import/src/transfer/collection/version.rs#L109) | — | — | — | — | +| [native/object-import/src/workspace/actions.rs](../../native/object-import/src/workspace/actions.rs) | [70](../../native/object-import/src/workspace/actions.rs#L70) | — | — | — | — | +| [native/object-import/src/workspace/engine/client_status/models.rs](../../native/object-import/src/workspace/engine/client_status/models.rs) | [61](../../native/object-import/src/workspace/engine/client_status/models.rs#L61) | — | — | — | — | +| [native/object-import/src/workspace/engine/command/bitmap/lane.rs](../../native/object-import/src/workspace/engine/command/bitmap/lane.rs) | [118](../../native/object-import/src/workspace/engine/command/bitmap/lane.rs#L118) | — | — | — | — | +| [native/object-import/src/workspace/engine/command/bitmap.rs](../../native/object-import/src/workspace/engine/command/bitmap.rs) | [373](../../native/object-import/src/workspace/engine/command/bitmap.rs#L373) | — | — | — | — | +| [native/object-import/src/workspace/engine/command/checkout_policy/publication.rs](../../native/object-import/src/workspace/engine/command/checkout_policy/publication.rs) | [42](../../native/object-import/src/workspace/engine/command/checkout_policy/publication.rs#L42) | — | — | — | — | +| [native/object-import/src/workspace/engine/command/checkout_policy.rs](../../native/object-import/src/workspace/engine/command/checkout_policy.rs) | [116](../../native/object-import/src/workspace/engine/command/checkout_policy.rs#L116), [125](../../native/object-import/src/workspace/engine/command/checkout_policy.rs#L125), [128](../../native/object-import/src/workspace/engine/command/checkout_policy.rs#L128), [132](../../native/object-import/src/workspace/engine/command/checkout_policy.rs#L132) | — | — | — | — | +| [native/object-import/src/workspace/engine/command/checkout_stash/additions.rs](../../native/object-import/src/workspace/engine/command/checkout_stash/additions.rs) | [123](../../native/object-import/src/workspace/engine/command/checkout_stash/additions.rs#L123), [139](../../native/object-import/src/workspace/engine/command/checkout_stash/additions.rs#L139) | — | — | — | — | +| [native/object-import/src/workspace/engine/command/checkout_stash.rs](../../native/object-import/src/workspace/engine/command/checkout_stash.rs) | [66](../../native/object-import/src/workspace/engine/command/checkout_stash.rs#L66) | — | — | — | — | +| [native/object-import/src/workspace/engine/command/inbound/autosync.rs](../../native/object-import/src/workspace/engine/command/inbound/autosync.rs) | [112](../../native/object-import/src/workspace/engine/command/inbound/autosync.rs#L112) | — | — | — | — | +| [native/object-import/src/workspace/engine/command/inbound/details.rs](../../native/object-import/src/workspace/engine/command/inbound/details.rs) | [236](../../native/object-import/src/workspace/engine/command/inbound/details.rs#L236), [250](../../native/object-import/src/workspace/engine/command/inbound/details.rs#L250) | — | — | — | — | +| [native/object-import/src/workspace/engine/command/inbound/preparation.rs](../../native/object-import/src/workspace/engine/command/inbound/preparation.rs) | [47](../../native/object-import/src/workspace/engine/command/inbound/preparation.rs#L47) | — | — | — | — | +| [native/object-import/src/workspace/engine/command/inbound/remote/file_list.rs](../../native/object-import/src/workspace/engine/command/inbound/remote/file_list.rs) | [215](../../native/object-import/src/workspace/engine/command/inbound/remote/file_list.rs#L215), [220](../../native/object-import/src/workspace/engine/command/inbound/remote/file_list.rs#L220) | — | — | — | — | +| [native/object-import/src/workspace/engine/command/inbound/remote/history.rs](../../native/object-import/src/workspace/engine/command/inbound/remote/history.rs) | [48](../../native/object-import/src/workspace/engine/command/inbound/remote/history.rs#L48) | — | — | — | — | +| [native/object-import/src/workspace/engine/command/inbound/remote/inventory.rs](../../native/object-import/src/workspace/engine/command/inbound/remote/inventory.rs) | [303](../../native/object-import/src/workspace/engine/command/inbound/remote/inventory.rs#L303), [319](../../native/object-import/src/workspace/engine/command/inbound/remote/inventory.rs#L319) | — | — | — | — | +| [native/object-import/src/workspace/engine/command/inbound/remote/lane_list.rs](../../native/object-import/src/workspace/engine/command/inbound/remote/lane_list.rs) | [90](../../native/object-import/src/workspace/engine/command/inbound/remote/lane_list.rs#L90) | — | — | — | — | +| [native/object-import/src/workspace/engine/command/inbound/remote.rs](../../native/object-import/src/workspace/engine/command/inbound/remote.rs) | [63](../../native/object-import/src/workspace/engine/command/inbound/remote.rs#L63), [175](../../native/object-import/src/workspace/engine/command/inbound/remote.rs#L175), [191](../../native/object-import/src/workspace/engine/command/inbound/remote.rs#L191) | — | — | — | — | +| [native/object-import/src/workspace/engine/command/inbound/write/existing.rs](../../native/object-import/src/workspace/engine/command/inbound/write/existing.rs) | [323](../../native/object-import/src/workspace/engine/command/inbound/write/existing.rs#L323) | — | — | — | — | +| [native/object-import/src/workspace/engine/command/inbound/write/policy.rs](../../native/object-import/src/workspace/engine/command/inbound/write/policy.rs) | [30](../../native/object-import/src/workspace/engine/command/inbound/write/policy.rs#L30) | — | — | — | — | +| [native/object-import/src/workspace/engine/command/inbound/write/preflight.rs](../../native/object-import/src/workspace/engine/command/inbound/write/preflight.rs) | [101](../../native/object-import/src/workspace/engine/command/inbound/write/preflight.rs#L101) | — | — | — | — | +| [native/object-import/src/workspace/engine/command/inbound/write/relocation.rs](../../native/object-import/src/workspace/engine/command/inbound/write/relocation.rs) | [160](../../native/object-import/src/workspace/engine/command/inbound/write/relocation.rs#L160) | — | — | — | — | +| [native/object-import/src/workspace/engine/command/inbound/write_dependencies/publication.rs](../../native/object-import/src/workspace/engine/command/inbound/write_dependencies/publication.rs) | [100](../../native/object-import/src/workspace/engine/command/inbound/write_dependencies/publication.rs#L100) | — | — | — | — | +| [native/object-import/src/workspace/engine/command/inbound/write.rs](../../native/object-import/src/workspace/engine/command/inbound/write.rs) | [307](../../native/object-import/src/workspace/engine/command/inbound/write.rs#L307), [311](../../native/object-import/src/workspace/engine/command/inbound/write.rs#L311) | — | — | — | — | +| [native/object-import/src/workspace/engine/command/inbound.rs](../../native/object-import/src/workspace/engine/command/inbound.rs) | [363](../../native/object-import/src/workspace/engine/command/inbound.rs#L363) | — | — | — | — | +| [native/object-import/src/workspace/engine/command/lane_context.rs](../../native/object-import/src/workspace/engine/command/lane_context.rs) | [196](../../native/object-import/src/workspace/engine/command/lane_context.rs#L196) | — | — | — | — | +| [native/object-import/src/workspace/engine/command/lane_history/heads.rs](../../native/object-import/src/workspace/engine/command/lane_history/heads.rs) | [13](../../native/object-import/src/workspace/engine/command/lane_history/heads.rs#L13), [31](../../native/object-import/src/workspace/engine/command/lane_history/heads.rs#L31), [48](../../native/object-import/src/workspace/engine/command/lane_history/heads.rs#L48) | — | — | — | — | +| [native/object-import/src/workspace/engine/command/lane_publication/squash.rs](../../native/object-import/src/workspace/engine/command/lane_publication/squash.rs) | [35](../../native/object-import/src/workspace/engine/command/lane_publication/squash.rs#L35), [109](../../native/object-import/src/workspace/engine/command/lane_publication/squash.rs#L109) | — | — | — | — | +| [native/object-import/src/workspace/engine/command/lane_publication.rs](../../native/object-import/src/workspace/engine/command/lane_publication.rs) | [28](../../native/object-import/src/workspace/engine/command/lane_publication.rs#L28) | — | — | — | — | +| [native/object-import/src/workspace/engine/command/lane_referenced.rs](../../native/object-import/src/workspace/engine/command/lane_referenced.rs) | [67](../../native/object-import/src/workspace/engine/command/lane_referenced.rs#L67) | — | — | — | — | +| [native/object-import/src/workspace/engine/command/materialization/license.rs](../../native/object-import/src/workspace/engine/command/materialization/license.rs) | [18](../../native/object-import/src/workspace/engine/command/materialization/license.rs#L18) | — | — | — | — | +| [native/object-import/src/workspace/engine/command/merge_dependency_guard.rs](../../native/object-import/src/workspace/engine/command/merge_dependency_guard.rs) | [280](../../native/object-import/src/workspace/engine/command/merge_dependency_guard.rs#L280) | — | — | — | — | +| [native/object-import/src/workspace/engine/command/outbound/hub.rs](../../native/object-import/src/workspace/engine/command/outbound/hub.rs) | [32](../../native/object-import/src/workspace/engine/command/outbound/hub.rs#L32), [43](../../native/object-import/src/workspace/engine/command/outbound/hub.rs#L43) | — | — | — | — | +| [native/object-import/src/workspace/engine/command/outbound/lane_model/exhaustive.rs](../../native/object-import/src/workspace/engine/command/outbound/lane_model/exhaustive.rs) | [82](../../native/object-import/src/workspace/engine/command/outbound/lane_model/exhaustive.rs#L82) | — | — | — | — | +| [native/object-import/src/workspace/engine/command/outbound/lane_model/hidden.rs](../../native/object-import/src/workspace/engine/command/outbound/lane_model/hidden.rs) | [265](../../native/object-import/src/workspace/engine/command/outbound/lane_model/hidden.rs#L265), [278](../../native/object-import/src/workspace/engine/command/outbound/lane_model/hidden.rs#L278), [323](../../native/object-import/src/workspace/engine/command/outbound/lane_model/hidden.rs#L323) | — | — | — | — | +| [native/object-import/src/workspace/engine/command/outbound/lane_model/staged.rs](../../native/object-import/src/workspace/engine/command/outbound/lane_model/staged.rs) | [56](../../native/object-import/src/workspace/engine/command/outbound/lane_model/staged.rs#L56) | — | — | — | — | +| [native/object-import/src/workspace/engine/command/outbound/lane_model.rs](../../native/object-import/src/workspace/engine/command/outbound/lane_model.rs) | [58](../../native/object-import/src/workspace/engine/command/outbound/lane_model.rs#L58), [93](../../native/object-import/src/workspace/engine/command/outbound/lane_model.rs#L93), [256](../../native/object-import/src/workspace/engine/command/outbound/lane_model.rs#L256) | — | — | — | — | +| [native/object-import/src/workspace/engine/command/outbound/lane_publication.rs](../../native/object-import/src/workspace/engine/command/outbound/lane_publication.rs) | [380](../../native/object-import/src/workspace/engine/command/outbound/lane_publication.rs#L380) | — | — | — | — | +| [native/object-import/src/workspace/engine/command/outbound/model/prefilter.rs](../../native/object-import/src/workspace/engine/command/outbound/model/prefilter.rs) | [111](../../native/object-import/src/workspace/engine/command/outbound/model/prefilter.rs#L111) | — | — | — | — | +| [native/object-import/src/workspace/engine/command/outbound/model.rs](../../native/object-import/src/workspace/engine/command/outbound/model.rs) | [93](../../native/object-import/src/workspace/engine/command/outbound/model.rs#L93) | — | — | — | — | +| [native/object-import/src/workspace/engine/command/outbound/packages.rs](../../native/object-import/src/workspace/engine/command/outbound/packages.rs) | [81](../../native/object-import/src/workspace/engine/command/outbound/packages.rs#L81), [143](../../native/object-import/src/workspace/engine/command/outbound/packages.rs#L143), [151](../../native/object-import/src/workspace/engine/command/outbound/packages.rs#L151) | — | — | — | — | +| [native/object-import/src/workspace/engine/command/outbound/publication.rs](../../native/object-import/src/workspace/engine/command/outbound/publication.rs) | [377](../../native/object-import/src/workspace/engine/command/outbound/publication.rs#L377) | — | — | — | — | +| [native/object-import/src/workspace/engine/command/outbound/remote/file_bridge.rs](../../native/object-import/src/workspace/engine/command/outbound/remote/file_bridge.rs) | — | — | — | [58](../../native/object-import/src/workspace/engine/command/outbound/remote/file_bridge.rs#L58) | — | +| [native/object-import/src/workspace/engine/command/outbound/remote/group.rs](../../native/object-import/src/workspace/engine/command/outbound/remote/group.rs) | [11](../../native/object-import/src/workspace/engine/command/outbound/remote/group.rs#L11), [22](../../native/object-import/src/workspace/engine/command/outbound/remote/group.rs#L22), [33](../../native/object-import/src/workspace/engine/command/outbound/remote/group.rs#L33), [41](../../native/object-import/src/workspace/engine/command/outbound/remote/group.rs#L41), [48](../../native/object-import/src/workspace/engine/command/outbound/remote/group.rs#L48), [57](../../native/object-import/src/workspace/engine/command/outbound/remote/group.rs#L57) | — | — | — | — | +| [native/object-import/src/workspace/engine/command/outbound/remote.rs](../../native/object-import/src/workspace/engine/command/outbound/remote.rs) | — | — | [40](../../native/object-import/src/workspace/engine/command/outbound/remote.rs#L40), [49](../../native/object-import/src/workspace/engine/command/outbound/remote.rs#L49) | — | — | +| [native/object-import/src/workspace/engine/command/outbound.rs](../../native/object-import/src/workspace/engine/command/outbound.rs) | [244](../../native/object-import/src/workspace/engine/command/outbound.rs#L244) | — | — | — | — | +| [native/object-import/src/workspace/engine/command/plan_command.rs](../../native/object-import/src/workspace/engine/command/plan_command.rs) | [100](../../native/object-import/src/workspace/engine/command/plan_command.rs#L100) | — | — | — | — | +| [native/object-import/src/workspace/engine/command/planning/checkout_select.rs](../../native/object-import/src/workspace/engine/command/planning/checkout_select.rs) | [177](../../native/object-import/src/workspace/engine/command/planning/checkout_select.rs#L177) | — | — | — | — | +| [native/object-import/src/workspace/engine/command/planning.rs](../../native/object-import/src/workspace/engine/command/planning.rs) | [324](../../native/object-import/src/workspace/engine/command/planning.rs#L324) | — | — | — | — | +| [native/object-import/src/workspace/engine/command/policy/workspace.rs](../../native/object-import/src/workspace/engine/command/policy/workspace.rs) | [19](../../native/object-import/src/workspace/engine/command/policy/workspace.rs#L19), [47](../../native/object-import/src/workspace/engine/command/policy/workspace.rs#L47) | — | — | — | — | +| [native/object-import/src/workspace/engine/command/prepare/checkout_import.rs](../../native/object-import/src/workspace/engine/command/prepare/checkout_import.rs) | [16](../../native/object-import/src/workspace/engine/command/prepare/checkout_import.rs#L16), [21](../../native/object-import/src/workspace/engine/command/prepare/checkout_import.rs#L21) | — | — | — | — | +| [native/object-import/src/workspace/engine/command/recovery/lane.rs](../../native/object-import/src/workspace/engine/command/recovery/lane.rs) | [27](../../native/object-import/src/workspace/engine/command/recovery/lane.rs#L27), [30](../../native/object-import/src/workspace/engine/command/recovery/lane.rs#L30) | — | — | — | — | +| [native/object-import/src/workspace/engine/command/recovery/publication.rs](../../native/object-import/src/workspace/engine/command/recovery/publication.rs) | [143](../../native/object-import/src/workspace/engine/command/recovery/publication.rs#L143), [150](../../native/object-import/src/workspace/engine/command/recovery/publication.rs#L150), [164](../../native/object-import/src/workspace/engine/command/recovery/publication.rs#L164) | — | — | — | — | +| [native/object-import/src/workspace/engine/command/recovery/restore/main.rs](../../native/object-import/src/workspace/engine/command/recovery/restore/main.rs) | [26](../../native/object-import/src/workspace/engine/command/recovery/restore/main.rs#L26) | — | — | — | — | +| [native/object-import/src/workspace/engine/command/repository.rs](../../native/object-import/src/workspace/engine/command/repository.rs) | [234](../../native/object-import/src/workspace/engine/command/repository.rs#L234) | — | — | — | — | +| [native/object-import/src/workspace/engine/command/resolve/tag_versions/ranges.rs](../../native/object-import/src/workspace/engine/command/resolve/tag_versions/ranges.rs) | [66](../../native/object-import/src/workspace/engine/command/resolve/tag_versions/ranges.rs#L66), [79](../../native/object-import/src/workspace/engine/command/resolve/tag_versions/ranges.rs#L79) | — | — | — | — | +| [native/object-import/src/workspace/engine/command/resolve_publication/controls.rs](../../native/object-import/src/workspace/engine/command/resolve_publication/controls.rs) | [65](../../native/object-import/src/workspace/engine/command/resolve_publication/controls.rs#L65), [68](../../native/object-import/src/workspace/engine/command/resolve_publication/controls.rs#L68) | — | — | — | — | +| [native/object-import/src/workspace/engine/command/resolve_snapshot/history.rs](../../native/object-import/src/workspace/engine/command/resolve_snapshot/history.rs) | [43](../../native/object-import/src/workspace/engine/command/resolve_snapshot/history.rs#L43), [48](../../native/object-import/src/workspace/engine/command/resolve_snapshot/history.rs#L48), [64](../../native/object-import/src/workspace/engine/command/resolve_snapshot/history.rs#L64) | — | — | — | — | +| [native/object-import/src/workspace/engine/command/resolve_snapshot.rs](../../native/object-import/src/workspace/engine/command/resolve_snapshot.rs) | [207](../../native/object-import/src/workspace/engine/command/resolve_snapshot.rs#L207) | — | — | — | — | +| [native/object-import/src/workspace/engine/command/stash/graph.rs](../../native/object-import/src/workspace/engine/command/stash/graph.rs) | [43](../../native/object-import/src/workspace/engine/command/stash/graph.rs#L43), [348](../../native/object-import/src/workspace/engine/command/stash/graph.rs#L348) | — | — | — | — | +| [native/object-import/src/workspace/engine/command/stash/planning.rs](../../native/object-import/src/workspace/engine/command/stash/planning.rs) | [91](../../native/object-import/src/workspace/engine/command/stash/planning.rs#L91) | — | — | — | — | +| [native/object-import/src/workspace/engine/command/switch_completion.rs](../../native/object-import/src/workspace/engine/command/switch_completion.rs) | [37](../../native/object-import/src/workspace/engine/command/switch_completion.rs#L37) | — | — | — | — | +| [native/object-import/src/workspace/engine/command_request.rs](../../native/object-import/src/workspace/engine/command_request.rs) | [56](../../native/object-import/src/workspace/engine/command_request.rs#L56) | — | — | — | — | +| [native/object-import/src/workspace/engine/dependency_graph/proof.rs](../../native/object-import/src/workspace/engine/dependency_graph/proof.rs) | [186](../../native/object-import/src/workspace/engine/dependency_graph/proof.rs#L186) | — | — | — | — | +| [native/object-import/src/workspace/engine/dependency_graph/scan.rs](../../native/object-import/src/workspace/engine/dependency_graph/scan.rs) | [211](../../native/object-import/src/workspace/engine/dependency_graph/scan.rs#L211) | — | — | — | — | +| [native/object-import/src/workspace/engine/dependency_policy/cached.rs](../../native/object-import/src/workspace/engine/dependency_policy/cached.rs) | [109](../../native/object-import/src/workspace/engine/dependency_policy/cached.rs#L109), [127](../../native/object-import/src/workspace/engine/dependency_policy/cached.rs#L127) | — | — | — | — | +| [native/object-import/src/workspace/engine/dependency_policy/fresh/complete.rs](../../native/object-import/src/workspace/engine/dependency_policy/fresh/complete.rs) | [151](../../native/object-import/src/workspace/engine/dependency_policy/fresh/complete.rs#L151) | — | — | — | — | +| [native/object-import/src/workspace/engine/dependency_policy/fresh/packages.rs](../../native/object-import/src/workspace/engine/dependency_policy/fresh/packages.rs) | [56](../../native/object-import/src/workspace/engine/dependency_policy/fresh/packages.rs#L56), [106](../../native/object-import/src/workspace/engine/dependency_policy/fresh/packages.rs#L106), [109](../../native/object-import/src/workspace/engine/dependency_policy/fresh/packages.rs#L109), [119](../../native/object-import/src/workspace/engine/dependency_policy/fresh/packages.rs#L119), [129](../../native/object-import/src/workspace/engine/dependency_policy/fresh/packages.rs#L129), [198](../../native/object-import/src/workspace/engine/dependency_policy/fresh/packages.rs#L198), [203](../../native/object-import/src/workspace/engine/dependency_policy/fresh/packages.rs#L203) | — | — | — | — | +| [native/object-import/src/workspace/engine/dependency_policy/fresh/project/relative.rs](../../native/object-import/src/workspace/engine/dependency_policy/fresh/project/relative.rs) | [109](../../native/object-import/src/workspace/engine/dependency_policy/fresh/project/relative.rs#L109), [111](../../native/object-import/src/workspace/engine/dependency_policy/fresh/project/relative.rs#L111), [153](../../native/object-import/src/workspace/engine/dependency_policy/fresh/project/relative.rs#L153), [170](../../native/object-import/src/workspace/engine/dependency_policy/fresh/project/relative.rs#L170) | — | — | — | — | +| [native/object-import/src/workspace/engine/dependency_policy/packages_on_disk/metadata.rs](../../native/object-import/src/workspace/engine/dependency_policy/packages_on_disk/metadata.rs) | [81](../../native/object-import/src/workspace/engine/dependency_policy/packages_on_disk/metadata.rs#L81) | — | — | — | — | +| [native/object-import/src/workspace/engine/dependency_policy/packages_on_disk.rs](../../native/object-import/src/workspace/engine/dependency_policy/packages_on_disk.rs) | [75](../../native/object-import/src/workspace/engine/dependency_policy/packages_on_disk.rs#L75), [142](../../native/object-import/src/workspace/engine/dependency_policy/packages_on_disk.rs#L142), [158](../../native/object-import/src/workspace/engine/dependency_policy/packages_on_disk.rs#L158) | — | — | — | — | +| [native/object-import/src/workspace/engine/dependency_policy/versions/select.rs](../../native/object-import/src/workspace/engine/dependency_policy/versions/select.rs) | [103](../../native/object-import/src/workspace/engine/dependency_policy/versions/select.rs#L103) | — | — | — | — | +| [native/object-import/src/workspace/engine/diff.rs](../../native/object-import/src/workspace/engine/diff.rs) | — | — | — | [260](../../native/object-import/src/workspace/engine/diff.rs#L260) | — | +| [native/object-import/src/workspace/engine/inventory/patterns.rs](../../native/object-import/src/workspace/engine/inventory/patterns.rs) | [106](../../native/object-import/src/workspace/engine/inventory/patterns.rs#L106), [112](../../native/object-import/src/workspace/engine/inventory/patterns.rs#L112) | — | — | — | — | +| [native/object-import/src/workspace/engine/read_dependency_cache/reader.rs](../../native/object-import/src/workspace/engine/read_dependency_cache/reader.rs) | [21](../../native/object-import/src/workspace/engine/read_dependency_cache/reader.rs#L21), [23](../../native/object-import/src/workspace/engine/read_dependency_cache/reader.rs#L23), [28](../../native/object-import/src/workspace/engine/read_dependency_cache/reader.rs#L28), [35](../../native/object-import/src/workspace/engine/read_dependency_cache/reader.rs#L35), [65](../../native/object-import/src/workspace/engine/read_dependency_cache/reader.rs#L65), [115](../../native/object-import/src/workspace/engine/read_dependency_cache/reader.rs#L115), [126](../../native/object-import/src/workspace/engine/read_dependency_cache/reader.rs#L126) | — | — | — | — | +| [native/object-import/src/workspace/engine/resolve_dependencies.rs](../../native/object-import/src/workspace/engine/resolve_dependencies.rs) | [136](../../native/object-import/src/workspace/engine/resolve_dependencies.rs#L136), [192](../../native/object-import/src/workspace/engine/resolve_dependencies.rs#L192), [203](../../native/object-import/src/workspace/engine/resolve_dependencies.rs#L203), [256](../../native/object-import/src/workspace/engine/resolve_dependencies.rs#L256), [265](../../native/object-import/src/workspace/engine/resolve_dependencies.rs#L265), [275](../../native/object-import/src/workspace/engine/resolve_dependencies.rs#L275), [328](../../native/object-import/src/workspace/engine/resolve_dependencies.rs#L328), [362](../../native/object-import/src/workspace/engine/resolve_dependencies.rs#L362), [364](../../native/object-import/src/workspace/engine/resolve_dependencies.rs#L364), [382](../../native/object-import/src/workspace/engine/resolve_dependencies.rs#L382), [392](../../native/object-import/src/workspace/engine/resolve_dependencies.rs#L392) | — | — | — | — | +| [native/object-import/src/workspace/engine/resolve_typescript.rs](../../native/object-import/src/workspace/engine/resolve_typescript.rs) | [39](../../native/object-import/src/workspace/engine/resolve_typescript.rs#L39), [53](../../native/object-import/src/workspace/engine/resolve_typescript.rs#L53), [61](../../native/object-import/src/workspace/engine/resolve_typescript.rs#L61), [65](../../native/object-import/src/workspace/engine/resolve_typescript.rs#L65), [126](../../native/object-import/src/workspace/engine/resolve_typescript.rs#L126), [142](../../native/object-import/src/workspace/engine/resolve_typescript.rs#L142), [156](../../native/object-import/src/workspace/engine/resolve_typescript.rs#L156) | — | — | — | — | +| [native/object-import/src/workspace/engine/source_overlay/lookup.rs](../../native/object-import/src/workspace/engine/source_overlay/lookup.rs) | [66](../../native/object-import/src/workspace/engine/source_overlay/lookup.rs#L66), [73](../../native/object-import/src/workspace/engine/source_overlay/lookup.rs#L73), [77](../../native/object-import/src/workspace/engine/source_overlay/lookup.rs#L77), [89](../../native/object-import/src/workspace/engine/source_overlay/lookup.rs#L89), [105](../../native/object-import/src/workspace/engine/source_overlay/lookup.rs#L105), [118](../../native/object-import/src/workspace/engine/source_overlay/lookup.rs#L118) | — | — | — | — | +| [native/object-import/src/workspace/engine/status_graph.rs](../../native/object-import/src/workspace/engine/status_graph.rs) | [138](../../native/object-import/src/workspace/engine/status_graph.rs#L138), [179](../../native/object-import/src/workspace/engine/status_graph.rs#L179), [188](../../native/object-import/src/workspace/engine/status_graph.rs#L188) | — | — | — | — | +| [native/object-import/src/workspace/engine/status_models/component_log.rs](../../native/object-import/src/workspace/engine/status_models/component_log.rs) | [58](../../native/object-import/src/workspace/engine/status_models/component_log.rs#L58), [247](../../native/object-import/src/workspace/engine/status_models/component_log.rs#L247) | — | — | — | — | +| [native/object-import/src/workspace/engine/status_models/lane_updates.rs](../../native/object-import/src/workspace/engine/status_models/lane_updates.rs) | [135](../../native/object-import/src/workspace/engine/status_models/lane_updates.rs#L135), [142](../../native/object-import/src/workspace/engine/status_models/lane_updates.rs#L142), [176](../../native/object-import/src/workspace/engine/status_models/lane_updates.rs#L176), [179](../../native/object-import/src/workspace/engine/status_models/lane_updates.rs#L179), [186](../../native/object-import/src/workspace/engine/status_models/lane_updates.rs#L186), [190](../../native/object-import/src/workspace/engine/status_models/lane_updates.rs#L190), [206](../../native/object-import/src/workspace/engine/status_models/lane_updates.rs#L206) | — | — | — | — | +| [native/object-import/src/workspace/engine/status_models/lane.rs](../../native/object-import/src/workspace/engine/status_models/lane.rs) | [29](../../native/object-import/src/workspace/engine/status_models/lane.rs#L29), [113](../../native/object-import/src/workspace/engine/status_models/lane.rs#L113) | — | — | — | — | +| [native/object-import/src/workspace/engine/status_models/list.rs](../../native/object-import/src/workspace/engine/status_models/list.rs) | [342](../../native/object-import/src/workspace/engine/status_models/list.rs#L342) | — | — | — | — | +| [native/object-import/src/workspace/engine/status_models/parent_diff.rs](../../native/object-import/src/workspace/engine/status_models/parent_diff.rs) | [135](../../native/object-import/src/workspace/engine/status_models/parent_diff.rs#L135) | — | — | — | — | +| [native/object-import/src/workspace/engine/status_models/versions.rs](../../native/object-import/src/workspace/engine/status_models/versions.rs) | [97](../../native/object-import/src/workspace/engine/status_models/versions.rs#L97) | — | — | — | — | +| [native/object-import/src/workspace/engine/status_models/workspace_diff.rs](../../native/object-import/src/workspace/engine/status_models/workspace_diff.rs) | [130](../../native/object-import/src/workspace/engine/status_models/workspace_diff.rs#L130) | — | — | — | — | +| [native/workspace-config/src/jsonc/lexer.rs](../../native/workspace-config/src/jsonc/lexer.rs) | [79](../../native/workspace-config/src/jsonc/lexer.rs#L79) | — | — | — | — | diff --git a/docs/rust/native-install.md b/docs/rust/native-install.md new file mode 100644 index 000000000000..38462cd6d7f7 --- /dev/null +++ b/docs/rust/native-install.md @@ -0,0 +1,302 @@ +# Native `bit install` + +`bit install` (alias `bit in`) runs in Rust. The command is parsed, planned and +executed by the `bit-install` crate (`native/install`). Package installation calls +pnpm's Rust engine (pacquet) directly as a library. It does not go through +`@pnpm/napi`, and no component is loaded through Harmony. + +JavaScript runs only where a component or aspect implements the behavior in +JavaScript. The engine asks for these **host callbacks** while it runs; the Rust +CLI serves them from its embedded host script, which loads the Node.js CLI's +aspects only when a callback is needed. The callbacks are: + +| Callback | Why it is JavaScript | +| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | +| `prepareInstall` | Before the package manager, the env requests see only the envs `InstallMain.install`'s component loads register (see below) | +| `envPolicy` | The dependency policy of a core env, or of a legacy env without `env.jsonc`, comes from the env's `getDependencies()` | +| `registeredEnvPolicy` | The same, for a component of an aspect env, only when it is already registered as an env (never loaded as an aspect to find out) | +| `dependencyPolicies` | Aspects and env aspects register dependency policies in their providers (`registerDependenciesPolicies`) | +| `appTypes` | Aspects register application types (file patterns) | +| `detectDependencies` | Custom dependency detectors: the env's `getDepDetectors()` (or `detectors()`) and the detectors aspects register (`registerDetector`) | +| `packageJsonProps` | Envs and aspects transform the generated `node_modules//package.json` | +| `afterInstall` | Compilation (env compilers) and workspace config files (env config writers) | + +Everything else is Rust: + +- argv parsing +- reading `workspace.jsonc`, `.bitmap`, `component.json`, variants, the merge-config file, `scope.json` and the root `package.json` +- importing missing objects +- dependency detection (except custom detectors) and policies +- manifest generation +- registry and network configuration +- the engine call, workspace manifest transforms and core-removal overrides +- the lockfile `bit:` block +- linking and pruning +- comment-preserving edits to `workspace.jsonc` +- console output + +## Entry point + +The Rust `bit` CLI (`native/cli`, see [the Rust CLI](../rust-cli.md)) runs the +command in process and serves the host callbacks with its embedded JavaScript +host. `hostInfo`, the host's first answer, carries only process facts: + +- bit's installation directories and the bvm directory, for linking core aspects; +- the Node.js executable; +- the global config directory; +- terminal width and color support; +- the core aspect package list. + +Workspaces using yarn decline to the Node.js CLI. + +Output follows Bit's logger. The native installer prints status and success +lines while Bit's loader exists, and console lines without a loader only when +`logger.shouldWriteToConsole` is on. Like `PnpmPackageManager.install`, it turns +the loader on after the package manager, and status lines are not shown on CI +(`logger.setStatusLine`). + +## Custom dependency detectors + +Precinct asks custom detectors before its built-in detectives: first the env's +detectors (`getDepDetectors()`), then those aspects register (`registerDetector`). +The first one supporting a file (`isSupported({ ext, filename })`) detects it alone. +`detectDependencies` takes the env id (without version), the component dir and +the component's absolute file paths. It runs `detectWithCustomDetectors` of +`@teambit/dependency-resolver`, which returns +`{ "": { dependencies, typescript, lookups } }` for the files a custom +detector supports, or `null` when the env has no detectors and every registered +detector is one Rust replicates (env.jsonc, MDX). `typescript` marks a file a +registered detector supports: `filing-cabinet` resolves its relative imports +through `lookupTypeScript`. The detected specifiers replace the native scan of the +file. After that they are resolved and classified like any other import: packages, +components, relative files and missing packages. `@bit-no-check` files and detector +errors yield nothing. + +`filing-cabinet` resolves a file's imports with a detector's `dependencyLookup` +when one applies: the lookup of the registered detector supporting the file, +replaced by that of the last env detector supporting it. The host calls it as +`filing-cabinet` does, unbound, with `{ dependency, filename, directory, config, +webpackConfig, envDetectors }` (`directory` is the component dir, `isScript` is +added for an object result), for every specifier except built-ins and http ones. +`lookups` maps each to the returned path (`path.resolve`d, as `fs.existsSync` +reads it) or to `null` when the lookup returned nothing; a lookup that throws +yields nothing for the file, as a resolve error does. Rust classifies a returned +path as `dependency-tree` does: an existing path under `node_modules` is the +package containing it (`resolvePackageData`; one without a `package.json` is +ignored), any other existing path a file of the component, of another component, +or untracked. A path that does not exist is missing: a relative specifier is a +missing file, a package is looked up by name (`MissingHandler`). When the lookup +returned nothing and the specifier has a different extension with a default +lookup, that lookup is tried first, as `filing-cabinet` retries. + +The host is asked only for a component that can have a custom detector. Its env +must not be a core env, because no core env implements `getDepDetectors()`, or a +non-core aspect must be configured, which could register a detector: on the +component, or at the root of `workspace.jsonc`. The CLI loads the root aspects on +start (`getNotLoadedConfiguredExtensions`), but `loadBit` does not, so the first +`detectDependencies` request carries them as `aspectIds` and the host loads them +before detecting. The aspects the components configure are loaded earlier, by +`dependencyPolicies`. An env the host reports without detectors is not asked about +again. Components of core envs therefore never reach the host in a workspace +without custom aspects. A non-core env loads the env in the host, but the host +already loads it for its dev patterns. + +The dependency cache entry is keyed by the component id and checked against file +times, as in TypeScript, and does not record detector output. An entry written by +an install that did not apply the detectors would be stale. A component a custom +detector applies to is therefore always detected, and its entry is written again. + +## Repeat installs skip `afterInstall` + +`afterInstall` loads Harmony to compile the workspace and write its config files. On +a repeat install that changed nothing, the native installer skips it and prints the +compile step's success line itself. + +After a compilation without errors and a complete config write, the hosts return the +paths both wrote: each compiled component's dist directories (`BuildResult.distDirs`, +including injected directories) and the config files. Rust then writes a stamp to +`/cache/native-install/after-install.json` with: + +- **an input fingerprint:** the bit version, `workspace.jsonc`, `.bitmap`, + `pnpm-lock.yaml`, and the size and modification time of every file under each + component directory except `node_modules`. The component loader also picks up files + `.bitmap` does not list, so directories are walked, not the `.bitmap` file lists. +- **an output snapshot:** the size, modification time and SHA-256 of every file in + those paths. + +The next install skips the step only when the fingerprint matches and every output +is intact. A file whose modification time moved is re-hashed, and it is intact when its +content is the same. + +There is one exception. The install engine re-syncs the injected copies of workspace +packages (`node_modules/.pnpm/@file+`) from the component directory, which +drops their dist on every install. Compiling writes the same bytes there again. So a +missing or changed output is copied back from an intact output with the same content +(the package's own dist) before the step is skipped. Any other source, config or +package change, and any output that cannot be restored that way, runs the step. + +The step always runs: + +- for programmatic installs; +- with `--skip-compile` or `--skip-write-config-files`; +- when an env failed to load; +- with the `no-fs-cache` feature; +- when a `link:` or `file:` dependency resolves outside the workspace's components, + its `node_modules` and the directories bit links its core aspects from, because + such a package can change without any input changing. + +No stamp is written after compile errors, a failed config write, or a config write +that left files bit did not generate, because each of those prints output every time. +An installed bit whose hosts don't report these paths never writes one either. Bit's +own code is identified by its version, so a development build changed without a +version bump needs one full install, or removing the stamp. + +## Crate layout (`native/install/src`) + +| Module | Responsibility | TypeScript reference | +| -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ | +| `options` | argv → `InstallOptions`, flag validation | `install.cmd.tsx` | +| `host` | `Host` trait (callbacks above), `HostInfo` | — | +| `jsonc` | JSONC parse and comment-preserving edits | `comment-json` usage in `workspace-config.ts` | +| `config` | Global bit config, `workspace.jsonc` aspects, `dependency-resolver` settings, `scope.json` | `dependency-resolver.main.runtime.ts`, `workspace-config.ts` | +| `workspace` | `.bitmap`, component ids, dirs, files, merged component config (bitmap > component.json > variants), env id, package name | `bit-map.ts`, `component-config`, `variants`, `componentIdToPackageName` | +| `policy` | Workspace, variant and env policies; `env.jsonc` with `extends` | `policy/*`, `environments.main.runtime.ts` | +| `dependencies` | Per-component dependency list: detection (scanner and cache), overrides, versions | `dependencies-loader/*`, `apply-overrides.ts` | +| `manifests` | Workspace and component manifests: dedupe, env peers, root components, linked root deps | `workspace-manifest-factory.ts`, `deduping/*`, `install.main.runtime.ts` | +| `engine` | pacquet driver: config overlay, registries, proxy and network, script policy, hooks, reporter, lockfile `bit:` block, restore, prune, version resolution | `lynx.ts`, `pnpm.package-manager.ts`, `pnpm-prune-modules.ts` | +| `links` | Core aspect `link:` deps, `node_modules/` linking, injected `package.json` links, bit-roots hard links, global virtual store links | `dependency-linker.ts`, `node-modules-linker.ts` | +| `import` | Pre-install object import (bitmap or lane objects) | `importer.main.runtime.ts` (`importCurrentObjects`) | +| `command` | Orchestration, install loop, external package manager mode, `allowScripts` persistence, `bit install `, output | `install.main.runtime.ts` | + +## Engine pin + +`native/Cargo.toml` and `native/install/Cargo.toml` pin all pnpm crates and +its `tar` patch to `6b5b8ac53a6120a644c3693ecac8aaed8af88174`, the +`@pnpm/napi` 12.16.0 release shipped by Bit (`workspace.jsonc`). The root +workspace also repeats pnpm's `dashmap` patch because Cargo does not inherit +patches from Git dependencies. The existing npm-compatible `node-semver` +extension remains pinned separately. + +This release includes the real generated CAS loader and the bounded stale-link +retry from [pnpm/pnpm#17403](https://github.com/pnpm/pnpm/pull/17403). A filesystem +that keeps returning a removed stale link now produces a diagnostic instead of +unbounded recursion. Bit still does not enable the experimental CAS store loader. +The native adapters return unchanged pre-resolution context and use the released +lockfile writer's refusal of writes through symlinked lockfiles. + +## Lockfile compatibility + +Workspace manifests are transformed before the engine call. Registry core dependencies are removed through pnpm overrides, and the active install passes no read-package hook or `pnpmfileChecksum`, matching the released integration. Historical graph restoration migrates `bit-1` checksums to those overrides when the graph has no explicit overrides. Merging restored lockfiles preserves compatible checksum, settings and override fields; an empty or link-only side does not invalidate the other side’s resolution context. A released-helper oracle covers 144 combinations of these fields and resolution inventories. + +## Envs before the package manager + +`InstallMain.install` computes the manifests from components it loads without +loading the envs among them as aspects (`getComponentsDirectory` passes +`loadSeedersAsAspects: false`), and `calculateEnv` resolves an env that is not +registered to the default env, `teambit.harmony/node`, without a warning in the +install context. An external env whose policy is in code (no `env.jsonc`) is +therefore not applied before the package manager, and its components get the +default env's policy, dev patterns and detectors. + +`prepareInstall` reproduces this. The first env or aspect request after it loads +the workspace aspects the CLI loads on start, then every component the way +`getComponentsDirectory` does. Until `afterInstall` (which follows the package +manager, as `reloadNonLoadedEnvs` does), an env request never loads an env: one +these loads did not register is the default env, and `dependencyPolicies` returns +the registry without loading the components' extensions (they load with +`loadExtensions: false`). An install that reaches no such request never loads +Harmony. + +## Registered dependency policies + +Policies registered through `registerDependenciesPolicies` are the slots layer of +`mergeVariantPolicies`: a component gets the registration whose registrar is, or +contains, one of the extension ids it configures. Registering runs in an aspect's +provider, so the host loads the candidate aspects and returns the registry (after +the package manager; before it, the registry as the install's loads left it). To +keep `bit install` from loading Harmony where nothing can register, the +candidates are the configured extension ids that are not core aspects (core +aspects and core envs never register one), and the component's env only when it +is an env aspect: + +- An env whose own env is `teambit.envs/env` is an env plugin (`*.bit-env.*`). It + is loaded without a provider, so it cannot register a policy and is not a + candidate. This holds whether or not it has an `env.jsonc`. +- Any other non-core env is an aspect whose provider may register one, with or + without an `env.jsonc`. A workspace env is classified by its own env, an + external env by the Version the scope holds. An env the scope does not hold + is a candidate, since only loading it tells. + +Most custom envs are env plugins, so their workspaces make no host call before +the package manager. An env aspect without an `env.jsonc` already loads Harmony +for `envPolicy`, so it adds no load either. + +## `resolveFromEnv` links + +Without root components, a policy entry marked `resolveFromEnv` is linked at the +root from where the component's env resolves it (`linkDepsResolvedFromEnv`). The +env's directory is a core env's directory in the running bit, or its package in +the workspace `node_modules`: a workspace env under its package name, an external +env under the name the scope records for its Version (`getPackageName`, which +applies a `teambit.pkg/pkg` `packageJson.name`). That is the name the env is +installed under; the default name is used only when the scope does not hold the +env. The root-components env package (`_getEnvPackage`) uses the same name. + +## Pre-install import + +The import requests what `importCurrentObjects` requests, and nothing more: + +- No artifacts: `ObjectFetcher` sends `includeArtifacts: false`, and the + objects-only import never calls `importAllArtifactsFromLane`. +- No `LaneHistory` objects: the lane object comes from + `getCurrentRemoteLane()`, which fetches with the default + `includeLaneHistory = false`. The local lane's history is still updated when + the fetched lane is merged into it, as `saveLane` does. +- No parent traversal (`collectParents`): the import does not pass + `allHistory`. +- With `BIT_FETCH_INCOMPLETE_VERSION_HISTORY` set, components whose local + `VersionHistory` is incomplete since the requested version are fetched again, + and complete histories record the version in `graphCompleteRefs`. + +A `resolverPath` in `scope.json` names a JavaScript module that resolves scopes +with no configured remote, instead of the hub. The module runs in the Node.js +that runs Bit, as a one-shot process, and gets the scope name, the workspace +scope name and the configured `user.token`. As in Bit, the path is `require`d from +`remote-resolver.js` of the `@teambit/scope.remotes` that Bit runs, so a relative +path or a package name resolves from there. Without that package it resolves from +the current directory. + +## Validation + +The same templates were installed with the Node.js CLI (`BIT_NATIVE=off`) and natively: + +- plain install and repeat install +- `bit install `, `@`, `--save-prefix`, `-t peer` +- `--skip-compile`, `--update`, `--lockfile-only` + +The comparison covers console output, `pnpm-lock.yaml` (including the `bit:` block and `pnpmfileChecksum`), `workspace.jsonc`, the `node_modules` tree, link targets and the generated component `package.json` files. All of these match. The only differences are throttled progress-line fragments. + +These install e2e suites passed on the native engine, run through the earlier +integration that loaded it into the Node.js CLI: + +- `install` +- `install-and-compile` +- `install-missing-dependencies` +- `dependencies/allow-scripts` +- `root-components` + +Untracked relative dependencies are checked against the released CLI for direct files +and directory indexes across all 15 released extensions, including TypeScript and +stylesheets. The native engine also recognizes empty stylesheet dependency leaves. + +## Known differences + +- Workspaces configured with the yarn package manager decline to the Node.js CLI, because yarn is a JavaScript package manager. +- Released Bit 2.2.146 comparisons cover empty-workspace and missing-object import progress, output, and full local/remote state. Empty imports do not announce zero components, and imported-object counts describe received objects, including already-present objects, as Node does. pnpm's throttled download progress still varies with scheduling. + +## CI + +The `Rust` workflow (`.github/workflows/rust.yml`) runs the crate's unit tests with +the rest of the native workspace on Linux, macOS and Windows. The ported E2E suites run the Rust CLI against a pinned released Node CLI; strict differential fixtures replay both implementations on the same workspace. Windows link tests execute in the Windows Rust job. `native/plans/TEST_PORTING.md` records coverage, explicit delegation and upstream exceptions. + +Restored graphs deduplicate snapshot dependencies against the root manifest before resolving missing package metadata. Exact root versions and root-provided peers take precedence; optional-only providers and explicit overrides retain their original resolution. Unreachable entries are removed before contacting the registry. A released Bit 2.2.146 converter comparison covers twelve root-provider cases. diff --git a/docs/rust/upstream-sync-2026-10-26.md b/docs/rust/upstream-sync-2026-10-26.md new file mode 100644 index 000000000000..58ac2d2f3171 --- /dev/null +++ b/docs/rust/upstream-sync-2026-10-26.md @@ -0,0 +1,908 @@ +# Upstream sync: 2026-10-26 + +Previous shared ancestor: `8acc4d603abbb644efd60de978c1202983d75bc9`. +Initial upstream target: `833c8928132c61c72d73aa7bee054a05d8dcc56c`. +Publication follow-up target: `99e0f30dbcda684161b81ef6578ff61713358d73`. +Integration starting point: `beb177e26186a1dc9f837c76e3dac305365cd5e3`. + +The interval contains 57 commits, including 26 release-version bumps. The merge applies cleanly. It must retain upstream ancestry: squashing the upstream merge would make upstream TypeScript changes appear to be Rust-owned changes to the scope guard. + +The released parity oracle advances from 2.2.94 to 2.2.146. The initial baseline against the new release completed with 1,508 passing, 102 failing and two ignored E2E cases; focused qualification follows below. The regenerated E2E index is fully accounted for: new scenarios have executable mappings, and upstream-skipped or invalid fixtures have explicit explanations. Delegated cases are labeled as such. + +| Upstream change | Native impact and qualification status | +| --- | --- | +| #10854: in-memory dependency-graph filtering and missing root-snapshot fallback | Implemented reachable-only filtering, missing root/entry/snapshot fallback, and cycle-safe traversal. Released 2.2.147 filter/converter comparisons preserve exact graph data and edge order. The 2.2.146 path retains that release’s filtering behavior. | +| #10838: move unexported main snaps when creating a lane | Qualified staged-snap transfer, exported-main restoration, original-lane identity, cross-scope export, and closed version history. Switching back to main excludes components moved entirely onto the lane. Rejected lane creation compares exact released errors through pre-effect delegation. | +| #10815: component peers and plus ranges | Qualified plus ranges, policy and manifest consumers, peer metadata, and snapshot feature guards. A lane-to-main-to-scope-tag fixture retains component peers through checkout and capsule creation. Scope tagging and component-peer builds remain explicitly delegated; the build declines before writing capsules. | +| #10841: retain added type dependencies during merge | Implemented: TypeScript type-package discovery checks workspace policy, incoming automatic merge policy, then model dev dependencies; an explicit removal stops fallback. Eleven detector tests pass, including precedence/removal cases across all policy fields. A released lane-merge comparison retains the incoming type package and matches reports and dependency metadata, masking only the newly allocated snap hash. | +| #10833: import directory ownership collisions | Scoped collisions retain owner-prefixed destinations, duplicate-name bitmap keys and ordered warnings. Same-path import comparisons cover re-import and stored state. The dependent-installation regression is fixed by preserving sibling dependency manifests for pnpm injection. Unscoped collision identities still decline before writes. | +| #10836: remove without loading removed environments | Qualified source-only modification checks, effective package/aspect metadata, lane-only history and missing-main cache invalidation. The soft-removal snapshot workflow preserves its pre-effect fallback where hosted callbacks require it. | +| #10822: planned build tasks on unbuilt versions | Implemented task-descriptor transport from the published builder and native Version allocation; the 14 snapshot-environment tests pass. The soft-removal workflow now passes through pre-effect delegation where needed. Stash retains stored planned-task metadata, including with configured aspects. Full-suite results and remaining failures are recorded below. | +| #10831: replace the previous environment in scope snapshots | Audited: scope snapshot mutation remains owned by the published SnappingMain; the Rust workspace snapshot engine does not implement snapFromScope. The upstream environment replacement enters through the unchanged published fallback. | +| #10832: load scope applications only when building | Audited: scope aspect loading and application build callbacks remain in the published SnappingMain. Workspace snapshot fixtures retain registered application metadata and planned build tasks. This is an explicit hosted boundary, not a Rust implementation of scope snapping. | +| #10829: deduplicate restored graphs against capsule roots | Ported root-manifest deduplication before graph package resolution: exact root versions and peer providers win, optional-only providers and explicit overrides are preserved, and unreachable packages are removed before registry resolution. Released conversion qualification is recorded below. | +| #10823 and #10808: scope dependency and lane merging | Audited: bare-scope dependency and lane merging remain delegated to the published runtime, including merged-source dependency correction. Rust workspace merge fixtures cover retained type dependencies and component peer lifecycles; the bare-scope test helper calls the published API explicitly. | +| #10818: restore only tracked lane configuration in CI merge | Native CI merge preserves tracked lane configuration and uses the released force-ours switch. Cross-scope fixtures cover lane host identity, merge/export and Git state. | +| #10840: commit main bitmap when CI merge tags nothing | Native CI merge commits and pushes the main bitmap even when no component is tagged, and propagates child switch/tag/export failures. The no-change CI fixture passes. | +| #10819: resolve CI branches against the lane host scope | CI sync remains delegated. Released fixtures cover foreign host scopes, exported branch commits, unrelated branches, missing origins and closed pull requests; delegation is asserted rather than counted as a native port. | +| #10820 and #10835: CI onboarding output and agent instructions | CI onboarding remains delegated. Fixtures cover repeat initialization, unrelated staged files, ignored workflow files, initial commits, GitHub commands and enterprise webhooks. The helper’s Node-only control now actually forces delegation. | +| #10827: ripple log identity resolution and error flag | Audited: native Ripple handles only the bare command and invalid list limits. Log identity lookup, error reporting and the renamed full-log flag delegate unchanged to the installed CLI; native remote execution remains outstanding. | +| #10848, #10843 and #10773: module and type relocation | Published adapters are exercised by installation, snapshot, environment and import fixtures against 2.2.146. Type relocations require no Rust runtime duplicate; the upstream source refactors enter through the ancestry-preserving merge. | +| #10825, #10821 and #10802: browser comparison and preview fixes | Browser UI changes, outside the native CLI command/runtime scope; upstream files enter through the merge unchanged. | +| #10837: UI development environment migration | Upstream UI build configuration; no native command implementation to duplicate. | +| #10842 and #10830: public generation and hygiene checks | Preserve upstream checks; scan new Rust artifacts and public messages for private command invocations and identifiers. | +| #10851, #10853, #10850 and #10834: CI/runtime/package tooling | Preserved upstream tooling. All pnpm engine crates are pinned together to the 12.13.0 backport `0745ab835ad21f0f0284b1858fdbb3ea8d6b7b53`, containing the real bundled CAS loader. The preparation stub and workflow workaround are removed. Upstream fix: pnpm/pnpm#17388. Historical graph restoration retains resolution context and migrates legacy hook checksums; the released-helper comparison covers 144 context combinations. | + +A row becomes qualified only after its implementation and appropriate released-oracle tests pass. A Node delegation is not recorded as a Rust port. + +## Publication validation + +The merge includes upstream master through 2.2.147 and preserves application-option parsing and show-help changes. The full-suite oracle remains pinned to released Bit 2.2.146; the new graph algorithm is separately compared with 2.2.147. This sync does not claim that every command runs entirely in Rust. + +The previous published checkpoint had 29 reproducible failures. Those failures are addressed by accepting configured versioned dependencies, restoring component peer metadata, preserving injected sibling manifests, and updating onboarding fixtures to the current release. Every failure from the earlier storage-affected complete run passed a focused rerun. + +Additional qualification covers all 15 released relative-file extensions and directory indexes, empty pre-install imports and received-object progress counts, cross-scope CI merges, and lane peer restoration. No test or CI check was disabled. The final complete E2E run records **1,635 passed, one failed and two existing skips**. The sole failure is the Node-only control fetching the published aspect from Bit Cloud; the complete failed case passes on an immediate rerun. The preceding run recorded 1,633 passes and three failures: two snapshot-input checks and a Node-control network request. Both snapshot cases pass in the final run and in nine additional bitmap stress runs; no weakening or retry was added to the harness. + +The workspace suite covers 1,798 passing tests and one existing skip. Formatting, Clippy, Dylint, rustdoc, the generated index, packaging tests and scope checks pass. Separate released-oracle checks pass all 14 snapshot-environment cases on 2.2.147, the reachable graph comparisons, twelve restored-root graph conversions on 2.2.146, and 144 historical context combinations. + +Native distribution builds five bvm archives plus a musl binary. Bvm 3.1.7 selects the native entry; an isolated installation with that released package successfully ran the native command and its Node fallback. Distribution remains opt-in and does not change bvm’s default release index. + +## Publication follow-up + +Five further upstream commits landed during qualification, including release versions 2.2.148 and 2.2.149. They are merged with ancestry preserved. #10856 injects the existing remote resolver into the legacy scope provider and re-exports the relocated errors; the native inventory host still calls the published resolver directly, and hosted scope operations load Harmony before invoking the importer. No native protocol or fetch semantics change. #10857 removes obsolete TypeScript suppression comments. #10858 replaces dependency-tree configuration with an equivalent class and makes type declarations explicit; defaults, cloning and resolution behavior remain unchanged. + +The suppression-comment removal shifted E2E source locations and made the generated porting index stale in GitHub’s upstream merge checkout. Regenerating the index against this merged tree updates the locations without changing case coverage or marking additional cases complete. + +A repeated full-suite run exposed test interference: the root-clone fixture changed the shared global remote registry while parallel snapshot and checkout commands were validating it. Diagnostic paths identified `global-remotes.json`; the fixture now uses its own global configuration for remote registration and every clone invocation. Native input validation is unchanged. + +Follow-up qualification: formatting, workspace/E2E Clippy, Dylint, rustdoc, the 1,798 workspace tests and the generated porting index pass. All 37 focused root-clone, bitmap and CI-sync cases pass together. The final full E2E run at 16 threads records 1,632 passes, four failures and two existing skips. Two failures are published environment fetches; two registry-update cases leave their old versions unchanged. All four complete cases pass on focused reruns. No retry or weakened assertion was added. The published native implementation at `df2ce7438` also passes all platform, E2E-shard and Windows workspace jobs in [run 40373417279](https://github.com/zkochan/bit/actions/runs/40373417279). + + +## Follow-up sync on 2026-10-27 + +The next sync advances from `3ab125f2df4329b256a4fda904478cb02375b756` to +`f74f23c8ef59e90206b35e84ceb06bd524686391` (Bit 2.2.157), seven commits. + +| Upstream change | Native disposition | +| --- | --- | +| #10859: scope/object TypeScript migration cleanup | Type assertions and optional fields preserve runtime object formats. Buffer-to-string parsing preserves existing JSON coercion. Removed unused legacy helpers have no native callers. Remote latest-version requests already serialize ID arrays directly; the repaired remote-map validation has no production call sites in the synced tree. | +| #10861: CLI and parser TypeScript migration cleanup | Error rendering, doctor flags, documentation parsing, component diff, removal, fetch reporting and snapshot result fields retain their observable behavior. Empty package arrays still disappear after filtering; snapshot lane-name insertion order is unchanged. No Rust behavior change is needed for these valid inputs. | +| #10864: installer engine 12.16.0 | All pnpm Git dependencies advance together to the release commit, including the direct override-parser dependency and tar patch. The upstream dashmap patch is retained for Cargo resolution. Native adapters follow the new hook-result, install-policy and symlinked-lockfile APIs. The upstream stale-link retry regression is run from the exact pinned engine source. | +| Four version bumps and regenerated metadata | Merged without editing the Node implementation. | + +The cross-scope hidden-update merge regression also completes the outstanding +workspace merge assertion from the previous sync. Main-history prefetch includes +hidden lane identities, and multiple histories received in one operation merge +against retained history rather than discarding the earlier lane history. The +fixture still uses the released scope-side producer; those two producer assertions +remain open. Removing either native history fix makes the workspace merge fail. + +Bit 2.2.157 is not yet downloadable, and released 2.2.156 still bundles pnpm +12.13.0. The E2E reference therefore keeps released Bit 2.2.146 and resolves its +pnpm dependency to the published 12.16.0 addon through an isolated test preload. +The addon and platform binaries are integrity-pinned in the test reference lockfile; +CI installs them without package scripts. No Bit implementation is replaced. +Comparisons retain new validation digests, manifest fingerprints, file sizes and +settings. Only validation recording and file-stat timestamps are projected after +checking their types and time bounds. The original version mismatch is not masked. + +Qualification: formatting, Clippy, Dylint and rustdoc passed; all 1,800 workspace +tests passed, with one existing ignored test. The final full E2E run recorded +1,658 passes, two Bit Cloud request failures during released-CLI fixture setup +and two existing skips. Both failed cases passed unchanged on a serial rerun. +The new cross-scope merge regression fails independently when either history fix +is removed. The pinned pnpm stale-symlink regression and the new symlinked-lockfile +preservation test pass. Generated porting and runtime-boundary checks pass. +Repository Node lint remains unavailable because `tsc` is not installed. + +The Windows transfer fixture now uses the released CLI explicitly for its setup +snapshots: the tested operation is native cross-scope fetching, including exact +output, model, version and ref comparisons. The prior Windows failure was a setup +snapshot declining because its environment package was absent, before that fetch +comparison ran. No native fetch assertion was removed. + + +## Follow-up through Bit 2.2.160 + +This sync advances from `f74f23c8ef59e90206b35e84ceb06bd524686391` to +`e564f07658bdcc541d4ff4c66ec6c4751ef21d04`, preserving all six upstream commits. +The generated test inventory includes the two new main recovery assertions; +the stale inventory caused the formatting job to fail on GitHub's synthetic +merge of the previous Rust head with this newer master. + +| Upstream change | Native disposition | +| --- | --- | +| #10865: indexed version-parent traversal | Cached and newly loaded history use indexed membership and replacement while preserving insertion and preorder traversal order. Subset traversal uses first-entry lookup and indexed stop boundaries. A deep-history regression covers overlay replacement and stop order. | +| #10860: TypeScript migration cleanup | Type annotations, explicit identity-string keys and removed unused constructor arguments do not change native wire formats or behavior. The existing status error-field behavior is preserved. | +| #10846: aborting a lane merge into main restores component models | Native merge snapshots all selected main models before publication. Abort restores their canonical objects, updates retained import proofs and removes pending model overlays so later import publication cannot replay the merged heads. Missing legacy backups remain compatible; malformed snapshots and restoration requiring index repair decline before effects. | + +The two new upstream recovery assertions run natively: abort restores the original +main head and leaves no merged snaps staged for export. Disabling the new snapshot +makes the head assertion fail. Serialization uses the existing released-qualified +component projection. Bit 2.2.160 is not yet downloadable from the release service, +so these new upstream semantics are covered by explicit regression assertions; +a differential run against that release remains pending its availability. The +full-suite released reference stays at Bit 2.2.146. + +Collection qualification also adds bare verbose counts and object identities, +workspace verbose text/JSON suppression, legacy aliases and boxed names, and scoped lookup without a name-only +fallback. Missing alias targets delegate before deletion. Mixed Version/Source +workspace reports remain delegated because their property and text order depends +on the released glob traversal. Runtime callbacks and the other documented +collection boundaries remain open. + +The installer compatibility comparisons retain exact scope state and add only the +three expected entries for the new main-model backup (directory, object bytes, +and file mode). The expected object is the pre-merge model written by the released +Node fixture, not a native-generated expected value. The older reference still +supplies every other output, file, remote and callback assertion. The first full +run had 1,702 passes, these two expected upstream-delta failures, one Bit Cloud +setup failure and two existing skips. Both installer cases pass with the explicit +backup assertion, alongside all 36 focused collection/recovery cases. Disabling +workspace verbose logging makes its exact stdout comparison fail. + +Final qualification: formatting, Clippy, Dylint, rustdoc and all 1,809 workspace +tests pass. Full E2E records 1,704 passes, two Bit Cloud failures during released +fixture setup and two existing skips; both failed cases pass unchanged in a serial +rerun. Generated inventory/census/asset and scope checks pass. Windows CI now +explicitly includes the lane-abort regressions. Repository Node lint remains +unavailable because `tsc` is not installed. A newer upstream history-membership +optimization landed during validation and is the next sync audit. + +## Indexed history-merge follow-up + +Upstream `3754c4f5754aa82394141a3f807f3403c26a30e2` replaces linear membership +checks in `VersionHistory.merge` with a set. Native `operation::history` already +uses sets for both incoming and stored membership, and `command_import::history` +keeps incoming rows first before appending existing-only rows. No algorithm +replacement is needed. Added regression coverage verifies 40,000-row histories, +incoming override data and ordering, both empty-side cases, and preservation of +existing graph metadata. The upstream performance test's wall-clock threshold +is not copied into the native correctness test. + +The preceding Linux CI run exposed a host-transport test fixture race. Repeated +local runs with the hidden call error included reproduce `ETXTBSY` when executing +the fake Node program just written by the test. The fixture now uses an immutable, +checked-in shell executable and writes only per-session response data. Script +lifetime, exact script contents, callback errors and cleanup assertions are +unchanged. The original fails on iteration 34 at 64 test threads; the fix passes +all 60 complete CLI unit-suite stress runs at the same concurrency. + +Validation: formatting, Clippy, Dylint, rustdoc, generated checks and all 1,811 +workspace tests pass. Full E2E: 1,703 passed, three failed and two existing skips. +Two failures were Bit Cloud requests during fixture setup; the third was a +dependency-capture freshness failure in the environment-policy tagging case. +All three pass unchanged in a serial rerun. The intermittent freshness failure +remains under investigation; no proof validation was weakened. Node lint cannot +start locally because `tsc` is unavailable. Windows merge-history CI on the +preceding head passed, including the new lane-abort assertions. + +## Default-environment formatter orchestration + +Rust now selects components, loads their sources, prepares native dependency, +history and documentation caches, invokes external Prettier and renders formatter +reports. The external tool adapter requires only Prettier and Node built-ins; +this is not a Rust reimplementation of Prettier or a Node-free distribution. +An execution fixture rejects Bit module loads and still runs the native command. + +Coverage includes literal/glob selection, changed-only selection, ordinary and +CI JSON, text, empty workspaces, configured names, new/tagged identities, nested +source checks, single-file writes and repeated clean checks. Read-only source +writes retain the released successful exit with an EACCES entry in the JSON +errors array. Documentation-cache preparation retains existing cache values; +its AST proof additionally covers zero-argument arrows returning literal strings, +numbers or booleans, while null-returning/parameterized/async arrows and other +semantic parser inputs still delegate when their documentation is not cached. + +The released loader returns cached components in completion order. A strict +Node-to-Node replay of one unchanged two-component fixture produced both full +result permutations. Only this two-row JSON order is normalized in the test; +identities, cardinality, fields, file order, formatting bytes, command formatting, +exit status, caches, workspace state and the real remote remain compared. The +initial quiet probes copied timestamps incorrectly and invalidated the cache; +they were replaced with same-path replay preserving the complete freshness state. +Larger cached batches, cached text reports and concurrent writes remain guarded. +Custom environments/providers and semantic documentation parsing are still open. +Windows provisioning CI includes these formatter cases. + +Negative controls verify that disabling native dispatch fails the ownership +assertion and disabling documentation-cache publication fails the complete scope +comparison. Nested source order follows the released breadth-first loader. + +Validation: formatting, Clippy, Dylint, rustdoc and all 1,811 workspace tests pass. +The final full E2E run passes all 1,715 active cases, with two existing skips. +An earlier run had one Bit Cloud request failure during capsule fixture setup; +that case passed unchanged before the final full run. Generated inventory/census, +asset and scope checks pass. The preceding PR head has no failing CI checks. +Repository Node lint remains unavailable because `tsc` is not installed. + +## Pending-import status partition + +Merged upstream through `c15f6da17` (Bit 2.2.162). Since the preceding sync at +`3754c4f5754aa82394141a3f807f3403c26a30e2`, the only behavioral change is +`3b52056c6e17d1c0b5a8947a229fda5b62b76072`: pending-import components no longer +appear a second time as invalid components. Version/bitmap metadata and the +status component's test-dependency lockfile changes are inherited unchanged. + +The native status engine now partitions pending-import and other invalid +components in input order. The mixed-error regression retains ordinary invalid +errors and verifies that invalid entries do not enter modification or auto-tag +results. Restoring the old duplicate classification makes the regression fail. + +Released Bit 2.2.146 still produces the duplicate classification; the 2.2.162 +release artifact was unavailable during qualification. The existing real-workspace +regression therefore asserts the exact released duplicate row before applying +only the explicit upstream removal to its expected JSON. All other fields, +exit status and stderr remain compared. The text regression asserts the entire +old invalid section before replacing it with the existing clean summary that +upstream now renders when no other sections exist; the import warning and +loading prefix are preserved. This is an explicit upstream delta, not a claim +that the released oracle already contains the fix. + +Validation: formatting, Clippy, Dylint, rustdoc and all 1,812 workspace tests pass. +Full E2E: 1,714 passed, one Bit Cloud request failed during released-CLI MDX +environment setup, and two existing cases were skipped. The failed fixture passes +unchanged on a serial rerun. Generated inventory/census/assets and scope checks +pass. Repository Node lint still cannot start because `tsc` is not installed. + +## Built-in lint orchestration and formatter platform fixes + +Linting now shares the Rust component loader with formatting. Rust owns default +Node-environment selection, dependency/history/documentation cache publication, +generated TypeScript configuration, diagnostic totals, reports and single-file +fixes. ESLint remains an external Node tool. Built-in configuration data is +embedded from public `@teambit/react.eslint-config-bit-react` 2.0.27 (Apache-2.0); +other versions decline before effects. Tool preparation resolves metadata and +third-party dependencies but imports neither Bit runtime nor Bit configuration +modules. A preload fixture rejects all Bit module imports during native linting. + +The generated config's name reproduces `object-hash` for its fixed plain-object +schema, including prototype references and UTF-16 string lengths. Independent +library probes match 101 configurations with Unicode paths. Regression tests +also assert known library hashes and the actual released generated filename. +The command preserves existing canonical configs; noncanonical configs delegate. +An external-tool process is prepared before cache publication and retained until +linting completes, with cleanup on early exits. After publication, config/source +write failures are owned service errors and never replayed through fallback. + +Released-CLI comparisons retain complete workspace/cache/remote state and raw +ESLint diagnostics, including generated config paths, bytes and permissions. +Only the validated duration changes. Cases cover clean/cached runs, warnings, +errors, autofixes, changed/new selection, empty/configured workspaces, nested +files, CI/colors, read-only sources and read-only config directories. The latter +exposed directory modes lost while copying comparison fixtures; the helper now +restores those modes along with timestamps, with a separate regression. +Multiple components, concurrent fixes, custom services and semantic documentation +cache misses remain guarded. Fixing rich source can invalidate its documentation +cache; subsequent cache misses retain that explicit boundary. + +Windows provisioning CI exposed two formatter defects. The released formatter +package uses `fs-extra.outputFile` directly, preserving Prettier's LF bytes; it +does not use the legacy vinyl writer's platform EOL conversion. Native formatting +now preserves those bytes on Windows too. Preload fixtures use quoted forward-slash +paths because Node's options parser consumes backslashes. The same fixes apply +to lint preload tests. Formatter checks also guard ignored extensions and now +retain new components whose status-context version is the empty string when +selecting changed components. Windows provisioning CI includes both services. + +Validation: formatting, Clippy, Dylint, rustdoc, generated plan/census/asset and +scope checks pass; all 1,814 workspace tests pass. Full E2E: 1,725 passed, two +existing skips and one Bit Cloud request failure during released-CLI fixture +setup. That case passed unchanged on a serial rerun. All 20 focused service and +fixture-permission cases pass. Negative controls catch disabled native dispatch +and incorrect generated config filenames. Windows CI for this batch is pending. +Repository Node lint remains unavailable because `tsc` is missing. + +## Built-in React component services + +Formatting and linting can now load qualified built-in React components without +the published Bit runtime host. Rust supplies the released React dependency +policy, development-file patterns and compiler facts, retaining native cache, +selection and report ownership. A dedicated component-service mode preserves +the existing lifecycle requirements of other operations. Startup still rejects +custom workspace/scope/global providers, configured requires, lane hydration +and bitmap auto-sync. Mixed environment groups remain guarded before effects. +Unsupported dependency syntax also declines before starting a Bit parser host. + +Differential fixtures exercise React formatting and linting on repeated caches, +changed selection, real dependency metadata, diagnostics, fixes, CI/colors and +read-only write failures. Errors identify the selected environment. An external +ESLint preload rejects all Bit module imports. External ESLint/Prettier and their +installed distribution metadata remain requirements; this is not a standalone +runtime or arbitrary JavaScript environment implementation. + +Validation: all 1,814 workspace tests and formatting, Clippy, Dylint, rustdoc, +generated plan/census/asset and scope checks pass. Full E2E: 1,729 passed, two +existing skips and five external request failures (two Bit Cloud fixture setup +requests and three registry timeouts in existing Yarn cases). All five passed +unchanged in a serial rerun. All eight new React comparisons pass; the peer-policy +negative control fails as intended. Yarn implementation remains excluded. +Windows provisioning passed on the preceding lint/formatter revision, confirming +its platform fixes; CI for this React batch is pending. Local Node lint still +requires the unavailable `tsc`. + +## Capsule fixture network verification + +The remaining-core-environment capsule comparison now configures five built-in +environments directly in its fixture. Independent released-CLI replay confirmed +that these are the exact component config entries written by environment +assignment and that all five resulting capsule manifests are identical. The +comparison still runs the actual released capsule command, requires native +ownership and compares complete manifest bytes and command output. A preload +rejects JavaScript HTTP/HTTPS/fetch requests during these five comparisons. + +The versioned custom-aspect environment retains released assignment and metadata +import: replacing it with an unversioned entry changes a manifest dependency, +and the correctly versioned entry needs imported objects. It therefore remains +a network-dependent fixture. No retry, timeout or assertion was weakened; this +removes only unrelated remote verification from the five built-in setups. + +Validation: formatting, Clippy, Dylint, rustdoc, all 1,814 workspace tests and +generated plan/census/asset/scope checks pass. Full E2E: 1,733 passed, two existing +skips and one Bit Cloud request failure in the unchanged React Native capsule +comparison. That case passed unchanged on a serial rerun. The revised remaining +core-environment comparison passed both focused and full runs. Platform CI is +pending; local Node lint remains unavailable because `tsc` is missing. + +## Upstream history indexing sync + +Merged upstream through `7c0228a4f` (Bit 2.2.163), following `c15f6da17`. +The delta contains history/export lookup indexing, the version bump and stale +TypeScript suppression removal. Rust history queries now index supplied versions +once per walk, preserve the first object for duplicate hashes and use indexed +stop boundaries. History population indexes existing hashes and accumulated +versions, preserving insertion order while replacing duplicate accumulated rows. + +The upstream population change has one observable delta: full traversal of a +merge diamond now returns each shared ancestor once. Running the actual method +extracted from both upstream revisions confirmed this difference and unchanged +default traversal. The Rust regression failed on the previous implementation +and passes after the port. Additional regressions preserve first-object lookup, +stop boundaries and missing-parent results. These are explicit upstream-delta +checks; the released CLI remains the oracle for unchanged behavior. + +Existing native export/history collection already uses indexed visited sets. +The upstream export filtering/index reuse and removed suppressions introduce no +additional command contract. Existing native bounds and pre-effect guards remain; +this sync does not claim arbitrary provider or compiler support. The generated +E2E plan follows the upstream source-line change. + +Validation: all 1,816 workspace tests, formatting, Clippy, Dylint, rustdoc and +generated plan/census/asset/scope checks pass. Full E2E: 1,733 passed, two existing +skips and one Bit Cloud fetch failure during released-CLI setup for the existing +nonstandard-main capsule case. That case passed unchanged on a serial rerun. +The regenerated plan also fixes the prior PR lint job's stale graph-test line +reference when GitHub combines the branch with the updated upstream master. +Platform CI for this sync is pending; local Node lint still lacks `tsc`. + +## Built-in compiler orchestration and byte parity + +Replaced the handwritten transpiler and placeholder source maps with external +TypeScript transpilation under Rust-owned component loading, selection, scheduling, +dist persistence, package linking and reports. Qualified built-in Node and React +commands no longer load the published Bit runtime. Rust retains documentation and +dependency-cache effects, repeated and changed selection, duplicate-selector load +counts, stale dist files unless deletion is requested, copied-file ordering, +platform text line endings, injected packages and root-component copies. Generated +manifests retain optional package, peer and component dependency metadata. + +TypeScript diagnostics preserve text/JSON differences, including JSON's released +zero exit code for compilation diagnostics. A narrow external TypeScript ESTree +parser supplies malformed-source diagnostics to the native dependency loader; +source hashes, graph resolution and cache ownership remain native. The external +tools resolve through installed distribution metadata. A preload rejects Bit +module imports for valid compilation and syntax-error handling. This is not a +standalone compiler distribution or an arbitrary environment implementation. + +Filesystem failures use the shared released error formatter, including CI details +and colors. Independent released-command replay established the tested first-open +failure's partial-write state: a pending sibling output is truncated, not completed. +Rust opens each configured I/O batch before writing it, retaining that state on +failure and respecting configured single-file batches. The regression compares +complete output bytes, caches, manifests and the unchanged remote; it does not +mask failed-write contents. Other concurrent-failure interleavings are not claimed +as qualified by this fixture. + +Fourteen differential cases cover emitted code/maps, repeated commands, new/changed +selection, diagnostics, assets, optional metadata, copies, CI/colors and owned write +failures. Negative controls detect both the previous missing loading messages and +corrupted source-map bytes. A cold-cache regression after dependency rewiring +now proves that bounded data-only `console.log` calls have no documentation; +released-parser replay confirms the empty result. Disabling that proof fails +the new native-execution assertion. Two obsolete unit assertions for the old transpiler +and incorrect empty JSON success were removed; the released no-selector JSON +failure remains explicitly delegated. Windows provisioning includes the new suite. +Custom providers/environments, mixed groups, type generation, rich documentation +cache misses, unsupported dependency syntax, binary sources, colliding outputs +and ordered multi-component reports remain pre-effect guards. + +Validation: formatting, Clippy, Dylint, rustdoc and all 1,814 workspace tests pass. +All 17 focused compiler/regression/capsule cases pass. The final full E2E run +reports 1,746 passes, two Bit Cloud failures in released environment assignment +and two existing skips; both failed cases pass unchanged on a serial rerun. +An earlier full run exposed the cold documentation-cache regression now covered +by the new comparison and negative control. Generated inventory/census, asset, +scope and public-data checks pass. Local Node lint still requires the unavailable +tsc. Platform CI for this compiler change is pending. + +The preceding head's E2E shard 3 failed on an extra timed pnpm progress frame. +Independent released-command replays reproduce that frame under slower scheduling; +a narrowly bounded comparison fix is prepared separately. The remaining runtime, +compiler, tester, build and service milestones remain open. + +## Installer progress scheduling qualification + +The preceding head's E2E shard 3 failed because its installer comparison retained +an optional initial progress frame with one resolved package before the two-package +store banner. Independent replays of the same restored fixture through the released +Node command reproduce both forms: ordinary scheduling coalesces the first frame, +while CPU contention on one core exposes it. Full outputs match after accounting +only for elapsed times and that initial frame. No reporter setting or clock hook +was changed for the replay. + +The comparison now accepts that specific zero-added one-to-two resolution advance, +as well as the already qualified identical-counter redraws. It preserves store +paths, the complete banner, later transitions, final counters, errors, reports, +provider assertions and filesystem comparisons. Released padded and unpadded banner +lines are recognized without changing their bytes. Other counter advances, reversed +counters, changed transfer/addition counts and malformed control sequences remain +unrecognized. Negative controls using both complete Node replays and the captured +CI failure fail with the old comparison and pass with the new one. + +Validation: formatting, Clippy, Dylint, rustdoc and all 1,814 workspace tests pass. +All 17 focused progress/provider cases pass. Full E2E: 1,749 passed, one Bit Cloud +failure while creating a React fixture and two existing skips. The failed case +passes unchanged on a serial rerun. Generated inventory/census, asset, scope and +public-data checks pass. Local Node lint still lacks tsc. CI for the preceding +compiler head has no reported failures at publication time, with jobs still +running; the new head requires its own platform checks. + +## Built-in tester orchestration and Windows compiler paths + +Rust now owns single-component Node/React JSON test selection, native loader/cache +publication, test discovery and Bit result/error projection. External Jest 29.7 +runs user tests, with external Babel and native-owned JavaScript configuration +adapters. Installed distribution metadata resolves these tools; no Bit runtime +module is imported on the qualified path. Configuration versions are checked before +workspace effects. Stable native-owned assets use their own cache directory, so +opaque global Jest transform-cache identities differ from the installed runner. +This is not a standalone distribution or an arbitrary environment implementation. + +Cold test callbacks, TypeScript/TSX sources and Jest snapshots now have bounded +AST proofs for empty documentation where appropriate. Released-parser comparisons +confirm these results; exported component/documentation shapes remain guarded. +Snapshots preserve exact workspace bytes on first write, repeated execution and +mismatch. A preload rejecting Bit imports verifies external-tool-only execution. +Twenty differential cases compare reports, exit codes, workspace/scope/remote bytes +and loader caches, masking test start/duration fields and one qualified pair of parallel test-file +completion orders. Sixteen independent same-path released-command replays produce +both orders and otherwise identical complete reports. Jest retains its default +scheduling; the external adapter is a script file so forked workers do not inherit +an evaluated Node program. The cases cover pending/todo +assertions, assertion failures, empty selection, React, actual TSX runtime execution, +colors/CI, test-set exit codes, and side effects without replay. Direct test process +exit preserves both the exit code and the unfinished command-history entry. The +new exit regression failed before both lifecycle fixes; configured negative +patterns also failed before preserving the exclusion prefix. Unknown negated CLI +flags retain the released command errors in populated and empty workspaces. +In-band user tests retain the released process arguments and working directory. +Deliberately corrupting +the success field fails the report comparison. + +Coverage consumption is native, including pre-existing reports without a coverage +flag, reports written by user tests, malformed input, rounded totals and the +released string-prefix inclusion behavior. A directory without the summary file +is preserved. Cleanup failure returns the released structured environment error +without replaying tests. The regression exposed a fixture-restoration bug: a +read-only directory could make removal fail silently and retain outputs from the +reference run. Restoration now removes that state or fails explicitly; its own +regression verifies that cleanup does not follow symlinks or alter their targets. + +Text, multiple-component/environment groups, explicit file selection, coverage +collection flags, JUnit, snapshot-update flags, custom providers, watch/debug and broader lifecycle +modes remain open. Unsupported selections/options decline before workspace effects. +Windows provisioning now includes these tester comparisons. + +The preceding head's Windows compiler comparisons exposed mixed separators in +emitted paths. Reconstructing emitted paths from platform components now matches +Node's normalized paths for text and JSON, including root-component copies. +Existing exact-output assertions remain unchanged; Windows CI must verify the fix. + +Validation: formatting, Clippy, Dylint, rustdoc and all 1,816 workspace tests pass. +All 20 focused tester cases pass. The final full E2E run reports 1,769 passes, +two Bit Cloud failures in existing capsule/generator fixtures and two existing +skips; both failed cases pass unchanged on serial reruns. The new fixture cleanup +regression also passes in the full run. Generated plan/census, asset, scope and +public-data checks pass. Local Node lint cannot run because tsc is unavailable. +Windows and other platform CI for this batch remain pending. + +## Tester JSON modes and upstream version sync + +Merged upstream `a5813e8d6` with ancestry preserved. The change since `7c0228a4f` +updates release metadata to 2.2.164; it adds no runtime behavior requiring a Rust +port. + +Built-in single-component Node/React testing now owns summary JSON, coverage +collection, snapshot updates and explicit test-file selection. Summary projection +preserves versionless identities, test counters, components without tests and +environment failure messages. Coverage uses the released development-file ignore +list and consumes/removes reports through the existing native coverage path. +Jest and Babel remain external tools with native-owned configuration adapters; +installed distribution metadata still resolves their dependencies. + +Explicit files are resolved lexically to their owning component and filtered +against the loader's recognized test files. Duplicate paths execute once. Invalid +existing non-test files still decline before loader effects so their released +validation and cache effects remain intact. Multiple-component file selections, +custom environments, text/JUnit reporting and watch/debug sessions remain gaps. +The released CLI ignores arguments after `--`; the Rust parser now reproduces +that behavior instead of treating those arguments as file selectors. + +Nine new differential cases cover summaries with passing/failing/empty tests, +nonzero process exit codes and environment errors; empty selections; Node/React +coverage and cleanup; snapshot updates and exact snapshot bytes; relative, +absolute and duplicate file selectors; invalid files; and the argument separator. +Reports, exit codes, workspace/scope contents and history remain compared. Only +existing qualified report timing fields are masked. Failing coverage tests and +environment failures assert that test side effects happen once. Pre-change +negative runs caught both missing native summary support and file selection. + +CI on the preceding tester commit exposed two Windows differences. Restoring a +remote now removes and verifies every child while retaining the root directory +held by a running HTTP scope server. It does not ignore deletion errors or retain +stale outputs. A Windows test holds a directory handle without delete sharing; +a Unix test checks read-only children and preservation of external symlink +targets. Windows transfer CI includes the restoration tests. The tester adapter +also resolves its exposed CLI entry path as Node does, preserving exact +`process.argv` bytes when the configured installation path mixes separators. +Existing strict process-artifact comparisons caught that mismatch on Windows. + +## Tester JUnit output + +Single-component built-in JSON testing now creates JUnit XML after executing +tests, including nested output directories, failure and suite-error messages, +skipped tests, test durations, timestamp, XML escaping and empty reports. The +Jest adapter supplies the original execution error message separately because +JavaScript Error messages are not normally enumerable. Direct test-process exit +does not create a report; filesystem failures remain owned after test effects. + +Differential cases compare complete XML apart from validated command timestamps +and nonzero durations (the fixture runs long enough to avoid zero-duration +attribute ambiguity), plus stdout, exit status, workspace and scope state. They +cover passing and failing assertions, suite errors, pending and todo tests, empty +reports, nonzero process exit and an output path that is a directory. The last +cases assert the test's side effect occurs once. A negative control that +suppressed report writing failed the full filesystem comparison. The standalone renderer also +matched captured released reports for Unicode, control characters and ANSI +sequences. Multi-component, custom tester and text paths remain outside this +bounded implementation. + +Validation for this checkpoint: formatting, Clippy, Dylint, rustdoc, generated +checks and 1,817 workspace tests passed. Four focused JUnit differential cases +passed. The final full E2E run reported 1,784 passes, two existing skips and two Bit +Cloud request failures during released capsule fixtures; both passed unchanged +on rerun. Platform CI for this checkpoint remains pending. + +## Tester selection flags + +For the qualified single-component JSON tester, explicit selection of the +configured built-in environment now runs natively. A different environment +continues to decline before loader effects. As in the released CLI, specifying +an environment suppresses JUnit file generation. The deprecated all and scope +flags are accepted in JSON mode and do not change component selection, matching +the released JSON handler. Differential cases cover long, short and inline flag +forms, summary output, JUnit interaction and an invalid environment. The new +strict native assertion failed on the prior implementation, which delegated the +first deprecated-flag case. + +Validation: 35 focused tester cases, formatting, Clippy, Dylint, rustdoc, +generated checks and 1,817 workspace tests passed. The final full E2E run had +1,785 passes, two existing skips and three Bit Cloud request failures during +fixture setup; all three cases passed unchanged on serial rerun. The preceding +JUnit head's platform checks were still running at this checkpoint. + +## Multi-component tester summary + +Same-environment built-in JSON summaries can run one Jest invocation for +multiple components. Rust combines test patterns, groups results by qualified +component roots, aggregates counters and preserves environment errors. Coverage +is collected by Jest and the existing native path consumes and removes its +artifact. Components without tracked tests still contribute their patterns and +development-file exclusions when another component has tests: the released +runner can execute matching untracked test files from those components. + +The path declines before effects for overlapping or symlinked component trees, +component roots containing glob metacharacters, negated or unconfined patterns, +explicit files from multiple components, JUnit output, detailed JSON and mixed or +custom environments. Warm parallel loader cases retain the existing cache-order +guard. A runtime import guard proves the qualified path does not load Bit's +JavaScript modules; Jest and Babel remain external tools resolved through the +installed distribution. + +Differential tests cover passing and failing tests, components with and without +tests, coverage, environment cleanup errors, matching untracked files, and the +pre-effect guards. Side-effect assertions verify tests run once. The test +normalizer only reorders the known two fixture components in summary arrays; +eight same-path released runs independently produced both orders for tested, +untested and environment-error arrays, with every other field unchanged. A +negative control that dropped patterns for the untracked component failed the +strict complete-summary comparison. + +Jest's default scheduling can place multiple test files in workers or run them +in-band according to its cache. Eight repeated released runs of a two-file +fixture that sets `process.exitCode` produced both exit codes 0 and 7; the +complete summaries otherwise matched after the same independently qualified +component-order projection. The adapter retains Jest's default scheduling and +uses its existing separate native cache identities. The deterministic regression +uses one file, verifies exit code 7 and compares command history exactly. + +Validation: 11 focused multi-component cases and 45 tester comparisons passed, +as did formatting, Clippy, Dylint, rustdoc, generated checks and 1,817 workspace +tests. The full E2E run had 1,797 passes, two existing skips and two Bit Cloud +fixture setup failures; both failed cases passed unchanged on serial rerun. +The separately committed Dylint bootstrap repair passed a clean driver build and +lint run against the current Clippy repository, plus local tests for both branch +layouts. Its GitHub CI job was still running at this checkpoint. + +## Multi-component detailed tester results + +Same-environment built-in multi-component testing now also returns detailed JSON +reports. Rust groups Jest files and errors by component, preserves the shared +Jest success and start values, projects coverage separately for each component, +and consumes the coverage artifact once. This includes coverage created during +the run, stale and malformed reports, and the released string-prefix path +matching behavior. JUnit for multiple components and the earlier path, pattern, +loader and environment guards still decline before effects. + +Differential cases cover mixed pass/fail results, components without tests, +per-component coverage, stale and malformed coverage, cleanup errors with +once-only test effects, and an in-band nonzero process exit. Eight same-path +released detailed reports produced both observed component orders; after +projecting that known pair and the existing timing fields, all remaining report +fields matched. A pre-change strict native test confirmed detailed multi-component +JSON previously delegated. + +Validation: seven focused detailed-report cases, formatting, Clippy, Dylint, +rustdoc, generated checks and 1,817 workspace tests passed. The full E2E run +had 1,805 passes, two existing skips and one Bit Cloud setup failure; the failed +case passed unchanged on rerun. The Dylint bootstrap repair subsequently passed +its GitHub Actions lint job. Other platform jobs for the prior head were pending +at this checkpoint. + +## Multi-component JUnit + +Same-environment built-in testing now writes multi-component JUnit reports, +including summary and coverage combinations, empty reports, suite failures, +nonzero exit codes and report-write errors. Rust groups Jest results by component +before rendering XML so distinct suite errors remain associated with their own +components. An explicit environment selector retains the released behavior of +suppressing JUnit output. Multi-component explicit-file selections and the +existing path, pattern, loader and environment guards continue to decline before +effects. + +Nine focused comparisons cover those modes, direct process termination, report +permissions, once-only side effects, and execution without loading Bit runtime +modules. Eight same-path released runs established both suite orders and the +validated timing projection. Another eight established both observed serialized +error-type forms for a one-error fixture; its JSON comparison permits only those +coupled forms while keeping the XML exact apart from suite order and timings. +A two-error negative control that removed per-component mapping failed the XML +comparison. Capturing Jest error code and type explicitly also preserves +non-enumerable fields that were previously lost during serialization. + +Validation: 62 focused tester cases, formatting, Clippy, Dylint, rustdoc, +generated checks and 1,817 workspace tests passed. The full E2E run had 1,814 +passes, two existing skips and one Bit Cloud setup failure; the case passed +unchanged on rerun. Platform CI for this checkpoint is pending. + +## Explicit test files across components + +Same-environment built-in JSON testing now accepts explicit test files from +multiple components. Rust validates each file against its owning component, +selects only the requested tests, and preserves the first-seen component order +in reports and JUnit suites. Duplicate paths are executed once. Existing +unsupported ownership, loader, environment and path cases still decline before +execution. + +Seven differential cases cover relative and absolute paths, duplicate inputs, +summary and coverage, JUnit, reversed input order, and an unrecognized file. +The fixtures assert selected and excluded test effects as well as output and +workspace state. Eight released runs in each input order confirmed the exact +component and JUnit suite order. Removing target-order loading makes the +reversed-order comparison fail; the previous implementation declined the +multi-component selection. + +Validation: 69 focused tester cases, 14 compiler cases, formatting, Clippy, +Dylint, rustdoc, generated checks and all workspace tests passed. The full E2E +run had 1,821 passes, two existing skips and one Bit Cloud request failure in +released-CLI custom-aspect fixture setup; that case passed unchanged on rerun. + +## Tester text summaries + +Built-in same-environment testing now owns the text `--summary` path as well +as JSON. Rust renders the released pass, failure, tester-error, no-test and +nonzero-exit headlines from the existing aggregate, with color and measured +duration. It preserves cold loader progress, explicit-file selection, report +writes and direct process termination. Regular text reporting, deprecated text +selection flags and the existing unsupported loader and environment cases still +decline before effects. + +Eight differential cases cover passing, pending, failing and missing tests, +suite errors, explicit files, CI colors, JUnit and coverage, nonzero and direct +exit, and once-only effects. A preload rejects Bit runtime imports for a text +summary. The before-change controls confirm these cases previously delegated; +comparisons retain output, exit codes and workspace effects while validating and +normalizing only elapsed durations and JUnit timestamps/durations. + +Validation: eight focused text-summary cases and 75 focused tester cases passed, +as did formatting, Clippy, Dylint, rustdoc, workspace tests and generated/scope +checks. The full E2E run had 1,827 passes, two existing skips and three Bit +Cloud request failures in existing fixture setup. All three passed unchanged +on serial rerun. + +## Tester summary selection flags + +Text summaries now preserve the released `--all` and `--scope` warnings and +selection rules. The all-components flag includes unchanged components; scope +qualifies component patterns, while explicit file paths ignore that filter. +JSON retains its existing released behavior for these flags. Unknown or +unsupported selections decline before test execution. + +Differential cases cover both flags, their combination, scope-qualified and +explicit-file selection, CI colors, and an unknown scope. A tagged unchanged +component proves the all-components selection; a negative control that reverts +it to changed-only selection fails the native-ownership assertion. + +Validation: all focused text-summary cases passed, along with formatting, +Clippy, Dylint, rustdoc, workspace tests and generated/scope checks. The full +E2E run had 1,835 passes, two existing skips and two Bit Cloud request failures +in existing fixtures; both passed unchanged on serial rerun. + +## Empty changed test selections + +When the workspace has components but none are changed, Rust now publishes the +released loader progress and no-components hint in both text summary and +ordinary text modes. This path does not start Jest. Ordinary text with selected +components still declines before test execution. + +Differential cases cover a tagged unchanged component, the summary and regular +text forms, colored CI output and the selected-component delegation boundary. +The summary case failed the native-ownership assertion before this change. + +Validation: focused text comparisons, formatting, Clippy, Dylint, rustdoc, +workspace tests and generated/scope checks passed. The full E2E run had 1,840 +passes, two existing skips and one Bit Cloud fixture setup failure; that case +passed unchanged on rerun. Upstream advanced after this validation and is synced +in the following checkpoint. + +## Upstream lane export reconciliation + +Merged upstream through `88bb28f16` (Bit 2.2.165); the later commit updates release metadata. The export delta reconciles hidden lane +entries against the current remote lane: remote-only entries are retained, +removed remote entries are dropped unless new entries were explicitly marked, +visible components are excluded, and a local head replaces the remote head only +when its version history establishes ancestry. The exported lane sets the +one-export override when its reconciled hidden list differs from the remote; +following acknowledgement, the local lane retains the reconciled list and clears +the override. File and HTTP lane reads are bounded; an explicit remote +lane-not-found response is distinguished from other remote errors. + +The new scenario-18 regression exercises a stale hidden entry removed remotely, +checks the visible exported head and checks the local lane after export. Another +case adds a remote-only hidden entry after checkout and verifies both lanes. +The original test mapping for scenarios 17 and 18 named assertions it did not +execute; it now maps only the fetch scenario, and the actual scenario-18 test +owns its assertions. Unit cases cover advanced and diverged heads, remote-only +and new entries, promoted components, and HTTP lane fetch/error routing. +Removing remote-only preservation makes the new end-to-end test fail. The +pre-change export failed the new local stale-entry assertion. + +The Windows provisioning failure reported a directory report target as access +denied. On Windows, a directory target now produces the released absolute-path +directory error while ordinary permission failures keep their existing message. +The existing single- and multi-component once-only write-failure comparisons +cover this in Windows CI; a unit case exercises the conversion locally. + +Validation: 15 cascade cases, 34 export cases, HTTP/reconciliation unit cases, +formatting, Clippy, Dylint, rustdoc, workspace tests and generated/scope checks +passed. The final full E2E run had 1,841 passes, two existing skips and two Bit +Cloud fixture failures. The capsule case passed unchanged on rerun; the custom +environment case encountered another Cloud request failure on its first rerun +and passed unchanged on the second. The Windows error conversion passed its +local unit test; the actual Windows differential tests await PR CI. + +## Upstream raw Source fetches + +Merged upstream through `9d1e17110`. For a valid fetched Source, upstream now +writes the received compressed representation directly instead of recompressing +it. Rust already captures, verifies and publishes those exact bytes for both +file remotes and virtual HTTP captures. New tests use a nondefault compression +stream and assert byte-for-byte capture and publication; a negative control +that discards the captured bytes fails. + +An incoming Source whose transfer reference disagrees with its content remains +a guarded Rust boundary: the upstream Node path writes under the content hash, +while Rust declines before publication. That pre-existing malformed-input gap is +not counted as completed parity. + +Validation: the Source capture tests, formatting, Clippy, Dylint, rustdoc, +workspace tests and generated/scope checks passed. The full E2E run had 1,841 +passes, two existing skips and two Bit Cloud request failures in fixture setup; +both cases passed unchanged on serial rerun. The preceding PR head's Rust lint +job passed; Windows provisioning is still running. + +## Ordinary text with no test files + +Rust now renders ordinary text reports when selected built-in components have +no test files. It preserves component-loading progress, workspace and component +counts, sorted verbose identities, CI colors, deprecated selection warnings and +empty JUnit reports. Ordinary text with runnable tests continues to decline +before execution. Four focused differential cases cover the new modes, and a +before-change control confirms they previously delegated. + +Validation: the focused text comparisons, formatting, Clippy, Dylint, rustdoc, +workspace tests and generated/scope checks passed. The full E2E run had 1,844 +passes, two existing skips and three Bit Cloud fixture setup failures; all three +passed unchanged on serial rerun. + +## Concurrent garbage-collection failure comparison + +A Linux E2E shard failed because a permission error raced another object move in +the released CLI. Sixteen same-path released runs completed the other move; +sixteen runs with that rename deliberately delayed left it pending when the +process exited. Both groups had identical stdout and stderr, and the only tree +differences were the object's original and backup paths and their mode entries. +The delayed replay reproduced the CI failure with the previous comparison. + +The comparison now accepts only those two observed locations for that one +object, requiring exactly one copy with the original bytes and mode. Every other +scope entry and the full command output remain exact, and the native run still +asserts the successful move occurred once without replay. A separate regression +rejects duplicate, missing and modified copies. No production behavior or test +timeout changed. + +Validation: 32 focused GC cases, formatting, Clippy, Dylint, rustdoc, workspace +tests and generated/scope checks passed. The full E2E run had 1,847 passes, +two existing skips and one Bit Cloud fixture setup failure; the case passed +unchanged on rerun. The preceding Windows provisioning check passed. + +The subsequent upstream sync to `f367980931c33240f17c1f259c7d1d000d6b7f89` updates release and CLI reference metadata; it does not add a Rust behavior gap. + +Upstream `e831b73da` adds release notes only; no Rust behavior change is required. + +Upstream `3bd72fe67` changes scope-side documentation recalculation: a failed +reader for an existing docs file leaves the stored doc intact, while a component +without a docs file clears it. The native scope configuration merge already +preserves stored documentation data, now covered by a regression test. Full +scope-side aspect recalculation and the reader/no-file distinction are still a +porting gap; scope-side snapshot paths needing those callbacks remain delegated. +The following `06ab7f06c` commit updates release metadata. + +Upstream `49db7bc3c` replaces repeated ref-to-tag scans with an index. Native +history traversal and reset now build an index per immutable request, choose the +newest normal tag before orphaned tags, and no longer let an earlier cached tag +change the answer. Component log projection also selects the newest orphaned tag. +The index is transient and is never serialized into a component model. A 20,000-tag +regression checks tagged and untagged lookups; existing history-query bounds remain. + +The same indexed lookup is used when reporting staged history and building a +scope's reverse dependency graph, avoiding a tag scan for every traversed snap. +Rebuilding the index after a model change observes removals and orphaned tags; +constructing it leaves the serialized model unchanged. + +Upstream `0d4c18398` updates release metadata to 2.2.168 without a CLI behavior change. + +The history population optimization in upstream `95d4a730f` also changes the +rebuild report when ancestry branches share an ancestor: repeated visits count +once. Rust now follows that behavior and rejects cycles before publication. +The shared-ancestor rebuild was compared against an isolated official Bit +2.2.168 installation with exact output and scope bytes. The pinned 2.2.146 +reference instead reports one additional visit in this fixture; the regression +checks that exact version-specific count while comparing all other output and +scope bytes exactly. Both absent and partial history objects are covered. + +The upstream hidden-dependent lane history change is covered against the 2.2.168 +release with an actual hidden lane entry. The older 2.2.146 reference predates +that traversal, so its comparison presents the same head as a visible entry and +restores the hidden lane bytes before comparing all scope effects. The native +command runs without the JavaScript runtime in both comparisons. diff --git a/native/.cargo/config.toml b/native/.cargo/config.toml new file mode 100644 index 000000000000..0fe91cf56ac4 --- /dev/null +++ b/native/.cargo/config.toml @@ -0,0 +1,8 @@ +# >>> pnpm-managed cargo sources >>> +[[include]] +path = "../.pnpm/crates/config.toml" +optional = true +# <<< pnpm-managed cargo sources <<< + +[env] +CMAKE_TOOLCHAIN_FILE = { value = "cmake/zlib-compat.cmake", relative = true } diff --git a/native/.gitignore b/native/.gitignore new file mode 100644 index 000000000000..58bfe6113aba --- /dev/null +++ b/native/.gitignore @@ -0,0 +1,5 @@ +/target/ +/**/target/ +/dist/ +/node_modules/ +/.pnpm/ diff --git a/native/AGENTS.md b/native/AGENTS.md new file mode 100644 index 000000000000..2b80ecfbbc79 --- /dev/null +++ b/native/AGENTS.md @@ -0,0 +1,147 @@ +# The Rust `bit` CLI + +`native/` is a Cargo workspace that builds `bit`, a Rust CLI that runs some commands natively and +delegates everything else to the Node.js Bit CLI. Read [docs/rust-cli.md](../docs/rust-cli.md) +before changing a command: it lists every native command, what it declines, and how it finds the +Node.js CLI. The repository-wide guide ([../CLAUDE.md](../CLAUDE.md)) applies too; this file +specializes it for the Rust code. + +## Behavior policy + +The Node.js CLI defines Bit's behavior; a native command is a faster implementation of it. + +1. **Match observable behavior, not structure.** What must match is what a user or a tool can + observe: stdout and stderr, colors, exit codes, the files written (the workspace, `.bit`, + remote scopes) and the order of everything printed. Mirroring the TypeScript decomposition is + a convenience for cross-referencing, not a requirement. +2. **New behavior belongs to the Node.js CLI first.** A native command does not add flags, + output or features the Node.js CLI lacks. When the Node.js CLI changes, the port follows it. +3. **Bugs in Bit are reproduced, not fixed, in the port.** If the Node.js CLI behaves oddly (for + example, `bit export ` in 2.2.94 reports "nothing to export" after exporting), either + reproduce it or decline that input, and point it out; fixing it is a Node.js change. +4. If the intended behavior is unclear or looks wrong, stop and ask rather than guessing. + +## Scope: never change the Node.js CLI + +The Rust CLI must stay mergeable into `teambit/bit` master without touching the Node.js CLI. +Compared with master, a branch may change only: + +- `native/` +- `docs/rust-cli.md` and `docs/rust/` +- `scripts/rust/` +- `.github/workflows/rust.yml` and `.github/workflows/rust-scope.yml` + +`scripts/rust/check-scope.sh ` enforces this, and the `Rust scope` +workflow runs it on every pull request and push to `rust-workspace-materialization`. Do not +bring back the in-process integration this CLI replaced: + +- no `rust-*.ts` glue, `BIT_RUST_*` or `BIT_NATIVE_INSTALL` flags, or other Node.js changes; +- no napi addon (`native/workspace-napi`) and no binary other than the CLI's (`cli/src/main.rs`): + the other crates are libraries; +- no stdin/stdout protocol servers for a Node.js caller. + +If a native command needs something only JavaScript knows, the CLI's embedded host script +(`cli/src/host/host.js`) asks the installed Node.js CLI, using only modules the published +`@teambit/bit` package ships. A module that exists only in a newer Node.js CLI must make the +command decline, not fail. + +## Decline before writing + +A native command either reproduces the Node.js CLI exactly or declines, and a decline hands the +untouched workspace to the Node.js CLI (`Outcome::Declined`). So: + +- parse the arguments narrowly: an unknown flag, an id form or an input the port does not cover + declines; +- read, plan and format the report before anything is written or printed; +- after the first write, failures are the command's own and are reported like the Node.js CLI + reports them, never declined (the Node.js CLI would run the command a second time). The same + holds for a remote that changed: `bit export` declines only before a remote persists. + +## Verify against the Node.js CLI + +Parity is checked against a released Node.js CLI, not against this repository's sources: + +- `BIT_NATIVE=off bit ` runs the Node.js CLI, `BIT_NATIVE_TRACE=1` prints why a command + ran natively or declined. +- Compare stdout, the exit code and the resulting files (the workspace, `.bit`, and any remote) + on fresh copies of the same fixture, in a non-interactive run, on a TTY (`script`) for colors, + and with `CI=1` (status lines are hidden on CI). +- A file remote is shared state: restore it before each run. +- Mask what legitimately differs (new hashes, dates, timings) and say so; Node's order of some + changed-component lists is not deterministic. +- Oracle tests that run the Node.js CLI's own functions are opt-in: + `BIT_TEST_SNAPSHOT_CLI=<@teambit/bit installation> cargo test`. The released graph + filter comparison uses `BIT_TEST_GRAPH_CLI=` with + `cargo test -p bit-cli --bin bit reachable`. +- The e2e suite's cases are ported to `cli/tests/e2e/` following + [plans/TEST_PORTING.md](plans/TEST_PORTING.md): read it before porting a case or changing a + native command an e2e case covers, and tick its boxes as cases land. `pnpm run test:e2e` + runs them against the Node.js CLI that `cli/tests/e2e/bit-version` names. + +### Tests + +- Prefer unit tests next to the code (`/_tests.rs`) and integration tests in + `cli/tests/` with a stub Node.js CLI. +- No tolerant tests: a test that needs `node` or `git` calls it and lets the missing tool fail + the test; never probe for it and `return` early. The oracle tests above are the one opt-in + set, selected by `BIT_TEST_SNAPSHOT_CLI`, not by probing. Gate platform-only tests with + `#[cfg(...)]` or `#[cfg_attr(..., ignore = "...")]` so the report shows them. +- When temporarily breaking an implementation to prove a test catches the regression, revert + with `git restore `, never by moving a saved copy back: Cargo's freshness check is + mtime-based, and a stale binary makes later runs fail in impossible-looking ways. If test + outcomes flip with no code change, `touch` the source file and rerun first. + +## Style + +- Warnings are errors (Clippy's pedantic and nursery groups, the perfectionist lints). When a + lint must be suppressed, use `#[expect(lint, reason = "…")]` with the specific reason, never a + bare `#[allow(...)]`; split a function into steps instead of suppressing a size lint. +- No `unsafe` without a `// SAFETY:` comment stating the invariant, and a reason it is needed. +- No star imports of modules this workspace owns (`use super::*`); external preludes are fine. +- Doc comments (`///`, `//!`) state the contract. Name the Node.js behavior a port reproduces + (`StatusMain.status`, `ExportCmd.report`, …) and describe what the code does, not how an + earlier integration did it. Put implementation-only rationale in `//` comments, and do not + restate in prose what a test already shows. +- Prefer renaming or extracting a helper over a comment that translates the code. + +## Checks + +Run from `native/` before every commit: + +```sh +node ../scripts/rust/rustfmt.mjs --all -- --check # pnpm's pinned formatter (write: without `-- --check`) +cargo clippy --locked --workspace --all-targets --features bit-cli/e2e -- -D warnings +RUSTFLAGS='-D warnings' cargo dylint --all -- --all-targets --workspace --features bit-cli/e2e # perfectionist lints +RUSTDOCFLAGS='-D warnings' cargo doc --locked --workspace --no-deps +cargo test --locked --workspace --no-fail-fast +cargo test --locked -p bit-cli --features e2e --test e2e # the ported e2e cases +node ../scripts/rust/e2e-port-plan.mjs --check # the plan lists every e2e case +``` + +A test failure that also fails on the base branch is reported as such, never hidden. + +When a command's native coverage changes, update its row in the native commands table of +`docs/rust-cli.md`. + +## Dependencies + +Check `[workspace.dependencies]` and the crates already in `Cargo.lock` before adding one, and +add a dependency to the crate that needs it, not to a shared crate. Do not add a third-party +crate the workspace does not already use without a human's approval. pnpm's engine crates are +pinned to one pnpm commit in `Cargo.toml`; bump them together, never one at a time. + +## Commits and pull requests + +- Conventional Commits with the `rust` scope: `feat(rust): native bit lane list`, + `fix(rust): …`, `ci(rust): …`. +- Keep commits focused: a port does not also refactor or reformat unrelated code. +- A pull request says what was verified against the Node.js CLI and how (fixtures, flags, + masked fields), and what still declines. + +## Things not to do + +- Do not change files outside the Rust CLI's paths (see Scope), even to make a port easier. +- Do not make a native command print or write something the Node.js CLI would not. +- Do not decline after the first write, or retry a write by delegating to the Node.js CLI. +- Do not disable lints, tests or CI checks (including `Rust scope`) to make a pull request + green. diff --git a/native/CLAUDE.md b/native/CLAUDE.md new file mode 100644 index 000000000000..43c994c2d361 --- /dev/null +++ b/native/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/native/Cargo.lock b/native/Cargo.lock new file mode 100644 index 000000000000..82d4a0833762 --- /dev/null +++ b/native/Cargo.lock @@ -0,0 +1,7225 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "bytes", + "crypto-common 0.1.7", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + +[[package]] +name = "aes-kw" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69fa2b352dcefb5f7f3a5fb840e02665d311d878955380515e4fd50095dd3d8c" +dependencies = [ + "aes", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "ambient-authority" +version = "0.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9d4ee0d472d1cd2e28c97dfa124b3d8d992e10eb0a035f33f5d12e3a177ba3b" + +[[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 = "annotate-snippets" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f211a51805bc641f3ad5b7664c77d2547af685cc33b4cd8d31964027a46f13f1" +dependencies = [ + "anstyle", + "memchr", + "unicode-width 0.2.2", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.2.17", + "password-hash", + "zeroize", +] + +[[package]] +name = "arraydeque" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" + +[[package]] +name = "async-compression" +version = "0.4.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee19bd99b43e3691acbad4e840420a4881cea6c0b66a208125a824f8fd53f5a1" +dependencies = [ + "compression-codecs", + "compression-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "async-recursion" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f8abc12baad266b1c8cec146854c195b5864b4221d4b2ca7296a7ae82d9e451" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[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.6", +] + +[[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 = "aws-lc-rs" +version = "1.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faac5829c2b74c28f830747e7818ccfb684261b5f48a1118b1e2a13d36dfab13" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1622d8446a2d4b2ce0c7eefc73dd43a99779028d5ee5c2dd8073a658ba8a2bc" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide 0.8.9", + "object", + "rustc-demangle", + "windows-link", +] + +[[package]] +name = "backtrace-ext" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537beee3be4a18fb023b570f80e3ae28003db9167a751266b259926e25539d50" +dependencies = [ + "backtrace", +] + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base16ct" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bit-cli" +version = "0.1.0" +dependencies = [ + "async-trait", + "bit-client-transfer", + "bit-dependency-scanner", + "bit-ignore", + "bit-install", + "bit-object-import", + "bit-workspace-config", + "chrono", + "diff", + "flate2", + "getrandom 0.3.4", + "indexmap", + "libc", + "node-semver", + "pnpm-deps-path", + "pnpm-diagnostics", + "pnpm-fs", + "pnpm-lockfile", + "regex", + "regress", + "reqwest", + "serde", + "serde_json", + "sha1", + "tar", + "tempfile", + "tokio", + "unicode-width 0.2.2", + "winapi-util", + "windows-sys 0.61.2", +] + +[[package]] +name = "bit-client-transfer" +version = "0.1.0" +dependencies = [ + "bit-workspace-config", + "futures-util", + "getrandom 0.3.4", + "indexmap", + "reqwest", + "serde", + "serde_json", + "tar", + "tempfile", + "tokio", +] + +[[package]] +name = "bit-dependency-scanner" +version = "0.1.0" +dependencies = [ + "indexmap", + "oxc_allocator", + "oxc_ast", + "oxc_ast_visit", + "oxc_parser", + "oxc_span", + "rayon", + "serde", + "serde_json", +] + +[[package]] +name = "bit-ignore" +version = "0.1.0" +dependencies = [ + "pretty_assertions", + "regress", + "serde", + "serde_json", +] + +[[package]] +name = "bit-install" +version = "0.1.0" +dependencies = [ + "async-trait", + "base64 0.22.1", + "bit-client-transfer", + "bit-dependency-scanner", + "bit-object-import", + "bit-workspace-config", + "flate2", + "futures-util", + "getrandom 0.3.4", + "indexmap", + "junction 2.1.0", + "miette 7.6.0", + "node-semver", + "pnpm-config", + "pnpm-config-parse-overrides", + "pnpm-default-reporter", + "pnpm-deps-path", + "pnpm-hooks", + "pnpm-lockfile", + "pnpm-modules-yaml", + "pnpm-network", + "pnpm-package-manager", + "pnpm-package-manifest", + "pnpm-reporter", + "pnpm-resolving-default-resolver", + "pnpm-resolving-deps-resolver", + "pnpm-resolving-npm-resolver", + "pnpm-resolving-parse-wanted-dependency", + "pnpm-resolving-resolver-base", + "pnpm-store-dir", + "pnpm-tarball", + "pnpm-workspace", + "pretty_assertions", + "rayon", + "regress", + "reqwest", + "ryu-js", + "same-file", + "serde", + "serde_json", + "sha1", + "sha2 0.10.9", + "tar", + "tempfile", + "tokio", +] + +[[package]] +name = "bit-object-import" +version = "0.1.0" +dependencies = [ + "base64 0.22.1", + "bit-client-transfer", + "bit-dependency-scanner", + "bit-ignore", + "bit-workspace-config", + "flate2", + "futures-util", + "indexmap", + "junction 2.1.0", + "node-semver", + "pretty_assertions", + "rayon", + "regress", + "ryu-js", + "same-file", + "serde", + "serde_json", + "sha1", + "sha2 0.10.9", + "tempfile", + "tokio", + "winapi-util", + "xattr", +] + +[[package]] +name = "bit-workspace-config" +version = "0.1.0" +dependencies = [ + "indexmap", + "pretty_assertions", + "ryu-js", + "serde", + "serde_json", +] + +[[package]] +name = "bitfields" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef6e59298da389bc0649c7463856b34c6e17fe542f88939426ede4436c6b1195" +dependencies = [ + "bitfields-impl", +] + +[[package]] +name = "bitfields-impl" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2c044f98f86f15414668d6c8187c7e4fadab1ad2b31680f648703e0fe07c555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "thiserror 2.0.21", +] + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "bitvec" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + +[[package]] +name = "blowfish" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7" +dependencies = [ + "byteorder", + "cipher", +] + +[[package]] +name = "boxcar" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36f64beae40a84da1b4b26ff2761a5b895c12adc41dc25aaee1c4f2bbfe97a6e" + +[[package]] +name = "buffer-redux" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "431a9cc8d7efa49bc326729264537f5e60affce816c66edf434350778c9f4f54" +dependencies = [ + "memchr", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "camellia" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3264e2574e9ef2b53ce6f536dea83a69ac0bc600b762d1523ff83fe07230ce30" +dependencies = [ + "byteorder", + "cipher", +] + +[[package]] +name = "cap-primitives" +version = "4.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b5f74729fd2f44701d1a8eb47e906cdb3ccd9ec0f02baad85a744b791940b18" +dependencies = [ + "ambient-authority", + "fs-set-times", + "io-extras", + "io-lifetimes 3.0.1", + "ipnet", + "maybe-owned", + "rustix", + "rustix-linux-procfs", + "windows-sys 0.61.2", + "winx", +] + +[[package]] +name = "cast5" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b07d673db1ccf000e90f54b819db9e75a8348d6eb056e9b8ab53231b7a9911" +dependencies = [ + "cipher", +] + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cc" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6651c9ed80effdc7db0ff72512157f901af5e3549e341e24b1dd4887d836d838" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfb-mode" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "738b8d467867f80a71351933f70461f5b56f24d5c93e0cf216e59229c968d330" +dependencies = [ + "cipher", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core 0.10.1", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "windows-link", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout", +] + +[[package]] +name = "cmac" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8543454e3c3f5126effff9cd44d562af4e31fb8ce1cc0d3dcd8f084515dbc1aa" +dependencies = [ + "cipher", + "dbl", + "digest 0.10.7", +] + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "combine" +version = "4.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "compact_str" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79fcda08c33bb58b97008b2cdada6622500e949e060f5913361763121abd2416" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "static_assertions", + "zmij", +] + +[[package]] +name = "compression-codecs" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98fc98460ba0ad5317075d3632b8dfc45d0be8c4a49347c2a38272019717614a" +dependencies = [ + "compression-core", + "flate2", + "memchr", + "zstd", + "zstd-safe", +] + +[[package]] +name = "compression-core" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e8ccc4ea9f6acc32d102c0f6d471d11d913ad15f20c04de743374861fa1d414" + +[[package]] +name = "console" +version = "0.16.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e96a4956774c13c126a8b5af4daa79384f4d826534c95a02d76afb39e2ab64e3" +dependencies = [ + "encode_unicode", + "libc", + "unicode-width 0.2.2", + "windows-sys 0.61.2", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "const_format" +version = "0.2.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4481a617ad9a412be3b97c5d403fef8ed023103368908b9c50af598ff467cc1e" +dependencies = [ + "const_format_proc_macros", + "konst", +] + +[[package]] +name = "const_format_proc_macros" +version = "0.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744" +dependencies = [ + "proc-macro2", + "quote", + "unicode-xid", +] + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + +[[package]] +name = "cow-utils" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "417bef24afe1460300965a25ff4a24b8b45ad011948302ec221e8a0a81eb2c79" + +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crc24" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd121741cf3eb82c08dd3023eb55bf2665e5f60ec20f89760cf836ae4562e6a0" + +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-bigint" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c035cb64cba03dad2fb921f3e218785e8cf9a41790da5eb3a1400f62fa2be11" +dependencies = [ + "cpubits", + "ctutils", + "getrandom 0.4.3", + "hybrid-array", + "num-traits", + "rand_core 0.10.1", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "getrandom 0.4.3", + "hybrid-array", + "rand_core 0.10.1", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "ctutils" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe3e9d21d39e6f39ff46e03d5b621424f88ee23d8a1672c6bfc9ccc7f2da4f6a" +dependencies = [ + "cmov", + "subtle", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest 0.10.7", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "cx448" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4c0cf476284b03eb6c10e78787b21c7abb7d7d43cb2f02532ba6b831ed892fa" +dependencies = [ + "crypto-bigint 0.5.5", + "elliptic-curve 0.13.8", + "pkcs8 0.10.2", + "rand_core 0.6.4", + "serdect 0.3.0", + "sha3", + "signature 2.2.0", + "subtle", + "zeroize", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "dashmap" +version = "7.0.0-rc2" +source = "git+https://github.com/pnpm/dashmap?rev=4401b5c0b2626266dd5d5a9202677bca8b53e57a#4401b5c0b2626266dd5d5a9202677bca8b53e57a" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "equivalent", + "hashbrown 0.15.5", + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "dbl" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd2735a791158376708f9347fe8faba9667589d82427ef3aed6794a8981de3d9" +dependencies = [ + "generic-array", +] + +[[package]] +name = "deno_error" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3007d3f1ea92ea503324ae15883aac0c2de2b8cf6fead62203ff6a67161007ab" +dependencies = [ + "deno_error_macro", + "libc", + "url", +] + +[[package]] +name = "deno_error_macro" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b565e60a9685cdf312c888665b5f8647ac692a7da7e058a5e2268a466da8eaf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "deno_path_util" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c7e98943f0d068928906db0c7bde89de684fa32c6a8018caacc4cee2cdd72b" +dependencies = [ + "deno_error", + "percent-encoding", + "sys_traits", + "thiserror 2.0.21", + "url", +] + +[[package]] +name = "deno_task_shell" +version = "0.33.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ac466d733d16a8ab5268b7e83a407289b25060a8f96dadc5cb96232a67c5282" +dependencies = [ + "bitflags", + "deno_path_util", + "futures", + "glob", + "monch", + "nix", + "path-dedot", + "sys_traits", + "thiserror 2.0.21", + "tokio", + "which", + "windows-sys 0.59.0", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "der" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a996a108feb8b427a6e12fcf6af4aa83409b79d72c15337d7741368131ee004" +dependencies = [ + "const-oid 0.10.2", + "zeroize", +] + +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn 2.0.119", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", + "unicode-xid", +] + +[[package]] +name = "des" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffdd80ce8ce993de27e9f063a444a4d53ce8e8db4c1f00cc03af5ad5a9867a1e" +dependencies = [ + "cipher", +] + +[[package]] +name = "dialoguer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25f104b501bf2364e78d0d3974cbc774f738f5865306ed128e1e0d7499c0ad96" +dependencies = [ + "console", + "shell-words", +] + +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + +[[package]] +name = "diffy" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e3dc2f773b6aaa63b1a7684b8589f670a8a0146a510b74d23a401c882364b49" +dependencies = [ + "hashbrown 0.17.1", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "const-oid 0.9.6", + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid 0.10.2", + "crypto-common 0.2.2", + "ctutils", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags", + "objc2", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "dragonbox_ecma" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd8e701084c37e7ef62d3f9e453b618130cbc0ef3573847785952a3ac3f746bf" + +[[package]] +name = "dsa" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48bc224a9084ad760195584ce5abb3c2c34a225fa312a128ad245a6b412b7689" +dependencies = [ + "digest 0.10.7", + "num-bigint-dig", + "num-traits", + "pkcs8 0.10.2", + "rfc6979 0.4.0", + "sha2 0.10.9", + "signature 2.2.0", + "zeroize", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "eax" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9954fabd903b82b9d7a68f65f97dc96dd9ad368e40ccc907a7c19d53e6bfac28" +dependencies = [ + "aead", + "cipher", + "cmac", + "ctr", + "subtle", +] + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der 0.7.10", + "digest 0.10.7", + "elliptic-curve 0.13.8", + "rfc6979 0.4.0", + "signature 2.2.0", + "spki 0.7.3", +] + +[[package]] +name = "ecdsa" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0681a4fc24c767085329728d8dfba959af91228aa4610cca4f8ce317ba46ae0" +dependencies = [ + "der 0.8.3", + "digest 0.11.3", + "elliptic-curve 0.14.3", + "rfc6979 0.6.0", + "signature 3.0.0", + "spki 0.8.1", + "zeroize", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8 0.10.2", + "signature 2.2.0", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core 0.6.4", + "serde", + "sha2 0.10.9", + "subtle", + "zeroize", +] + +[[package]] +name = "either" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct 0.2.0", + "base64ct", + "crypto-bigint 0.5.5", + "digest 0.10.7", + "ff 0.13.1", + "generic-array", + "group 0.13.0", + "hkdf", + "pem-rfc7468", + "pkcs8 0.10.2", + "rand_core 0.6.4", + "sec1 0.7.3", + "serde_json", + "serdect 0.2.0", + "subtle", + "tap", + "zeroize", +] + +[[package]] +name = "elliptic-curve" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "900f37e7e18d74a2347389af6cd69e3a30d5cc9e697f40004865679aa75e9c7c" +dependencies = [ + "base16ct 1.0.0", + "crypto-bigint 0.7.10", + "crypto-common 0.2.2", + "digest 0.11.3", + "ff 0.14.0", + "group 0.14.0", + "hybrid-array", + "pkcs8 0.11.0", + "rand_core 0.10.1", + "sec1 0.8.1", + "subtle", + "zeroize", +] + +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + +[[package]] +name = "encoding_rs" +version = "0.8.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679" +dependencies = [ + "cfg-if", + "core_detect", + "multiversion_no_op", + "rustversion", + "scopeguard", + "simdutf8", +] + +[[package]] +name = "encoding_rs_io" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fba3fe847045ecff794b9c138293a80db914678c453ad63fbf0c6a9eb6e00b22" +dependencies = [ + "encoding_rs", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "bitvec", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "ff" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1f686ab92a9fb0eaf188f6c6c87b89490baa6fdb0db4544ba4dc47f7942489f" +dependencies = [ + "rand_core 0.10.1", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[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.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "libz-sys", + "miniz_oxide 0.9.1", + "zlib-rs", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs-set-times" +version = "0.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94e7099f6313ecacbe1256e8ff9d617b75d1bcb16a6fddef94866d225a01a14a" +dependencies = [ + "io-lifetimes 2.0.4", + "rustix", + "windows-sys 0.59.0", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[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-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[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 = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] + +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix", + "windows-link", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "granit-parser" +version = "0.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d03f81ad4732830d85cfd417a9f62cde6dadda4354d37d078a6084a19560aa2d" +dependencies = [ + "arraydeque", + "smallvec", +] + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff 0.13.1", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "group" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fd1a1c7a5206c5b7a3f5a0d7ccd3ff85d0c8f5133d62a02680255b0004af5f4" +dependencies = [ + "ff 0.14.0", + "rand_core 0.10.1", + "subtle", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "foldhash", +] + +[[package]] +name = "hashlink" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f" +dependencies = [ + "hashbrown 0.16.1", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac 0.12.1", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hybrid-array" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +dependencies = [ + "subtle", + "typenum", + "zeroize", +] + +[[package]] +name = "hyper" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" +dependencies = [ + "atomic-waker", + "bytes", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "httparse", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[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 = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "serde", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_locale_fallback" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "251af8e57c9400e3eb58242fe5b8b1152b2a64fdf4cf632f923c38ccee6f2fa9" +dependencies = [ + "icu_locale_core", + "icu_locale_fallback_data", + "icu_provider", + "potential_utf", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_locale_fallback_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "decf2a22ec8fa68f1a0c1129a3f8583f8f8bc24e8b9ccbe98ead99f62a4dc3a8" + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "serde", + "stable_deref_trait", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_segmenter" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82d07aafccd67af15d02512a6adf5896fbc5ed00f2e99b471d2efa14016db3db" +dependencies = [ + "icu_collections", + "icu_locale_fallback", + "icu_provider", + "icu_segmenter_data", + "potential_utf", + "smallvec", + "utf8_iter", + "zerovec", +] + +[[package]] +name = "icu_segmenter_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae293c039020f9ec10710af98d29ce6aa2051486638b49c9a6409f3b4a9e98ad" + +[[package]] +name = "idea" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "075557004419d7f2031b8bb7f44bb43e55a83ca7b63076a8fb8fe75753836477" +dependencies = [ + "cipher", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "io-extras" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20fd6de4ccfcc187e38bc21cfa543cb5a302cb86a8b114eb7f0bf0dc9f8ac00f" +dependencies = [ + "io-lifetimes 3.0.1", + "windows-sys 0.59.0", +] + +[[package]] +name = "io-lifetimes" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06432fb54d3be7964ecd3649233cddf80db2832f47fec34c01f65b3d9d774983" + +[[package]] +name = "io-lifetimes" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f0fb0570afe1fed943c5c3d4102d5358592d8625fda6a0007fdbe65a92fba96" + +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + +[[package]] +name = "is-terminal" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" +dependencies = [ + "hermit-abi", + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "is_ci" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7655c9839580ee829dfacba1d1278c2b7883e50a277ff7541299489d6bdfdc45" + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror 2.0.21", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "json5" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "733a844dbd6fef128e98cb4487b887cb55454d92cd9994b1bafe004fabbe670c" +dependencies = [ + "serde", + "ucd-trie", +] + +[[package]] +name = "junction" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cfc352a66ba903c23239ef51e809508b6fc2b0f90e3476ac7a9ff47e863ae95" +dependencies = [ + "scopeguard", + "windows-sys 0.61.2", +] + +[[package]] +name = "junction" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cde48f36530cbd91b65f050101e6c056d6eb743cea2a8f0f19beef69c6742ff8" +dependencies = [ + "scopeguard", + "windows-sys 0.61.2", +] + +[[package]] +name = "k256" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b" +dependencies = [ + "cfg-if", + "ecdsa 0.16.9", + "elliptic-curve 0.13.8", + "once_cell", + "sha2 0.10.9", + "signature 2.2.0", +] + +[[package]] +name = "keccak" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" +dependencies = [ + "cpufeatures 0.2.17", +] + +[[package]] +name = "konst" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "128133ed7824fcd73d6e7b17957c5eb7bacb885649bd8c69708b2331a10bcefb" +dependencies = [ + "konst_macro_rules", +] + +[[package]] +name = "konst_macro_rules" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4933f3f57a8e9d9da04db23fb153356ecaf00cbd14aee46279c33dc80925c37" + +[[package]] +name = "lazy_static" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" +dependencies = [ + "spin", +] + +[[package]] +name = "libc" +version = "0.2.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libsqlite3-sys" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "libyaml-rs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e126dda6f34391ab7b444f9922055facc83c07a910da3eb16f1e4d9c45dc777" + +[[package]] +name = "libz-sys" +version = "1.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f710a23e6dbf193214fd46ca56a9d6864e550abe86202184532ae7275e46de19" +dependencies = [ + "cc", + "cmake", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "line-index" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e27e0ed5a392a7f5ba0b3808a2afccff16c64933312c84b57618b49d1209bd2" +dependencies = [ + "nohash-hasher", + "text-size", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[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 = "lru-slab" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "maybe-owned" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4facc753ae494aeb6e3c22f839b158aebd4f9270f55cd3c79906c45476c47ab4" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest 0.10.7", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "miette" +version = "5.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59bb584eaeeab6bd0226ccf3509a69d7936d148cf3d036ad350abe35e8c6856e" +dependencies = [ + "miette-derive 5.10.0", + "once_cell", + "thiserror 1.0.69", + "unicode-width 0.1.14", +] + +[[package]] +name = "miette" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" +dependencies = [ + "backtrace", + "backtrace-ext", + "cfg-if", + "miette-derive 7.6.0", + "owo-colors", + "supports-color 3.0.2", + "supports-hyperlinks", + "supports-unicode", + "terminal_size", + "textwrap", + "unicode-width 0.1.14", +] + +[[package]] +name = "miette-derive" +version = "5.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49e7bc1560b95a3c4a25d03de42fe76ca718ab92d1a22a55b9b4cf67b3ae635c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "miette-derive" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", +] + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "monch" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bcc6ad3b93f756f2532d29f7c7291b8d246a2c460a99a3611327bb726830014" + +[[package]] +name = "multiversion_no_op" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "node-semver" +version = "2.2.0" +source = "git+https://github.com/pnpm/node-semver-rs?rev=aa94acb21a1cd4321fd789ff14598dd410cde2d9#aa94acb21a1cd4321fd789ff14598dd410cde2d9" +dependencies = [ + "bytecount", + "miette 7.6.0", + "nom 7.1.3", + "regress", + "serde", + "thiserror 1.0.69", +] + +[[package]] +name = "nohash-hasher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "nonmax" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "610a5acd306ec67f907abe5567859a3c693fb9886eb1f012ab8f2a47bef3db51" + +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93e7820bc0a80a0238e650327316f929ba18d5be054b647490a3a6a339f3e7c0" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.8", + "serde", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "objc2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags", + "objc2", +] + +[[package]] +name = "objc2-io-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" +dependencies = [ + "libc", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-open-directory" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb82bed227edf5201dfedf072bba4015a33d3d4a98519837295a90f0a23f676d" +dependencies = [ + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + +[[package]] +name = "ocb3" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c196e0276c471c843dd5777e7543a36a298a4be942a2a688d8111cd43390dedb" +dependencies = [ + "aead", + "cipher", + "ctr", + "subtle", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "owo-colors" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c45bb4a6ae1280ec0803b1ef9d3455eb50f01efbbe1447ab020f1d54fba9d8" +dependencies = [ + "supports-color 2.1.0", + "supports-color 3.0.2", +] + +[[package]] +name = "oxc_allocator" +version = "0.153.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9222664e7018ba33c0781da9b8b97007afdf86e4702123dde331c0f32168550" +dependencies = [ + "allocator-api2", + "hashbrown 0.17.1", + "oxc_data_structures", + "rustc-hash", +] + +[[package]] +name = "oxc_ast" +version = "0.153.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b02c8089e522e19f85f03e384042b9acd2e7013e125bc11e17ff052160b5ce8" +dependencies = [ + "bitflags", + "oxc_allocator", + "oxc_ast_macros", + "oxc_data_structures", + "oxc_diagnostics", + "oxc_estree", + "oxc_regular_expression", + "oxc_span", + "oxc_str", + "oxc_syntax", +] + +[[package]] +name = "oxc_ast_macros" +version = "0.153.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b795bfdf0b88722a9f0e9a61a9ef6d2ecd115a728f5f0290fcc66da3ec5151ea" +dependencies = [ + "phf", + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "oxc_ast_visit" +version = "0.153.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62ee65572c656f232524b4c55c0fe8fc9b54a0f444a40c74727f623aecb2dd54" +dependencies = [ + "oxc_allocator", + "oxc_ast", + "oxc_span", + "oxc_syntax", +] + +[[package]] +name = "oxc_data_structures" +version = "0.153.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fbea321d618c6b87789cfc19aee4e332525b25420c8c5644ea71f1ffae5e68" + +[[package]] +name = "oxc_diagnostics" +version = "0.153.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a93ce3ad9aacc728d625990815e41a844e3285c518b8a4508c4f1a423f5251cd" +dependencies = [ + "bytecount", + "cow-utils", + "itoa", + "memchr", + "oxc_span", + "percent-encoding", + "smallvec", + "textwrap", + "unicode-segmentation", + "unicode-width 0.2.2", +] + +[[package]] +name = "oxc_ecmascript" +version = "0.153.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52605e06c9ef037d346a45e89db78f60962e39f26f648b41d930854354ba1e93" +dependencies = [ + "dragonbox_ecma", + "itoa", + "num-bigint", + "num-traits", + "oxc_ast", + "oxc_data_structures", + "oxc_span", + "oxc_syntax", +] + +[[package]] +name = "oxc_estree" +version = "0.153.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58d091d1d56046eca8741fc89d7e205a9c4c560ff80313be931a6be57edbcc3e" + +[[package]] +name = "oxc_index" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "191884bee6c3744909a51acc7d78d4ae370d817b25875b10642f632327b6296e" +dependencies = [ + "nonmax", + "serde", +] + +[[package]] +name = "oxc_parser" +version = "0.153.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71ce9e6d646d84b30f8eb4e1a1b50c69e66f4ef08066c721a3b82a451326bad4" +dependencies = [ + "bitflags", + "cow-utils", + "memchr", + "num-bigint", + "num-traits", + "oxc_allocator", + "oxc_ast", + "oxc_data_structures", + "oxc_diagnostics", + "oxc_ecmascript", + "oxc_regular_expression", + "oxc_span", + "oxc_str", + "oxc_syntax", + "rustc-hash", + "seq-macro", +] + +[[package]] +name = "oxc_regular_expression" +version = "0.153.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "369f33abce8a9a6a49c97a16579d8bb42331f1dd0ea5826412729ab94085c155" +dependencies = [ + "bitflags", + "oxc_allocator", + "oxc_ast_macros", + "oxc_diagnostics", + "oxc_span", + "oxc_str", + "phf", + "rustc-hash", + "unicode-id-start", +] + +[[package]] +name = "oxc_span" +version = "0.153.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "152232e21d62e0ddd2a6c23867121c3252e663f05cdc432578d9e1efc52a253b" +dependencies = [ + "compact_str", + "oxc_allocator", + "oxc_ast_macros", + "oxc_estree", + "oxc_str", +] + +[[package]] +name = "oxc_str" +version = "0.153.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37716fe1827bf766fa68e9f1011408ca0149b90903ae171289c48401229e2f34" +dependencies = [ + "compact_str", + "hashbrown 0.17.1", + "oxc_allocator", + "oxc_estree", +] + +[[package]] +name = "oxc_syntax" +version = "0.153.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2214f0546f6df736d976b611782df7072fbe8010e4f3bbd3b98bee37e814b6a" +dependencies = [ + "bitflags", + "cow-utils", + "dragonbox_ecma", + "nonmax", + "oxc_allocator", + "oxc_ast_macros", + "oxc_estree", + "oxc_index", + "oxc_span", + "oxc_str", + "phf", + "unicode-id-start", +] + +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa 0.16.9", + "elliptic-curve 0.13.8", + "primeorder 0.13.6", + "sha2 0.10.9", +] + +[[package]] +name = "p256" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2c9239b2dbc807adbbe147e8cf72ea7450c3a0aabe62cb8e75ff4ec22e1f72a" +dependencies = [ + "ecdsa 0.17.0", + "elliptic-curve 0.14.3", + "primefield", + "primeorder 0.14.0", + "sha2 0.11.1", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa 0.16.9", + "elliptic-curve 0.13.8", + "primeorder 0.13.6", + "sha2 0.10.9", +] + +[[package]] +name = "p521" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fc9e2161f1f215afdfce23677034ae137bbd45016a880c2eb3ba8eb95f085b2" +dependencies = [ + "base16ct 0.2.0", + "ecdsa 0.16.9", + "elliptic-curve 0.13.8", + "primeorder 0.13.6", + "rand_core 0.6.4", + "sha2 0.10.9", +] + +[[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 = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "path-dedot" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07ba0ad7e047712414213ff67533e6dd477af0a4e1d14fb52343e53d30ea9397" +dependencies = [ + "once_cell", +] + +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "pep440_rs" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31095ca1f396e3de32745f42b20deef7bc09077f918b085307e8eab6ddd8fb9c" +dependencies = [ + "once_cell", + "serde", + "unicode-width 0.2.2", + "unscanny", + "version-ranges", +] + +[[package]] +name = "pep508_rs" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faee7227064121fcadcd2ff788ea26f0d8f2bd23a0574da11eca23bc935bcc05" +dependencies = [ + "boxcar", + "indexmap", + "itertools 0.13.0", + "once_cell", + "pep440_rs", + "regex", + "rustc-hash", + "serde", + "smallvec", + "thiserror 1.0.69", + "unicode-width 0.2.2", + "url", + "urlencoding", + "version-ranges", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pgp" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cfa4743b28656065ff4c0ba09e46b357a65e8c00fc2341e89084b82f87cbdf1" +dependencies = [ + "aead", + "aes", + "aes-gcm", + "aes-kw", + "argon2", + "base64 0.22.1", + "bitfields", + "block-padding", + "blowfish", + "buffer-redux", + "byteorder", + "bytes", + "camellia", + "cast5", + "cfb-mode", + "cipher", + "const-oid 0.9.6", + "crc24", + "curve25519-dalek", + "cx448", + "derive_builder", + "derive_more", + "des", + "digest 0.10.7", + "dsa", + "eax", + "ecdsa 0.16.9", + "ed25519-dalek", + "elliptic-curve 0.13.8", + "flate2", + "generic-array", + "hex", + "hkdf", + "idea", + "k256", + "log", + "md-5", + "memchr", + "nom 8.0.0", + "num-bigint-dig", + "num-traits", + "num_enum", + "ocb3", + "p256 0.13.2", + "p384", + "p521", + "rand 0.8.8", + "replace_with", + "ripemd", + "rsa", + "sha1", + "sha1-checked", + "sha2 0.10.9", + "sha3", + "signature 2.2.0", + "smallvec", + "snafu", + "subtle", + "twofish", + "x25519-dalek", + "zeroize", +] + +[[package]] +name = "phf" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6" +dependencies = [ + "phf_macros", + "phf_shared", + "serde", +] + +[[package]] +name = "phf_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fa8d0ca26d424d27630da600c6624696e7dec8bf7b3b492b383c5dc49e5e085" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6fd9027e2d9319be6349febd1db4e8d02aa544921200c9b777720ac34a3aa89" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pipe-trait" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1be1ec9e59f0360aefe84efa6f699198b685ab0d5718081e9f72aa2344289e2" + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der 0.7.10", + "pkcs8 0.10.2", + "spki 0.7.3", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der 0.7.10", + "spki 0.7.3", +] + +[[package]] +name = "pkcs8" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" +dependencies = [ + "der 0.8.3", + "spki 0.8.1", +] + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "pnpm-catalogs-config" +version = "0.0.1" +source = "git+https://github.com/pnpm/pnpm?rev=6b5b8ac53a6120a644c3693ecac8aaed8af88174#6b5b8ac53a6120a644c3693ecac8aaed8af88174" +dependencies = [ + "derive_more", + "miette 7.6.0", + "pnpm-catalogs-types", + "pnpm-workspace", +] + +[[package]] +name = "pnpm-catalogs-protocol-parser" +version = "0.0.1" +source = "git+https://github.com/pnpm/pnpm?rev=6b5b8ac53a6120a644c3693ecac8aaed8af88174#6b5b8ac53a6120a644c3693ecac8aaed8af88174" +dependencies = [ + "pnpm-catalogs-types", +] + +[[package]] +name = "pnpm-catalogs-resolver" +version = "0.0.1" +source = "git+https://github.com/pnpm/pnpm?rev=6b5b8ac53a6120a644c3693ecac8aaed8af88174#6b5b8ac53a6120a644c3693ecac8aaed8af88174" +dependencies = [ + "derive_more", + "miette 7.6.0", + "pnpm-catalogs-protocol-parser", + "pnpm-catalogs-types", + "pnpm-local-spec", +] + +[[package]] +name = "pnpm-catalogs-types" +version = "0.0.1" +source = "git+https://github.com/pnpm/pnpm?rev=6b5b8ac53a6120a644c3693ecac8aaed8af88174#6b5b8ac53a6120a644c3693ecac8aaed8af88174" + +[[package]] +name = "pnpm-cmd-shim" +version = "0.0.1" +source = "git+https://github.com/pnpm/pnpm?rev=6b5b8ac53a6120a644c3693ecac8aaed8af88174#6b5b8ac53a6120a644c3693ecac8aaed8af88174" +dependencies = [ + "derive_more", + "dunce", + "miette 7.6.0", + "node-semver", + "pipe-trait", + "pnpm-fs", + "pnpm-package-manifest", + "rayon", + "same-file", + "serde_json", + "shell-words", + "tracing", + "walkdir", +] + +[[package]] +name = "pnpm-config" +version = "0.0.1" +source = "git+https://github.com/pnpm/pnpm?rev=6b5b8ac53a6120a644c3693ecac8aaed8af88174#6b5b8ac53a6120a644c3693ecac8aaed8af88174" +dependencies = [ + "base64 0.23.1", + "derive_more", + "dunce", + "home", + "indexmap", + "libc", + "miette 7.6.0", + "node-semver", + "pipe-trait", + "pnpm-catalogs-types", + "pnpm-config-dir", + "pnpm-detect-libc", + "pnpm-env-replace", + "pnpm-fs", + "pnpm-git-utils", + "pnpm-lockfile", + "pnpm-matcher", + "pnpm-network", + "pnpm-package-is-installable", + "pnpm-package-manifest", + "pnpm-patching", + "pnpm-store-dir", + "pnpm-tarball", + "pnpm-versioning", + "pnpm-workspace", + "pnpm-workspace-state", + "pnpr-registry", + "serde", + "serde-saphyr", + "serde_json", + "smart-default", + "tracing", + "url", +] + +[[package]] +name = "pnpm-config-dir" +version = "0.0.1" +source = "git+https://github.com/pnpm/pnpm?rev=6b5b8ac53a6120a644c3693ecac8aaed8af88174#6b5b8ac53a6120a644c3693ecac8aaed8af88174" + +[[package]] +name = "pnpm-config-parse-overrides" +version = "0.0.1" +source = "git+https://github.com/pnpm/pnpm?rev=6b5b8ac53a6120a644c3693ecac8aaed8af88174#6b5b8ac53a6120a644c3693ecac8aaed8af88174" +dependencies = [ + "derive_more", + "miette 7.6.0", + "node-semver", + "pnpm-catalogs-resolver", + "pnpm-catalogs-types", + "pnpm-resolving-parse-wanted-dependency", +] + +[[package]] +name = "pnpm-crypto-hash" +version = "0.0.1" +source = "git+https://github.com/pnpm/pnpm?rev=6b5b8ac53a6120a644c3693ecac8aaed8af88174#6b5b8ac53a6120a644c3693ecac8aaed8af88174" +dependencies = [ + "base64 0.23.1", + "sha2 0.10.9", + "ssri", +] + +[[package]] +name = "pnpm-crypto-shasums-file" +version = "0.0.1" +source = "git+https://github.com/pnpm/pnpm?rev=6b5b8ac53a6120a644c3693ecac8aaed8af88174#6b5b8ac53a6120a644c3693ecac8aaed8af88174" +dependencies = [ + "base64 0.23.1", + "derive_more", + "libc", + "miette 7.6.0", + "pgp", + "pnpm-fs", + "pnpm-http", + "pnpm-network", + "pnpm-wasm-host", + "reqwest", +] + +[[package]] +name = "pnpm-default-reporter" +version = "0.0.1" +source = "git+https://github.com/pnpm/pnpm?rev=6b5b8ac53a6120a644c3693ecac8aaed8af88174#6b5b8ac53a6120a644c3693ecac8aaed8af88174" +dependencies = [ + "chrono", + "console", + "node-semver", + "owo-colors", + "pnpm-config", + "pnpm-matcher", + "pnpm-reporter", + "pnpm-text-sanitize", + "serde_json", +] + +[[package]] +name = "pnpm-deps-inspection-peers" +version = "0.0.1" +source = "git+https://github.com/pnpm/pnpm?rev=6b5b8ac53a6120a644c3693ecac8aaed8af88174#6b5b8ac53a6120a644c3693ecac8aaed8af88174" +dependencies = [ + "dunce", + "node-semver", + "owo-colors", + "pnpm-catalogs-resolver", + "pnpm-catalogs-types", + "pnpm-config", + "pnpm-lockfile", + "pnpm-matcher", + "pnpm-package-manifest", + "pnpm-resolving-parse-wanted-dependency", + "pnpm-resolving-resolver-base", + "pnpm-text-sanitize", + "pnpm-workspace", + "serde", + "serde_json", +] + +[[package]] +name = "pnpm-deps-path" +version = "0.0.1" +source = "git+https://github.com/pnpm/pnpm?rev=6b5b8ac53a6120a644c3693ecac8aaed8af88174#6b5b8ac53a6120a644c3693ecac8aaed8af88174" +dependencies = [ + "derive_more", + "node-semver", + "pnpm-crypto-hash", + "pnpm-tool-protocol", +] + +[[package]] +name = "pnpm-deps-restorer" +version = "0.0.1" +source = "git+https://github.com/pnpm/pnpm?rev=6b5b8ac53a6120a644c3693ecac8aaed8af88174#6b5b8ac53a6120a644c3693ecac8aaed8af88174" +dependencies = [ + "base64 0.23.1", + "derive_more", + "futures-util", + "indexmap", + "libc", + "miette 7.6.0", + "node-semver", + "pathdiff", + "pipe-trait", + "pnpm-catalogs-protocol-parser", + "pnpm-cmd-shim", + "pnpm-config", + "pnpm-deps-path", + "pnpm-detect-libc", + "pnpm-directory-fetcher", + "pnpm-executor", + "pnpm-fs", + "pnpm-git-fetcher", + "pnpm-graph-hasher", + "pnpm-hooks", + "pnpm-lockfile", + "pnpm-lockfile-verification", + "pnpm-matcher", + "pnpm-modules-yaml", + "pnpm-network", + "pnpm-package-is-installable", + "pnpm-package-manifest", + "pnpm-package-name", + "pnpm-patching", + "pnpm-pnpr-client", + "pnpm-process", + "pnpm-real-hoist", + "pnpm-reporter", + "pnpm-resolving-npm-resolver", + "pnpm-resolving-resolver-base", + "pnpm-shared-artifact-protocol", + "pnpm-store-dir", + "pnpm-tarball", + "pnpm-text-sanitize", + "pnpm-workspace", + "pnpm-workspace-task-scheduler", + "rayon", + "reflink-copy", + "rustc-hash", + "same-file", + "serde", + "serde_json", + "sha2 0.10.9", + "ssri", + "sysinfo", + "tempfile", + "tokio", + "tracing", + "url", + "windows-sys 0.61.2", +] + +[[package]] +name = "pnpm-detect-libc" +version = "0.0.1" +source = "git+https://github.com/pnpm/pnpm?rev=6b5b8ac53a6120a644c3693ecac8aaed8af88174#6b5b8ac53a6120a644c3693ecac8aaed8af88174" +dependencies = [ + "pnpm-process", +] + +[[package]] +name = "pnpm-diagnostics" +version = "0.0.1" +source = "git+https://github.com/pnpm/pnpm?rev=6b5b8ac53a6120a644c3693ecac8aaed8af88174#6b5b8ac53a6120a644c3693ecac8aaed8af88174" +dependencies = [ + "miette 7.6.0", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "pnpm-directory-fetcher" +version = "0.0.1" +source = "git+https://github.com/pnpm/pnpm?rev=6b5b8ac53a6120a644c3693ecac8aaed8af88174#6b5b8ac53a6120a644c3693ecac8aaed8af88174" +dependencies = [ + "derive_more", + "miette 7.6.0", + "pnpm-diagnostics", + "pnpm-git-fetcher", + "pnpm-package-manifest", + "pnpm-store-dir", + "serde_json", + "tracing", +] + +[[package]] +name = "pnpm-engine-pm-yarn-resolver" +version = "0.0.1" +source = "git+https://github.com/pnpm/pnpm?rev=6b5b8ac53a6120a644c3693ecac8aaed8af88174#6b5b8ac53a6120a644c3693ecac8aaed8af88174" +dependencies = [ + "derive_more", + "miette 7.6.0", + "node-semver", + "pnpm-crypto-shasums-file", + "pnpm-http", + "pnpm-lockfile", + "pnpm-network", + "pnpm-resolving-resolver-base", + "pnpm-tool-protocol", + "reqwest", + "serde", + "serde_json", + "ssri", + "tokio", +] + +[[package]] +name = "pnpm-engine-runtime-bun-resolver" +version = "0.0.1" +source = "git+https://github.com/pnpm/pnpm?rev=6b5b8ac53a6120a644c3693ecac8aaed8af88174#6b5b8ac53a6120a644c3693ecac8aaed8af88174" +dependencies = [ + "derive_more", + "miette 7.6.0", + "pnpm-crypto-shasums-file", + "pnpm-detect-libc", + "pnpm-http", + "pnpm-lockfile", + "pnpm-network", + "pnpm-resolving-npm-resolver", + "pnpm-resolving-resolver-base", + "pnpm-tool-protocol", + "reqwest", + "serde", + "serde_json", + "ssri", + "tokio", +] + +[[package]] +name = "pnpm-engine-runtime-deno-resolver" +version = "0.0.1" +source = "git+https://github.com/pnpm/pnpm?rev=6b5b8ac53a6120a644c3693ecac8aaed8af88174#6b5b8ac53a6120a644c3693ecac8aaed8af88174" +dependencies = [ + "base64 0.23.1", + "derive_more", + "miette 7.6.0", + "pnpm-detect-libc", + "pnpm-http", + "pnpm-lockfile", + "pnpm-network", + "pnpm-resolving-npm-resolver", + "pnpm-resolving-resolver-base", + "pnpm-tool-protocol", + "reqwest", + "serde", + "serde_json", + "ssri", + "tokio", +] + +[[package]] +name = "pnpm-engine-runtime-node-resolver" +version = "0.0.1" +source = "git+https://github.com/pnpm/pnpm?rev=6b5b8ac53a6120a644c3693ecac8aaed8af88174#6b5b8ac53a6120a644c3693ecac8aaed8af88174" +dependencies = [ + "derive_more", + "miette 7.6.0", + "node-semver", + "pnpm-crypto-shasums-file", + "pnpm-detect-libc", + "pnpm-http", + "pnpm-lockfile", + "pnpm-network", + "pnpm-resolving-resolver-base", + "pnpm-tool-protocol", + "reqwest", + "serde", + "serde_json", + "ssri", + "tokio", +] + +[[package]] +name = "pnpm-env-replace" +version = "0.0.1" +source = "git+https://github.com/pnpm/pnpm?rev=6b5b8ac53a6120a644c3693ecac8aaed8af88174#6b5b8ac53a6120a644c3693ecac8aaed8af88174" + +[[package]] +name = "pnpm-executor" +version = "0.0.1" +source = "git+https://github.com/pnpm/pnpm?rev=6b5b8ac53a6120a644c3693ecac8aaed8af88174#6b5b8ac53a6120a644c3693ecac8aaed8af88174" +dependencies = [ + "deno_task_shell", + "derive_more", + "dunce", + "libc", + "miette 7.6.0", + "pnpm-fs", + "pnpm-package-manifest", + "pnpm-process", + "pnpm-reporter", + "pnpm-which", + "serde_json", + "tokio", + "tracing", + "which", + "windows-sys 0.61.2", +] + +[[package]] +name = "pnpm-fs" +version = "0.0.1" +source = "git+https://github.com/pnpm/pnpm?rev=6b5b8ac53a6120a644c3693ecac8aaed8af88174#6b5b8ac53a6120a644c3693ecac8aaed8af88174" +dependencies = [ + "dashmap", + "derive_more", + "dunce", + "getrandom 0.4.3", + "junction 2.1.0", + "libc", + "miette 7.6.0", + "pathdiff", + "pipe-trait", + "pnpm-crypto-hash", + "pnpm-wasm-host", + "reflink-copy", + "same-file", + "tempfile", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "pnpm-fs-packlist" +version = "0.0.1" +source = "git+https://github.com/pnpm/pnpm?rev=6b5b8ac53a6120a644c3693ecac8aaed8af88174#6b5b8ac53a6120a644c3693ecac8aaed8af88174" +dependencies = [ + "derive_more", + "pnpm-diagnostics", + "pnpm-fs", + "pnpm-package-manifest", + "serde_json", + "tracing", +] + +[[package]] +name = "pnpm-git-fetcher" +version = "0.0.1" +source = "git+https://github.com/pnpm/pnpm?rev=6b5b8ac53a6120a644c3693ecac8aaed8af88174#6b5b8ac53a6120a644c3693ecac8aaed8af88174" +dependencies = [ + "derive_more", + "miette 7.6.0", + "node-semver", + "pipe-trait", + "pnpm-cmd-shim", + "pnpm-diagnostics", + "pnpm-executor", + "pnpm-fs", + "pnpm-fs-packlist", + "pnpm-git-utils", + "pnpm-network", + "pnpm-package-manifest", + "pnpm-process", + "pnpm-reporter", + "pnpm-store-dir", + "pnpm-which", + "serde_json", + "tempfile", + "tokio", + "tracing", + "which", +] + +[[package]] +name = "pnpm-git-utils" +version = "0.0.1" +source = "git+https://github.com/pnpm/pnpm?rev=6b5b8ac53a6120a644c3693ecac8aaed8af88174#6b5b8ac53a6120a644c3693ecac8aaed8af88174" +dependencies = [ + "libc", + "pnpm-process", +] + +[[package]] +name = "pnpm-graph-hasher" +version = "0.0.1" +source = "git+https://github.com/pnpm/pnpm?rev=6b5b8ac53a6120a644c3693ecac8aaed8af88174#6b5b8ac53a6120a644c3693ecac8aaed8af88174" +dependencies = [ + "base64 0.23.1", + "indexmap", + "pnpm-detect-libc", + "pnpm-fs", + "pnpm-process", + "serde_json", + "sha2 0.10.9", +] + +[[package]] +name = "pnpm-hooks" +version = "0.0.1" +source = "git+https://github.com/pnpm/pnpm?rev=6b5b8ac53a6120a644c3693ecac8aaed8af88174#6b5b8ac53a6120a644c3693ecac8aaed8af88174" +dependencies = [ + "async-trait", + "derive_more", + "libc", + "pnpm-crypto-hash", + "pnpm-lockfile", + "pnpm-process", + "pnpm-resolving-resolver-base", + "serde", + "serde_json", + "tokio", +] + +[[package]] +name = "pnpm-http" +version = "0.0.1" +source = "git+https://github.com/pnpm/pnpm?rev=6b5b8ac53a6120a644c3693ecac8aaed8af88174#6b5b8ac53a6120a644c3693ecac8aaed8af88174" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-util", + "http", + "percent-encoding", + "pnpm-wasm-host", + "reqwest", + "rustls", + "serde", + "serde_json", + "url", +] + +[[package]] +name = "pnpm-injected-deps-syncer" +version = "0.0.1" +source = "git+https://github.com/pnpm/pnpm?rev=6b5b8ac53a6120a644c3693ecac8aaed8af88174#6b5b8ac53a6120a644c3693ecac8aaed8af88174" +dependencies = [ + "derive_more", + "miette 7.6.0", + "pnpm-cmd-shim", + "pnpm-directory-fetcher", + "pnpm-fs", + "pnpm-modules-yaml", + "pnpm-package-manifest", + "pnpm-workspace", + "serde_json", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "pnpm-local-spec" +version = "0.0.1" +source = "git+https://github.com/pnpm/pnpm?rev=6b5b8ac53a6120a644c3693ecac8aaed8af88174#6b5b8ac53a6120a644c3693ecac8aaed8af88174" +dependencies = [ + "pnpm-fs", +] + +[[package]] +name = "pnpm-lockfile" +version = "0.0.1" +source = "git+https://github.com/pnpm/pnpm?rev=6b5b8ac53a6120a644c3693ecac8aaed8af88174#6b5b8ac53a6120a644c3693ecac8aaed8af88174" +dependencies = [ + "derive_more", + "indexmap", + "miette 7.6.0", + "node-semver", + "pipe-trait", + "pnpm-catalogs-types", + "pnpm-crypto-hash", + "pnpm-deps-path", + "pnpm-diagnostics", + "pnpm-fs", + "pnpm-package-manifest", + "pnpm-patching", + "pnpm-resolving-parse-wanted-dependency", + "pnpm-tool-protocol", + "rayon", + "serde", + "serde-saphyr", + "serde_json", + "split-first-char", + "ssri", + "url", +] + +[[package]] +name = "pnpm-lockfile-preferred-versions" +version = "0.0.1" +source = "git+https://github.com/pnpm/pnpm?rev=6b5b8ac53a6120a644c3693ecac8aaed8af88174#6b5b8ac53a6120a644c3693ecac8aaed8af88174" +dependencies = [ + "node-semver", + "pnpm-catalogs-protocol-parser", + "pnpm-catalogs-resolver", + "pnpm-catalogs-types", + "pnpm-lockfile", + "pnpm-package-manifest", + "pnpm-resolving-resolver-base", + "rayon", +] + +[[package]] +name = "pnpm-lockfile-verification" +version = "0.0.1" +source = "git+https://github.com/pnpm/pnpm?rev=6b5b8ac53a6120a644c3693ecac8aaed8af88174#6b5b8ac53a6120a644c3693ecac8aaed8af88174" +dependencies = [ + "chrono", + "derive_more", + "futures-util", + "miette 7.6.0", + "pnpm-diagnostics", + "pnpm-lockfile", + "pnpm-package-name", + "pnpm-reporter", + "pnpm-resolving-resolver-base", + "pnpm-wasm-host", + "serde", + "serde_json", + "sha2 0.10.9", + "tokio", +] + +[[package]] +name = "pnpm-matcher" +version = "0.0.1" +source = "git+https://github.com/pnpm/pnpm?rev=6b5b8ac53a6120a644c3693ecac8aaed8af88174#6b5b8ac53a6120a644c3693ecac8aaed8af88174" +dependencies = [ + "regex", +] + +[[package]] +name = "pnpm-modules-yaml" +version = "0.0.1" +source = "git+https://github.com/pnpm/pnpm?rev=6b5b8ac53a6120a644c3693ecac8aaed8af88174#6b5b8ac53a6120a644c3693ecac8aaed8af88174" +dependencies = [ + "derive_more", + "httpdate", + "indexmap", + "pathdiff", + "pipe-trait", + "pnpm-diagnostics", + "pnpm-fs", + "serde", + "serde-saphyr", + "serde_json", +] + +[[package]] +name = "pnpm-network" +version = "0.0.1" +source = "git+https://github.com/pnpm/pnpm?rev=6b5b8ac53a6120a644c3693ecac8aaed8af88174#6b5b8ac53a6120a644c3693ecac8aaed8af88174" +dependencies = [ + "bytes", + "core-foundation", + "derive_more", + "futures-util", + "miette 7.6.0", + "pipe-trait", + "pnpm-http", + "pnpm-process", + "reqwest", + "rustls", + "security-framework", + "security-framework-sys", + "tokio", + "tracing", + "webpki-root-certs", +] + +[[package]] +name = "pnpm-package-is-installable" +version = "0.0.1" +source = "git+https://github.com/pnpm/pnpm?rev=6b5b8ac53a6120a644c3693ecac8aaed8af88174#6b5b8ac53a6120a644c3693ecac8aaed8af88174" +dependencies = [ + "derive_more", + "miette 7.6.0", + "node-semver", + "pnpm-detect-libc", + "pnpm-semver-include-prerelease", + "serde", +] + +[[package]] +name = "pnpm-package-manager" +version = "0.0.1" +source = "git+https://github.com/pnpm/pnpm?rev=6b5b8ac53a6120a644c3693ecac8aaed8af88174#6b5b8ac53a6120a644c3693ecac8aaed8af88174" +dependencies = [ + "async-recursion", + "chrono", + "console", + "dashmap", + "derive_more", + "dialoguer", + "dunce", + "futures-util", + "home", + "httpdate", + "indexmap", + "miette 7.6.0", + "node-semver", + "pathdiff", + "pipe-trait", + "pnpm-catalogs-config", + "pnpm-catalogs-protocol-parser", + "pnpm-catalogs-resolver", + "pnpm-catalogs-types", + "pnpm-cmd-shim", + "pnpm-config", + "pnpm-config-parse-overrides", + "pnpm-crypto-hash", + "pnpm-deps-inspection-peers", + "pnpm-deps-path", + "pnpm-deps-restorer", + "pnpm-directory-fetcher", + "pnpm-engine-pm-yarn-resolver", + "pnpm-engine-runtime-bun-resolver", + "pnpm-engine-runtime-deno-resolver", + "pnpm-engine-runtime-node-resolver", + "pnpm-executor", + "pnpm-fs", + "pnpm-git-fetcher", + "pnpm-graph-hasher", + "pnpm-hooks", + "pnpm-injected-deps-syncer", + "pnpm-local-spec", + "pnpm-lockfile", + "pnpm-lockfile-preferred-versions", + "pnpm-lockfile-verification", + "pnpm-matcher", + "pnpm-modules-yaml", + "pnpm-network", + "pnpm-package-is-installable", + "pnpm-package-manifest", + "pnpm-package-name", + "pnpm-patching", + "pnpm-real-hoist", + "pnpm-registry", + "pnpm-reporter", + "pnpm-resolving-default-resolver", + "pnpm-resolving-deps-resolver", + "pnpm-resolving-git-resolver", + "pnpm-resolving-jsr-specifier-parser", + "pnpm-resolving-local-resolver", + "pnpm-resolving-npm-resolver", + "pnpm-resolving-parse-wanted-dependency", + "pnpm-resolving-resolver-base", + "pnpm-resolving-tarball-resolver", + "pnpm-semver-include-prerelease", + "pnpm-store-dir", + "pnpm-tarball", + "pnpm-text-sanitize", + "pnpm-tool-protocol", + "pnpm-wasm-host", + "pnpm-workspace", + "pnpm-workspace-manifest-writer", + "pnpm-workspace-projects-graph", + "pnpm-workspace-range-resolver", + "pnpm-workspace-spec", + "pnpm-workspace-state", + "pnpm-workspace-task-scheduler", + "rayon", + "reflink-copy", + "rustc-hash", + "serde", + "serde_json", + "sha2 0.10.9", + "ssri", + "tempfile", + "tokio", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "pnpm-package-manifest" +version = "0.0.1" +source = "git+https://github.com/pnpm/pnpm?rev=6b5b8ac53a6120a644c3693ecac8aaed8af88174#6b5b8ac53a6120a644c3693ecac8aaed8af88174" +dependencies = [ + "derive_more", + "json5", + "miette 7.6.0", + "node-semver", + "pnpm-diagnostics", + "pnpm-fs", + "pnpm-tool-protocol", + "pnpm-yaml-document-sync", + "serde", + "serde_json", + "strum", + "tempfile", +] + +[[package]] +name = "pnpm-package-name" +version = "0.0.1" +source = "git+https://github.com/pnpm/pnpm?rev=6b5b8ac53a6120a644c3693ecac8aaed8af88174#6b5b8ac53a6120a644c3693ecac8aaed8af88174" + +[[package]] +name = "pnpm-patching" +version = "0.0.1" +source = "git+https://github.com/pnpm/pnpm?rev=6b5b8ac53a6120a644c3693ecac8aaed8af88174#6b5b8ac53a6120a644c3693ecac8aaed8af88174" +dependencies = [ + "derive_more", + "diffy", + "indexmap", + "miette 7.6.0", + "node-semver", + "pnpm-fs", + "pnpm-git-fetcher", + "pnpm-package-manifest", + "pnpm-process", + "serde_json", + "sha2 0.10.9", + "tempfile", +] + +[[package]] +name = "pnpm-pnpr-client" +version = "0.0.1" +source = "git+https://github.com/pnpm/pnpm?rev=6b5b8ac53a6120a644c3693ecac8aaed8af88174#6b5b8ac53a6120a644c3693ecac8aaed8af88174" +dependencies = [ + "base64 0.23.1", + "derive_more", + "futures-util", + "indexmap", + "miette 7.6.0", + "pnpm-catalogs-types", + "pnpm-config", + "pnpm-crypto-hash", + "pnpm-graph-hasher", + "pnpm-http", + "pnpm-lockfile", + "pnpm-lockfile-verification", + "pnpm-network", + "pnpm-python-resolver", + "pnpm-shared-artifact-protocol", + "reqwest", + "serde", + "serde_json", + "sha2 0.10.9", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "pnpm-process" +version = "0.0.1" +source = "git+https://github.com/pnpm/pnpm?rev=6b5b8ac53a6120a644c3693ecac8aaed8af88174#6b5b8ac53a6120a644c3693ecac8aaed8af88174" +dependencies = [ + "pnpm-wasm-host", + "serde", + "serde_json", + "tokio", +] + +[[package]] +name = "pnpm-python-resolver" +version = "0.0.1" +source = "git+https://github.com/pnpm/pnpm?rev=6b5b8ac53a6120a644c3693ecac8aaed8af88174#6b5b8ac53a6120a644c3693ecac8aaed8af88174" +dependencies = [ + "miette 7.6.0", + "pep440_rs", + "pep508_rs", + "percent-encoding", + "pnpm-crypto-hash", + "pubgrub", + "serde", + "serde_json", + "ssri", + "tracing", + "url", +] + +[[package]] +name = "pnpm-real-hoist" +version = "0.0.1" +source = "git+https://github.com/pnpm/pnpm?rev=6b5b8ac53a6120a644c3693ecac8aaed8af88174#6b5b8ac53a6120a644c3693ecac8aaed8af88174" +dependencies = [ + "derive_more", + "indexmap", + "miette 7.6.0", + "pnpm-lockfile", +] + +[[package]] +name = "pnpm-registry" +version = "0.0.1" +source = "git+https://github.com/pnpm/pnpm?rev=6b5b8ac53a6120a644c3693ecac8aaed8af88174#6b5b8ac53a6120a644c3693ecac8aaed8af88174" +dependencies = [ + "derive_more", + "miette 7.6.0", + "node-semver", + "pipe-trait", + "pnpm-diagnostics", + "pnpm-http", + "pnpm-network", + "pnpm-package-manifest", + "reqwest", + "serde", + "serde_json", + "ssri", + "tokio", + "tracing", +] + +[[package]] +name = "pnpm-reporter" +version = "0.0.1" +source = "git+https://github.com/pnpm/pnpm?rev=6b5b8ac53a6120a644c3693ecac8aaed8af88174#6b5b8ac53a6120a644c3693ecac8aaed8af88174" +dependencies = [ + "gethostname", + "pnpm-wasm-host", + "serde", + "serde_json", +] + +[[package]] +name = "pnpm-resolving-default-resolver" +version = "0.0.1" +source = "git+https://github.com/pnpm/pnpm?rev=6b5b8ac53a6120a644c3693ecac8aaed8af88174#6b5b8ac53a6120a644c3693ecac8aaed8af88174" +dependencies = [ + "derive_more", + "miette 7.6.0", + "pnpm-config", + "pnpm-engine-pm-yarn-resolver", + "pnpm-engine-runtime-bun-resolver", + "pnpm-engine-runtime-deno-resolver", + "pnpm-engine-runtime-node-resolver", + "pnpm-network", + "pnpm-resolving-git-resolver", + "pnpm-resolving-local-resolver", + "pnpm-resolving-npm-resolver", + "pnpm-resolving-resolver-base", + "pnpm-resolving-tarball-resolver", +] + +[[package]] +name = "pnpm-resolving-deps-resolver" +version = "0.0.1" +source = "git+https://github.com/pnpm/pnpm?rev=6b5b8ac53a6120a644c3693ecac8aaed8af88174#6b5b8ac53a6120a644c3693ecac8aaed8af88174" +dependencies = [ + "async-recursion", + "chrono", + "derive_more", + "futures-util", + "miette 7.6.0", + "node-semver", + "pathdiff", + "pipe-trait", + "pnpm-catalogs-resolver", + "pnpm-catalogs-types", + "pnpm-deps-path", + "pnpm-fs", + "pnpm-hooks", + "pnpm-local-spec", + "pnpm-lockfile", + "pnpm-network", + "pnpm-package-manifest", + "pnpm-package-name", + "pnpm-patching", + "pnpm-resolving-jsr-specifier-parser", + "pnpm-resolving-npm-resolver", + "pnpm-resolving-parse-wanted-dependency", + "pnpm-resolving-resolver-base", + "pnpm-semver-include-prerelease", + "pnpm-tool-protocol", + "rustc-hash", + "serde_json", + "smart-default", + "tokio", + "tracing", +] + +[[package]] +name = "pnpm-resolving-git-resolver" +version = "0.0.1" +source = "git+https://github.com/pnpm/pnpm?rev=6b5b8ac53a6120a644c3693ecac8aaed8af88174#6b5b8ac53a6120a644c3693ecac8aaed8af88174" +dependencies = [ + "derive_more", + "miette 7.6.0", + "node-semver", + "pnpm-git-fetcher", + "pnpm-git-utils", + "pnpm-http", + "pnpm-lockfile", + "pnpm-network", + "pnpm-process", + "pnpm-reporter", + "pnpm-resolving-resolver-base", + "pnpm-store-dir", + "pnpm-tarball", + "reqwest", + "serde_json", + "tokio", + "tracing", +] + +[[package]] +name = "pnpm-resolving-jsr-specifier-parser" +version = "0.0.1" +source = "git+https://github.com/pnpm/pnpm?rev=6b5b8ac53a6120a644c3693ecac8aaed8af88174#6b5b8ac53a6120a644c3693ecac8aaed8af88174" +dependencies = [ + "derive_more", + "miette 7.6.0", + "pnpm-package-name", +] + +[[package]] +name = "pnpm-resolving-local-resolver" +version = "0.0.1" +source = "git+https://github.com/pnpm/pnpm?rev=6b5b8ac53a6120a644c3693ecac8aaed8af88174#6b5b8ac53a6120a644c3693ecac8aaed8af88174" +dependencies = [ + "derive_more", + "home", + "miette 7.6.0", + "pnpm-fs", + "pnpm-local-spec", + "pnpm-lockfile", + "pnpm-package-manifest", + "pnpm-package-name", + "pnpm-resolving-resolver-base", + "pnpm-tarball", + "serde_json", + "tracing", +] + +[[package]] +name = "pnpm-resolving-npm-resolver" +version = "0.0.1" +source = "git+https://github.com/pnpm/pnpm?rev=6b5b8ac53a6120a644c3693ecac8aaed8af88174#6b5b8ac53a6120a644c3693ecac8aaed8af88174" +dependencies = [ + "chrono", + "dashmap", + "derive_more", + "httpdate", + "indexmap", + "libc", + "miette 7.6.0", + "node-semver", + "percent-encoding", + "pipe-trait", + "pnpm-config", + "pnpm-deps-path", + "pnpm-http", + "pnpm-lockfile", + "pnpm-network", + "pnpm-package-name", + "pnpm-registry", + "pnpm-resolving-jsr-specifier-parser", + "pnpm-resolving-resolver-base", + "pnpm-store-dir", + "pnpm-wasm-host", + "pnpm-workspace-range-resolver", + "pnpm-workspace-spec", + "rayon", + "reqwest", + "serde", + "serde_json", + "sha2 0.10.9", + "ssri", + "tokio", + "tracing", +] + +[[package]] +name = "pnpm-resolving-parse-wanted-dependency" +version = "0.0.1" +source = "git+https://github.com/pnpm/pnpm?rev=6b5b8ac53a6120a644c3693ecac8aaed8af88174#6b5b8ac53a6120a644c3693ecac8aaed8af88174" +dependencies = [ + "pnpm-package-name", +] + +[[package]] +name = "pnpm-resolving-resolver-base" +version = "0.0.1" +source = "git+https://github.com/pnpm/pnpm?rev=6b5b8ac53a6120a644c3693ecac8aaed8af88174#6b5b8ac53a6120a644c3693ecac8aaed8af88174" +dependencies = [ + "chrono", + "derive_more", + "miette 7.6.0", + "node-semver", + "pnpm-config", + "pnpm-lockfile", + "pnpm-network", + "pnpm-registry", + "serde", + "serde_json", + "url", +] + +[[package]] +name = "pnpm-resolving-tarball-resolver" +version = "0.0.1" +source = "git+https://github.com/pnpm/pnpm?rev=6b5b8ac53a6120a644c3693ecac8aaed8af88174#6b5b8ac53a6120a644c3693ecac8aaed8af88174" +dependencies = [ + "httpdate", + "pnpm-crypto-hash", + "pnpm-fs", + "pnpm-http", + "pnpm-lockfile", + "pnpm-network", + "pnpm-reporter", + "pnpm-resolving-resolver-base", + "pnpm-store-dir", + "pnpm-tarball", + "reqwest", + "serde_json", + "ssri", +] + +[[package]] +name = "pnpm-semver-include-prerelease" +version = "0.0.1" +source = "git+https://github.com/pnpm/pnpm?rev=6b5b8ac53a6120a644c3693ecac8aaed8af88174#6b5b8ac53a6120a644c3693ecac8aaed8af88174" +dependencies = [ + "node-semver", +] + +[[package]] +name = "pnpm-shared-artifact-protocol" +version = "0.0.1" +source = "git+https://github.com/pnpm/pnpm?rev=6b5b8ac53a6120a644c3693ecac8aaed8af88174#6b5b8ac53a6120a644c3693ecac8aaed8af88174" +dependencies = [ + "base64 0.23.1", + "derive_more", + "p256 0.14.0", + "serde", + "serde_json", + "sha2 0.10.9", +] + +[[package]] +name = "pnpm-store-dir" +version = "0.0.1" +source = "git+https://github.com/pnpm/pnpm?rev=6b5b8ac53a6120a644c3693ecac8aaed8af88174#6b5b8ac53a6120a644c3693ecac8aaed8af88174" +dependencies = [ + "dashmap", + "derive_more", + "dunce", + "miette 7.6.0", + "node-semver", + "pipe-trait", + "pnpm-crypto-hash", + "pnpm-deps-path", + "pnpm-diagnostics", + "pnpm-fs", + "pnpm-package-manifest", + "pnpm-package-name", + "pnpm-shared-artifact-protocol", + "rayon", + "rmp-serde", + "rusqlite", + "serde", + "serde_json", + "sha2 0.10.9", + "smart-default", + "ssri", + "tokio", + "tracing", + "url", + "windows-sys 0.61.2", +] + +[[package]] +name = "pnpm-tarball" +version = "0.0.1" +source = "git+https://github.com/pnpm/pnpm?rev=6b5b8ac53a6120a644c3693ecac8aaed8af88174#6b5b8ac53a6120a644c3693ecac8aaed8af88174" +dependencies = [ + "bytes", + "dashmap", + "derive_more", + "flate2", + "futures-util", + "libc", + "miette 7.6.0", + "num_cpus", + "pipe-trait", + "pnpm-diagnostics", + "pnpm-fs", + "pnpm-http", + "pnpm-lockfile", + "pnpm-network", + "pnpm-package-manifest", + "pnpm-reporter", + "pnpm-store-dir", + "rayon", + "reqwest", + "rustls", + "serde", + "serde_json", + "ssri", + "tar", + "tokio", + "tracing", + "url", + "zip", + "zstd", + "zune-inflate", +] + +[[package]] +name = "pnpm-text-sanitize" +version = "0.0.1" +source = "git+https://github.com/pnpm/pnpm?rev=6b5b8ac53a6120a644c3693ecac8aaed8af88174#6b5b8ac53a6120a644c3693ecac8aaed8af88174" + +[[package]] +name = "pnpm-tool-protocol" +version = "0.0.1" +source = "git+https://github.com/pnpm/pnpm?rev=6b5b8ac53a6120a644c3693ecac8aaed8af88174#6b5b8ac53a6120a644c3693ecac8aaed8af88174" + +[[package]] +name = "pnpm-versioning" +version = "0.0.1" +source = "git+https://github.com/pnpm/pnpm?rev=6b5b8ac53a6120a644c3693ecac8aaed8af88174#6b5b8ac53a6120a644c3693ecac8aaed8af88174" +dependencies = [ + "derive_more", + "getrandom 0.4.3", + "indexmap", + "miette 7.6.0", + "node-semver", + "pathdiff", + "pnpm-matcher", + "pnpm-package-manifest", + "serde", + "serde-saphyr", + "serde_json", +] + +[[package]] +name = "pnpm-wasm-host" +version = "0.0.1" +source = "git+https://github.com/pnpm/pnpm?rev=6b5b8ac53a6120a644c3693ecac8aaed8af88174#6b5b8ac53a6120a644c3693ecac8aaed8af88174" +dependencies = [ + "futures-util", + "serde", + "serde_json", +] + +[[package]] +name = "pnpm-which" +version = "0.0.1" +source = "git+https://github.com/pnpm/pnpm?rev=6b5b8ac53a6120a644c3693ecac8aaed8af88174#6b5b8ac53a6120a644c3693ecac8aaed8af88174" +dependencies = [ + "pnpm-fs", + "which", +] + +[[package]] +name = "pnpm-workspace" +version = "0.0.1" +source = "git+https://github.com/pnpm/pnpm?rev=6b5b8ac53a6120a644c3693ecac8aaed8af88174#6b5b8ac53a6120a644c3693ecac8aaed8af88174" +dependencies = [ + "cap-primitives", + "derive_more", + "indexmap", + "libc", + "miette 7.6.0", + "pathdiff", + "pnpm-catalogs-types", + "pnpm-fs", + "pnpm-local-spec", + "pnpm-package-manifest", + "pnpm-workspace-projects-graph", + "rayon", + "same-file", + "serde", + "serde-saphyr", + "serde_json", + "wax", + "windows-sys 0.61.2", + "yaml_serde", +] + +[[package]] +name = "pnpm-workspace-manifest-writer" +version = "0.0.1" +source = "git+https://github.com/pnpm/pnpm?rev=6b5b8ac53a6120a644c3693ecac8aaed8af88174#6b5b8ac53a6120a644c3693ecac8aaed8af88174" +dependencies = [ + "derive_more", + "indexmap", + "miette 7.6.0", + "pnpm-catalogs-types", + "pnpm-config", + "pnpm-config-parse-overrides", + "pnpm-fs", + "pnpm-package-manifest", + "pnpm-workspace", + "pnpm-yaml-document-sync", + "serde", + "serde-saphyr", + "serde_json", + "tempfile", + "yaml_serde", + "yamlpatch", + "yamlpath", +] + +[[package]] +name = "pnpm-workspace-projects-graph" +version = "0.0.1" +source = "git+https://github.com/pnpm/pnpm?rev=6b5b8ac53a6120a644c3693ecac8aaed8af88174#6b5b8ac53a6120a644c3693ecac8aaed8af88174" +dependencies = [ + "indexmap", + "node-semver", + "pnpm-catalogs-protocol-parser", + "pnpm-catalogs-resolver", + "pnpm-catalogs-types", + "pnpm-fs", + "pnpm-workspace-range-resolver", + "pnpm-workspace-spec", + "rayon", +] + +[[package]] +name = "pnpm-workspace-range-resolver" +version = "0.0.1" +source = "git+https://github.com/pnpm/pnpm?rev=6b5b8ac53a6120a644c3693ecac8aaed8af88174#6b5b8ac53a6120a644c3693ecac8aaed8af88174" +dependencies = [ + "node-semver", +] + +[[package]] +name = "pnpm-workspace-spec" +version = "0.0.1" +source = "git+https://github.com/pnpm/pnpm?rev=6b5b8ac53a6120a644c3693ecac8aaed8af88174#6b5b8ac53a6120a644c3693ecac8aaed8af88174" + +[[package]] +name = "pnpm-workspace-state" +version = "0.0.1" +source = "git+https://github.com/pnpm/pnpm?rev=6b5b8ac53a6120a644c3693ecac8aaed8af88174#6b5b8ac53a6120a644c3693ecac8aaed8af88174" +dependencies = [ + "derive_more", + "indexmap", + "pnpm-diagnostics", + "pnpm-fs", + "serde", + "serde_json", + "tempfile", +] + +[[package]] +name = "pnpm-workspace-task-scheduler" +version = "0.0.1" +source = "git+https://github.com/pnpm/pnpm?rev=6b5b8ac53a6120a644c3693ecac8aaed8af88174#6b5b8ac53a6120a644c3693ecac8aaed8af88174" +dependencies = [ + "derive_more", + "futures-util", + "indexmap", + "miette 7.6.0", + "pnpm-config", + "pnpm-fs", + "pnpm-reporter", + "rustc-hash", + "serde", +] + +[[package]] +name = "pnpm-yaml-document-sync" +version = "0.0.1" +source = "git+https://github.com/pnpm/pnpm?rev=6b5b8ac53a6120a644c3693ecac8aaed8af88174#6b5b8ac53a6120a644c3693ecac8aaed8af88174" +dependencies = [ + "serde-saphyr", + "serde_json", + "yaml_serde", + "yamlpatch", + "yamlpath", +] + +[[package]] +name = "pnpr-error" +version = "0.0.1" +source = "git+https://github.com/pnpm/pnpm?rev=6b5b8ac53a6120a644c3693ecac8aaed8af88174#6b5b8ac53a6120a644c3693ecac8aaed8af88174" +dependencies = [ + "derive_more", + "url", +] + +[[package]] +name = "pnpr-package-name" +version = "0.0.1" +source = "git+https://github.com/pnpm/pnpm?rev=6b5b8ac53a6120a644c3693ecac8aaed8af88174#6b5b8ac53a6120a644c3693ecac8aaed8af88174" +dependencies = [ + "derive_more", + "pep508_rs", + "pnpr-error", + "serde", + "strum", +] + +[[package]] +name = "pnpr-registry" +version = "0.0.1" +source = "git+https://github.com/pnpm/pnpm?rev=6b5b8ac53a6120a644c3693ecac8aaed8af88174#6b5b8ac53a6120a644c3693ecac8aaed8af88174" +dependencies = [ + "derive_more", + "indexmap", + "pnpr-package-name", + "wax", +] + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "pori" +version = "0.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a63d338dec139f56dacc692ca63ad35a6be6a797442479b55acd611d79e906" +dependencies = [ + "nom 7.1.3", +] + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "serde_core", + "writeable", + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "pretty_assertions" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" +dependencies = [ + "diff", + "yansi", +] + +[[package]] +name = "primefield" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c555a6e4eb7d4e158fcb028c835c3b8642206ddc279b5c6b202ef9a8bdb592f4" +dependencies = [ + "crypto-bigint 0.7.10", + "crypto-common 0.2.2", + "ff 0.14.0", + "rand_core 0.10.1", + "subtle", + "zeroize", +] + +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve 0.13.8", +] + +[[package]] +name = "primeorder" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c9f42978c78a00e3d68f69fc03e57a234debae69da4020a4fb588fcdcd07b06" +dependencies = [ + "elliptic-curve 0.14.3", + "once_cell", + "primefield", + "serdect 0.4.3", + "wnaf", +] + +[[package]] +name = "priority-queue" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93980406f12d9f8140ed5abe7155acb10bb1e69ea55c88960b9c2f117445ef96" +dependencies = [ + "equivalent", + "indexmap", + "serde", +] + +[[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 = "pubgrub" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1c3256d319f1ed35251223140ab8f29fd6b0528a1216344efd904c651fecd5e" +dependencies = [ + "indexmap", + "log", + "priority-queue", + "rustc-hash", + "thiserror 2.0.21", + "version-ranges", +] + +[[package]] +name = "quinn" +version = "0.11.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.21", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.3", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.21", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[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 = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "reflink-copy" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9dd7ab4af0363d5ccfd2838d782a28196cf32a5cc2e4fe3c5dc83f2be588b8b" +dependencies = [ + "cfg-if", + "libc", + "rustix", + "windows", +] + +[[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 = "regress" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32eef8b209c3c1c15dbad02c1f30f9539f00dc7253e0cbcdaae442a50a09d7c1" +dependencies = [ + "memchr", +] + +[[package]] +name = "replace_with" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51743d3e274e2b18df81c4dc6caf8a5b8e15dbe799e0dca05c7617380094e884" + +[[package]] +name = "reqwest" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac 0.12.1", + "subtle", +] + +[[package]] +name = "rfc6979" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4a459cddafb3fe76b31fd8f1108007566c40301feb64dc7b54656eb7388172b" +dependencies = [ + "crypto-bigint 0.7.10", + "hmac 0.13.0", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "ripemd" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd124222d17ad93a644ed9d011a40f4fb64aa54275c08cc216524a9ea82fb09f" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "rmp" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "rmp-serde" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155" +dependencies = [ + "rmp", + "serde", +] + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid 0.9.6", + "digest 0.10.7", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8 0.10.2", + "rand_core 0.6.4", + "signature 2.2.0", + "spki 0.7.3", + "subtle", + "zeroize", +] + +[[package]] +name = "rsqlite-vfs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" +dependencies = [ + "hashbrown 0.16.1", + "thiserror 2.0.21", +] + +[[package]] +name = "rusqlite" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0d2b0146dd9661bf67bb107c0bb2a55064d556eeb3fc314151b957f313bcd4e" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", + "sqlite-wasm-rs", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustix-linux-procfs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fc84bf7e9aa16c4f2c758f27412dc9841341e16aa682d9c7ac308fe3ee12056" +dependencies = [ + "once_cell", + "rustix", +] + +[[package]] +name = "rustls" +version = "0.23.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +dependencies = [ + "aws-lc-rs", + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "ryu-js" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04d056b875a9d2e6cb9a61d127afee9ac5999b9f87bcb32079d1318e505be714" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct 0.2.0", + "der 0.7.10", + "generic-array", + "pkcs8 0.10.2", + "serdect 0.2.0", + "subtle", + "zeroize", +] + +[[package]] +name = "sec1" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56d437c2f19203ce5f7122e507831de96f3d2d4d3be5af44a0b0a09d8a80e4d" +dependencies = [ + "base16ct 1.0.0", + "ctutils", + "der 0.8.3", + "hybrid-array", + "subtle", + "zeroize", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "self_cell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab42ca02749e120097e328d91d415325bdf43b1c72c4c8badf37375fe40a813" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "seq-macro" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc711410fbe7399f390ca1c3b60ad0f53f80e95c5eb935e52268a0e2cd49acc" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-saphyr" +version = "0.0.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bd22781911de0ca6debda95f073c8f18bec65d1a94f1fa9573f3102e514cea4" +dependencies = [ + "ahash", + "annotate-snippets", + "base64 0.22.1", + "encoding_rs_io", + "getrandom 0.3.4", + "granit-parser", + "nohash-hasher", + "num-traits", + "serde_core", + "smallvec", + "zmij", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "indexmap", + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serdect" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a84f14a19e9a014bb9f4512488d9829a68e04ecabffb0f9904cd1ace94598177" +dependencies = [ + "base16ct 0.2.0", + "serde", +] + +[[package]] +name = "serdect" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f42f67da2385b51a5f9652db9c93d78aeaf7610bf5ec366080b6de810604af53" +dependencies = [ + "base16ct 0.2.0", + "serde", +] + +[[package]] +name = "serdect" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e" +dependencies = [ + "base16ct 1.0.0", + "serde", +] + +[[package]] +name = "sha-1" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5058ada175748e33390e40e872bd0fe59a19f265d0158daa551c5a88a76009c" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha1-checked" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89f599ac0c323ebb1c6082821a54962b839832b03984598375bff3975b804423" +dependencies = [ + "digest 0.10.7", + "sha1", + "zeroize", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", + "sha2-asm", +] + +[[package]] +name = "sha2" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", +] + +[[package]] +name = "sha2-asm" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b845214d6175804686b2bd482bcffe96651bb2d1200742b712003504a2dac1ab" +dependencies = [ + "cc", +] + +[[package]] +name = "sha3" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" +dependencies = [ + "digest 0.10.7", + "keccak", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[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 = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest 0.10.7", + "rand_core 0.6.4", +] + +[[package]] +name = "signature" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" +dependencies = [ + "digest 0.11.3", + "rand_core 0.10.1", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "siphasher" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +dependencies = [ + "serde", +] + +[[package]] +name = "smart-default" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eb01866308440fc64d6c44d9e86c5cc17adfe33c4d6eed55da9145044d0ffc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "smawk" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8e2fb0f499abb4d162f2bedad68f5ef91a1682b5a03596ddb67efd37768d100" + +[[package]] +name = "snafu" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e45cb604038abb7b926b679887b3226d8d0f23874b66623625a0454be425a4b7" +dependencies = [ + "snafu-derive", +] + +[[package]] +name = "snafu-derive" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "287f59010008f0d7cf5e3b03196d666c1acc46c8d3e9cf34c28a1a7157601e72" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der 0.7.10", +] + +[[package]] +name = "spki" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef958a98b9d5da290cfc78946e9f3e61e1e62a18db0d92cac0b83cc161491a9" +dependencies = [ + "base64ct", + "der 0.8.3", +] + +[[package]] +name = "split-first-char" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9997ca938c186fe85387bedd38c54522f1800d7707b14ed4b8516a384c2c05" + +[[package]] +name = "sqlite-wasm-rs" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75" +dependencies = [ + "cc", + "js-sys", + "rsqlite-vfs", + "wasm-bindgen", +] + +[[package]] +name = "ssri" +version = "9.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7a2b3c2bc9693bcb40870c4e9b5bf0d79f9cb46273321bf855ec513e919082" +dependencies = [ + "base64 0.21.7", + "digest 0.10.7", + "hex", + "miette 5.10.0", + "serde", + "sha-1", + "sha2 0.10.9", + "thiserror 1.0.69", + "xxhash-rust", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "subfeature" +version = "1.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d17bf55e69a49b140d9298be68c319c283c5479c391c8393b70e14d32b907f82" +dependencies = [ + "memchr", + "regex", + "serde", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "supports-color" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6398cde53adc3c4557306a96ce67b302968513830a77a95b2b17305d9719a89" +dependencies = [ + "is-terminal", + "is_ci", +] + +[[package]] +name = "supports-color" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c64fc7232dd8d2e4ac5ce4ef302b1d81e0b80d055b9d77c7c4f51f6aa4c867d6" +dependencies = [ + "is_ci", +] + +[[package]] +name = "supports-hyperlinks" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e396b6523b11ccb83120b115a0b7366de372751aa6edf19844dfb13a6af97e91" + +[[package]] +name = "supports-unicode" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7401a30af6cb5818bb64852270bb722533397edcfc7344954a38f420819ece2" + +[[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.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "sys_traits" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a35f11f9773f11930a7d1e6276766f1577400ee2530b7f0cf3844c2dcbf12b" +dependencies = [ + "junction 1.4.2", + "libc", + "sys_traits_macros", + "windows-sys 0.59.0", +] + +[[package]] +name = "sys_traits_macros" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "181f22127402abcf8ee5c83ccd5b408933fec36a6095cf82cda545634692657e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "sysinfo" +version = "0.39.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2071df9448915b71c4fe6d25deaf1c22f12bd234f01540b77312bb8e41361e6" +dependencies = [ + "libc", + "memchr", + "ntapi", + "objc2-core-foundation", + "objc2-io-kit", + "objc2-open-directory", + "windows", +] + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "tar" +version = "0.4.46" +source = "git+https://github.com/pnpm/pnpm?rev=6b5b8ac53a6120a644c3693ecac8aaed8af88174#6b5b8ac53a6120a644c3693ecac8aaed8af88174" +dependencies = [ + "filetime", + "libc", + "xattr", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "terminal_size" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" +dependencies = [ + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "text-size" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f18aa187839b2bdb1ad2fa35ead8c4c2976b64e4363c386d45ac0f7ee85c9233" + +[[package]] +name = "textwrap" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ecfad6c3abc80a577f2b91c1e412ee57e7a060d430b553c1b0c940974ebcd49" +dependencies = [ + "icu_segmenter", + "smawk", + "unicode-width 0.2.2", +] + +[[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.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl 2.0.21", +] + +[[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.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "serde_core", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + +[[package]] +name = "tokio" +version = "1.53.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce3335fa71841cda333a58d7615b03901380ecf09d59b3296d21f8bbac0dde4e" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[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.6", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "async-compression", + "bitflags", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "pin-project-lite", + "tokio", + "tokio-util", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-serde" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" +dependencies = [ + "serde", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "serde", + "serde_json", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", + "tracing-serde", +] + +[[package]] +name = "tree-sitter" +version = "0.26.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17ebdd3a5a7e28a1890b876fdbd0c3c0fe0a6336cffaa104f11b9f720c9daa29" +dependencies = [ + "cc", + "regex", + "regex-syntax", + "serde_json", + "streaming-iterator", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-iter" +version = "1.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ec40618be69571d6487d2ef96a84a2749c98a279a12346375cbf19b871420eb" +dependencies = [ + "tree-sitter", +] + +[[package]] +name = "tree-sitter-language" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0af592be68c579aa78a16846bd19422978c3c52e438523d45ff5d1bff1f9d4a" + +[[package]] +name = "tree-sitter-yaml" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c223db85f05e34794f065454843b0668ebc15d240ada63e2b5939f43ce7c97" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "twofish" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a78e83a30223c757c3947cd144a31014ff04298d8719ae10d03c31c0448c8013" +dependencies = [ + "cipher", +] + +[[package]] +name = "typed-path" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "unicode-id-start" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0825d2e09dcae814a4273c1e1435979d414ede25b2ee235b1545da6682483765" + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "unscanny" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9df2af067a7953e9c3831320f35c1cc0600c30d44d9f7a12b01db1cd88d6b47" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version-ranges" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31e9bd4e9c9ff6a2a9b5969462ba26216af3e010df0377dad8320ab515262ef8" +dependencies = [ + "smallvec", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" +dependencies = [ + "js-sys", + "tokio", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.6", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wax" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f8cbf8125142b9b30321ac8721f54c52fbcd6659f76cf863d5e2e38c07a3d7b" +dependencies = [ + "const_format", + "itertools 0.14.0", + "nom 7.1.3", + "pori", + "regex", + "thiserror 2.0.21", + "walkdir", +] + +[[package]] +name = "web-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e49d0e65abc00c142e8f9428da991da9f1a250c39be97459901eb12b9b49f62a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "which" +version = "8.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bae2f2b2b816647a1cab1acc91f5bd20812d53cb344382635ec2181940c8034f" +dependencies = [ + "libc", +] + +[[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-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[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" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[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-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[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-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[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.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[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 = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winx" +version = "0.36.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f3fd376f71958b862e7afb20cfe5a22830e1963462f3a17f49d82a6c1d1f42d" +dependencies = [ + "bitflags", + "windows-sys 0.59.0", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wnaf" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "795ca18b3fdb5e62bf982199278341ddcf7ebf7d32e25e212ad05d496e95f6fa" +dependencies = [ + "ff 0.14.0", + "group 0.14.0", + "hybrid-array", + "primefield", +] + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + +[[package]] +name = "x25519-dalek" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +dependencies = [ + "curve25519-dalek", + "rand_core 0.6.4", + "serde", + "zeroize", +] + +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + +[[package]] +name = "xxhash-rust" +version = "0.8.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "550a2b930b62486a393c52d5c3b84bff264b28aa437ed64694d31e93b1757af7" + +[[package]] +name = "yaml_serde" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33b729a08a9a6be689bbad3e2bf8015926db54b6622cc89c3a5f7dc174b9e918" +dependencies = [ + "indexmap", + "itoa", + "libyaml-rs", + "ryu", + "serde", +] + +[[package]] +name = "yamlpatch" +version = "1.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aec7b59ab1c3b70872ab54a84b3bbd91ab743ea8e59fc02f6f0c45c04426392" +dependencies = [ + "indexmap", + "line-index", + "serde_json", + "subfeature", + "thiserror 2.0.21", + "yaml_serde", + "yamlpath", +] + +[[package]] +name = "yamlpath" +version = "1.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a2ea2334936adb60c891686760016e723e220caee5c5b581a16b575ba85b2f0" +dependencies = [ + "line-index", + "self_cell", + "serde", + "thiserror 2.0.21", + "tree-sitter", + "tree-sitter-iter", + "tree-sitter-yaml", +] + +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "serde", + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "zip" +version = "8.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b" +dependencies = [ + "crc32fast", + "flate2", + "indexmap", + "memchr", + "typed-path", + "zopfli", +] + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zopfli" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aaf7fc5d30c28483d93805c4a5e12b05bbb52407fa67c5f8bd552374cd01fb11" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + +[[package]] +name = "zstd" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db3ae9bf90af6e40197bef767eeefc46ad6397420fe873830bd85e5fbbfb3c70" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "8.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ded520f16b29e1a9b771c722221b2b0a0398c2316d9bb3c42b458a5414d7434b" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.1.2+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "481ada9c543b2323cbb7bbdbb5249177fbf0188ed959ed2c15e4b22421c84ed3" +dependencies = [ + "cc", + "pkg-config", +] + +[[package]] +name = "zune-inflate" +version = "0.2.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" +dependencies = [ + "simd-adler32", +] diff --git a/native/Cargo.toml b/native/Cargo.toml new file mode 100644 index 000000000000..397ac3460c9b --- /dev/null +++ b/native/Cargo.toml @@ -0,0 +1,122 @@ +[workspace] +members = ["cli", "client-transfer", "dependency-scanner", "ignore", "install", "object-import", "workspace-config"] +resolver = "3" + +# pnpm's engine (pacquet) at the commit that released `@pnpm/napi` 12.16.0, the +# engine version `workspace.jsonc` ships. Bump together with that version. +[workspace.dependencies] +pnpm-config = { git = "https://github.com/pnpm/pnpm", rev = "6b5b8ac53a6120a644c3693ecac8aaed8af88174" } +pnpm-default-reporter = { git = "https://github.com/pnpm/pnpm", rev = "6b5b8ac53a6120a644c3693ecac8aaed8af88174" } +pnpm-diagnostics = { git = "https://github.com/pnpm/pnpm", rev = "6b5b8ac53a6120a644c3693ecac8aaed8af88174" } +pnpm-deps-path = { git = "https://github.com/pnpm/pnpm", rev = "6b5b8ac53a6120a644c3693ecac8aaed8af88174" } +pnpm-fs = { git = "https://github.com/pnpm/pnpm", rev = "6b5b8ac53a6120a644c3693ecac8aaed8af88174" } +pnpm-hooks = { git = "https://github.com/pnpm/pnpm", rev = "6b5b8ac53a6120a644c3693ecac8aaed8af88174" } +pnpm-lockfile = { git = "https://github.com/pnpm/pnpm", rev = "6b5b8ac53a6120a644c3693ecac8aaed8af88174" } +pnpm-modules-yaml = { git = "https://github.com/pnpm/pnpm", rev = "6b5b8ac53a6120a644c3693ecac8aaed8af88174" } +pnpm-network = { git = "https://github.com/pnpm/pnpm", rev = "6b5b8ac53a6120a644c3693ecac8aaed8af88174" } +pnpm-package-manager = { git = "https://github.com/pnpm/pnpm", rev = "6b5b8ac53a6120a644c3693ecac8aaed8af88174" } +pnpm-package-manifest = { git = "https://github.com/pnpm/pnpm", rev = "6b5b8ac53a6120a644c3693ecac8aaed8af88174" } +pnpm-reporter = { git = "https://github.com/pnpm/pnpm", rev = "6b5b8ac53a6120a644c3693ecac8aaed8af88174" } +pnpm-resolving-default-resolver = { git = "https://github.com/pnpm/pnpm", rev = "6b5b8ac53a6120a644c3693ecac8aaed8af88174" } +pnpm-resolving-deps-resolver = { git = "https://github.com/pnpm/pnpm", rev = "6b5b8ac53a6120a644c3693ecac8aaed8af88174" } +pnpm-resolving-npm-resolver = { git = "https://github.com/pnpm/pnpm", rev = "6b5b8ac53a6120a644c3693ecac8aaed8af88174" } +pnpm-resolving-parse-wanted-dependency = { git = "https://github.com/pnpm/pnpm", rev = "6b5b8ac53a6120a644c3693ecac8aaed8af88174" } +pnpm-resolving-resolver-base = { git = "https://github.com/pnpm/pnpm", rev = "6b5b8ac53a6120a644c3693ecac8aaed8af88174" } +pnpm-store-dir = { git = "https://github.com/pnpm/pnpm", rev = "6b5b8ac53a6120a644c3693ecac8aaed8af88174" } +pnpm-tarball = { git = "https://github.com/pnpm/pnpm", rev = "6b5b8ac53a6120a644c3693ecac8aaed8af88174" } +pnpm-workspace = { git = "https://github.com/pnpm/pnpm", rev = "6b5b8ac53a6120a644c3693ecac8aaed8af88174" } + +# Repeats pnpm's own `[patch.crates-io]` at the same commit: a git dependency +# does not inherit its workspace's patches. Keep the node-semver extension +# that supplies the npm-compatible grammar used by the native CLI. +[patch.crates-io] +dashmap = { git = "https://github.com/pnpm/dashmap", rev = "4401b5c0b2626266dd5d5a9202677bca8b53e57a" } +tar = { git = "https://github.com/pnpm/pnpm", rev = "6b5b8ac53a6120a644c3693ecac8aaed8af88174" } +node-semver = { git = "https://github.com/pnpm/node-semver-rs", rev = "aa94acb21a1cd4321fd789ff14598dd410cde2d9" } + +[workspace.lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(dylint_lib, values("perfectionist"))'] } + +[workspace.lints.clippy] +pedantic = { level = "warn", priority = -1 } +nursery = { level = "warn", priority = -1 } + +# Windows sizes `PathBuf` 8 bytes wider, carrying `InstallFrozenLockfileError` past the +# 128-byte threshold it clears on unix, and with it every `install`, `add`, `update` and +# `remove` signature that wraps that error. +result_large_err = "allow" # deferred: shrink `SymlinkDirectDependenciesError::SymlinkPackage`, the variant at the root of that chain +doc_link_with_quotes = "allow" # false positive: matches markdown link titles +unreadable_literal = "allow" # false positive: octal file modes +case_sensitive_file_extension_comparisons = "allow" # extension checks are intentionally case-sensitive +implicit_hasher = "allow" # only matters across a public API +unnecessary_debug_formatting = "allow" # keeps the quoted `Path` output +option_option = "allow" # distinguishes missing from null +cast_possible_truncation = "allow" # deliberate numeric narrowing +cast_sign_loss = "allow" +cast_possible_wrap = "allow" +cast_precision_loss = "allow" +missing_errors_doc = "allow" # error enums are self-documenting (#[display] + #[diagnostic(code)]); a # Errors section would just restate them +missing_panics_doc = "allow" # deferred: refactor away / document / #[expect] each panic before enabling +too_many_lines = "allow" # pacquet mirrors pnpm's function decomposition +struct_excessive_bools = "allow" # option structs mirror pnpm's +fn_params_excessive_bools = "allow" # mirrors pnpm's option parameters +match_same_arms = "allow" # arms kept explicit to mirror pnpm's switch cases +unnecessary_wraps = "allow" +unused_async = "allow" # deferred: audit each async signature (trait/seam-required?) before enabling +similar_names = "allow" # names ported verbatim from pnpm +items_after_statements = "allow" +needless_continue = "allow" +many_single_char_names = "allow" # perfectionist's single_letter_* lints already cover this with finer per-position control + +# nursery-group opt-outs (the group is experimental; these lints are either +# false positives on this code or fight conventions the project keeps on purpose) +use_self = "allow" # spelling out the concrete type name over Self is left to author discretion +too_long_first_doc_paragraph = "allow" # doc comments deliberately open with a detailed pnpm-parity summary, not a one-line lede +missing_const_for_fn = "allow" # const-ness is a forward semver commitment and this nursery lint is noisy / churns as const-eval grows +option_if_let_else = "allow" # the suggested map_or_else closures read worse than the if-let they replace +significant_drop_tightening = "allow" # the flagged guards are held deliberately (atomic check-then-act); early-dropping is FP-prone and behavioral +redundant_pub_crate = "allow" # pub(crate) is kept to document intended visibility even where technically redundant +derive_partial_eq_without_eq = "allow" # deriving Eq is a forward semver commitment that all fields stay Eq +branches_sharing_code = "allow" # false-positive-prone: clippy itself flags its own suggestion as needing adjustment +useless_let_if_seq = "allow" # rewrites large init blocks into an awkward if-as-expression +single_option_map = "allow" # flags test helpers that exist precisely to dedupe a `.map` over an Option +iter_with_drain = "allow" # false positive: drain(..) reuses the batch Vec's allocation across loop iterations; into_iter() would consume it +literal_string_with_formatting_args = "allow" # false positive: `${VAR:-default}` strings are .npmrc / env-replace fixtures, not Rust format args +collection_is_never_read = "allow" # false positive: the flagged Vec owns sockets to keep them alive, it is never meant to be read + +# restriction lints opted into individually — not part of any default group or +# the nursery group, so these lines are what enable them (kept on purpose). +clone_on_ref_ptr = "warn" +if_then_some_else_none = "warn" +mod_module_files = "warn" # forbids mod.rs, enforcing the flat module.rs layout (see CODE_STYLE_GUIDE.md) +undocumented_unsafe_blocks = "warn" +unnecessary_safety_comment = "warn" +todo = "warn" +unimplemented = "warn" +exit = "warn" +infinite_loop = "warn" +mem_forget = "warn" +unused_result_ok = "warn" +pathbuf_init_then_push = "warn" +string_add = "warn" +verbose_file_reads = "warn" + +# cargo-group lints opted into individually (the cargo group is otherwise off). +negative_feature_names = "warn" +redundant_feature_names = "warn" +wildcard_dependencies = "warn" + + +# Local optimized builds (`pnpm run build`). Thin LTO keeps a rebuild after an +# edit around 20 s instead of over 3 minutes with fat LTO, and the binary is +# within a few percent as fast. +[profile.release] +lto = "thin" +strip = "symbols" + +# Release binaries (`pnpm run build:dist`). Matches pnpm's cross-crate release +# optimization: smaller and slightly faster, but slow to build. +[profile.dist] +inherits = "release" +lto = "fat" +codegen-units = 1 diff --git a/native/cli/Cargo.toml b/native/cli/Cargo.toml new file mode 100644 index 000000000000..98d459b44e46 --- /dev/null +++ b/native/cli/Cargo.toml @@ -0,0 +1,60 @@ +[package] +name = "bit-cli" +version = "0.1.0" +edition = "2024" +publish = false +description = "The bit command line: commands implemented in Rust run in process, every other command runs the Node.js CLI" + +[[bin]] +name = "bit" +path = "src/main.rs" + +[features] +# The ported e2e cases (`tests/e2e`), which need the Node.js CLI of `tests/e2e/bit-version`. +e2e = [] + +[[test]] +name = "e2e" +path = "tests/e2e/main.rs" +required-features = ["e2e"] + +[dependencies] +regex = "1" +indexmap = "2" +bit-client-transfer = { path = "../client-transfer" } +bit-dependency-scanner = { path = "../dependency-scanner" } +bit-ignore = { path = "../ignore" } +bit-workspace-config = { path = "../workspace-config" } +diff = "0.1.13" +flate2 = "1" +tar = "0.4" +chrono = { version = "0.4", default-features = false, features = ["clock"] } +getrandom = "0.3" +bit-install = { path = "../install" } +bit-object-import = { path = "../object-import" } +node-semver = "2" +regress = "=0.12.0" +reqwest = { version = "0.13", default-features = false, features = ["json", "rustls"] } +async-trait = "0.1" +pnpm-deps-path = { workspace = true } +pnpm-diagnostics = { workspace = true } +pnpm-fs = { workspace = true } +pnpm-lockfile = { workspace = true } +serde = { version = "1", features = ["derive"] } +serde_json = { version = "1", features = ["preserve_order"] } +sha1 = "0.10" +tempfile = "3" +tokio = { version = "1", features = ["rt-multi-thread", "sync"] } +unicode-width = "0.2" + +[target.'cfg(unix)'.dependencies] +libc = "0.2" + +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.61", features = ["Win32_System_Console"] } + +[target.'cfg(windows)'.dev-dependencies] +winapi-util = "0.1" + +[lints] +workspace = true diff --git a/native/cli/src/commands.rs b/native/cli/src/commands.rs new file mode 100644 index 000000000000..70f04a34ef01 --- /dev/null +++ b/native/cli/src/commands.rs @@ -0,0 +1,258 @@ +//! The commands implemented in Rust (`bit-cli`). A command is looked up by its +//! name or one of its aliases, exactly as the Node.js CLI spells them. +//! +//! Dispatches top-level and subcommand names to native [`Command`] handlers and [`find`] lookup. + +mod add; +mod app_cmd; +mod artifacts_cmd; +mod aspect; +mod build_cmd; +mod capsule; +mod cat; +mod cat_scope; +mod check_types; +mod checkout; +mod ci; +mod clear_cache; +mod cli_cmd; +mod clone; +mod color; +mod compile_cmd; +mod completion_cmd; +mod component_issues; +mod config; +mod create; +mod debug_cat; +mod debug_load; +mod delete; +mod dependents; +mod deprecate; +mod deps; +mod details; +mod diff; +mod doctor; +mod eject; +mod eject_conf; +mod engine; +mod envs; +mod export; +mod file_history; +mod fork; +mod format_cmd; +mod gc; +mod git; +mod globals; +mod graph_cmd; +mod ids; +mod import; +mod init; +mod insights; +mod install; +mod installation; +mod internalize; +mod inventory; +mod lane; +mod link_cmd; +mod lint_cmd; +mod list; +mod local_only; +mod log; +mod login; +mod logout; +mod mcp_server; +mod merge; +mod mini_status; +mod move_paths; +mod new_cmd; +mod npmrc; +mod pack_cmd; +mod pattern; +mod patterns; +mod preview_cmd; +mod publish_cmd; +mod recover; +mod recovery; +mod refactor; +mod remote; +mod remote_action; +mod remote_resolution; +mod remove; +mod rename; +mod reset; +mod resume_export; +mod ripple; +mod run_action; +mod run_cmd; +mod schema_cmd; +mod scope_rename; +mod scope_set; +mod scope_trust; +mod script; +mod search; +mod server_cmd; +mod set_peer; +mod show_cmd; +mod snap; +mod snap_distance; +mod start_cmd; +mod stash; +mod status; +mod switch; +mod system; +mod tag; +mod templates; +mod test_cmd; +#[cfg(test)] +mod test_support; +mod ui_build; +mod uninstall; +mod unuse_cmd; +mod update_cmd; +mod use_cmd; +mod validate; +mod version; +mod version_history; +mod watch_cmd; +mod whoami; +mod workspace_command; +mod ws_config; + +use crate::native::Outcome; +use std::{ffi::OsString, io}; + +/// Descriptor for a native CLI command, its aliases, and its entry-point runner. +pub struct Command { + pub name: &'static str, + pub aliases: &'static [&'static str], + /// Receives every argument after the executable, starting with the command name. + pub run: fn(&[OsString]) -> io::Result, +} + +pub const COMMANDS: &[Command] = &[ + add::COMMAND, + app_cmd::COMMAND, + artifacts_cmd::COMMAND, + aspect::COMMAND, + build_cmd::COMMAND, + capsule::COMMAND, + cat::COMMAND, + cat_scope::COMMAND, + check_types::COMMAND, + checkout::COMMAND, + checkout::REVERT, + ci::COMMAND, + clear_cache::COMMAND, + cli_cmd::COMMAND, + clone::COMMAND, + compile_cmd::COMMAND, + completion_cmd::COMMAND, + component_issues::COMMAND, + config::COMMAND, + create::COMMAND, + debug_cat::CAT_COMPONENT, + debug_cat::CAT_LANE, + debug_cat::CAT_LANE_HISTORY, + debug_cat::CAT_OBJECT, + debug_cat::CAT_VERSION_HISTORY, + debug_load::COMMAND, + delete::COMMAND, + dependents::COMMAND, + deprecate::COMMAND, + deprecate::UNDEPRECATE, + deps::COMMAND, + details::COMMAND, + doctor::COMMAND, + eject::COMMAND, + eject_conf::COMMAND, + envs::COMMAND, + file_history::BLAME, + fork::COMMAND, + format_cmd::COMMAND, + gc::COMMAND, + diff::COMMAND, + export::COMMAND, + file_history::LOG_FILE, + git::COMMAND, + globals::COMMAND, + graph_cmd::COMMAND, + import::FETCH, + import::IMPORT, + init::COMMAND, + insights::COMMAND, + install::COMMAND, + internalize::COMMAND, + lane::COMMAND, + link_cmd::COMMAND, + lint_cmd::COMMAND, + list::COMMAND, + local_only::COMMAND, + log::COMMAND, + login::COMMAND, + logout::COMMAND, + mcp_server::COMMAND, + merge::COMMAND, + mini_status::COMMAND, + move_paths::COMMAND, + new_cmd::COMMAND, + npmrc::COMMAND, + pack_cmd::COMMAND, + pattern::COMMAND, + preview_cmd::GENERATE_PREVIEW, + preview_cmd::SERVE_PREVIEW, + publish_cmd::COMMAND, + recover::COMMAND, + refactor::COMMAND, + remote::COMMAND, + remove::COMMAND, + reset::COMMAND, + resume_export::COMMAND, + rename::COMMAND, + ripple::COMMAND, + run_action::COMMAND, + run_cmd::COMMAND, + schema_cmd::COMMAND, + scope_rename::COMMAND, + script::COMMAND, + search::COMMAND, + server_cmd::COMMAND, + set_peer::SET_PEER, + set_peer::UNSET_PEER, + show_cmd::COMMAND, + snap::COMMAND, + snap_distance::COMMAND, + start_cmd::COMMAND, + stash::COMMAND, + status::COMMAND, + switch::COMMAND, + system::COMMAND, + tag::COMMAND, + templates::COMMAND, + test_cmd::COMMAND, + ui_build::COMMAND, + uninstall::COMMAND, + unuse_cmd::COMMAND, + update_cmd::COMMAND, + use_cmd::COMMAND, + validate::COMMAND, + version::COMMAND, + version_history::COMMAND, + watch_cmd::COMMAND, + deps::WHY, + whoami::COMMAND, + ws_config::COMMAND, +]; + +pub fn find(name: &str) -> Option<&'static Command> { + let trimmed = name.trim(); + if trimmed.is_empty() + || trimmed.len() > 64 + || !trimmed.is_ascii() + || trimmed.bytes().any(|byte| byte.is_ascii_whitespace()) + { + return None; + } + COMMANDS + .iter() + .find(|command| command.name == trimmed || command.aliases.contains(&trimmed)) +} diff --git a/native/cli/src/commands/add.rs b/native/cli/src/commands/add.rs new file mode 100644 index 000000000000..48e854fbedc5 --- /dev/null +++ b/native/cli/src/commands/add.rs @@ -0,0 +1,128 @@ +//! Native `bit add` tracking: retain the projected `.bitmap` until component links succeed. +//! +//! Coordinates [`options`], [`preparation`], [`execution`], [`linking`], and [`report`] rendering. +pub(in crate::commands) mod docs; + +pub(in crate::commands) use cache::invalidate as invalidate_component_dependencies; +pub(super) use cache::restrict as restrict_dependency_cache; +pub(in crate::commands) use dependencies::packages as component_package_dependencies; +pub(in crate::commands) use docs::Prepared as PreparedDocs; +pub(in crate::commands) use linking::overlay as overlay_link_components; +pub(super) use mutation::ProjectedLinker; + +mod cache; +mod cleanup; +mod dependencies; +mod diagnostics; +mod execution; +mod linking; +mod mutation; +mod options; +mod output; +mod preparation; +mod projection; +mod remote; +mod report; + +use super::{ + Command, + color::Chalk, + engine::Engine, + status::{autosync::Startup, capture}, + workspace_command, +}; +use crate::{native::Outcome, workspace}; +use bit_object_import::component_tracking::Plan; +use std::{env, ffi::OsString, io, path::Path}; + +/// Native `bit add` command descriptor and alias table. +pub const COMMAND: Command = Command { name: "add", aliases: &["a"], run }; + +/// Executes `bit add` against `args`, declining when arguments fall outside native tracking support. +fn run(args: &[OsString]) -> io::Result { + let Some(argv) = workspace_command::utf8_args(args) else { + return Ok(Outcome::Declined("tracking arguments are not UTF-8".to_owned())); + }; + let Some(options) = options::parse(&argv[1..]) else { + return Ok(Outcome::Declined("outside native tracking grammar".to_owned())); + }; + let cwd = env::current_dir()?; + let Some(root) = workspace::root(&cwd) else { + if diagnostics::ci() { + return Ok(Outcome::Declined( + "CI workspace diagnostics require the published stack".to_owned(), + )); + } + return output::failure( + &cwd, + args, + options.json, + &Startup::default(), + io::Error::other( + "This command can only be run inside a bit workspace. Please check that you are inside a bit workspace and then re-run (to initialize a new workspace please use bit init)", + ), + ); + }; + if !root.join(".bitmap").is_file() + || !root.join("workspace.jsonc").is_file() + || !bit_workspace_config::scope_path(&root).join("objects").is_dir() + { + if diagnostics::ci() { + return Ok(Outcome::Declined( + "CI startup diagnostics require the published stack".to_owned(), + )); + } + eprintln!("{}",Chalk::detect(args).red(r#"fatal: unable to load the workspace. workspace.jsonc or .bitmap or local-scope are missing. run "bit init" to generate the missing files"#)); + return Ok(Outcome::Exit(std::process::ExitCode::FAILURE)); + } + run_workspace(&root, &cwd, args, &options) +} + +#[inline] +fn run_workspace( + root: &Path, + cwd: &Path, + args: &[OsString], + options: &options::Options, +) -> io::Result { + let mut startup = Startup::default(); + let prepared = preparation::prepare(root, cwd, options, &mut startup); + let prepared = match prepared { + Ok(prepared) => prepared, + Err(error) + if !startup.committed + && diagnostics::ci() + && error.kind() == io::ErrorKind::InvalidInput => + { + return Ok(Outcome::Declined(format!( + "CI tracking diagnostics require the published stack: {error}", + ))); + } + Err(error) if startup.committed || error.kind() == io::ErrorKind::InvalidInput => { + return output::failure(root, args, options.json, &startup, error); + } + Err(error) => return Ok(Outcome::Declined(format!("tracking: {error}"))), + }; + workspace_command::checkout_lifecycle::before(&startup)?; + let chalk = Chalk::detect(args); + let execute = |output: &bit_install::output::Output| { + let component_count = prepared.plan.components().len(); + if component_count > 0 { + output.status(&format!("loading {component_count} component(s)")); + } + workspace_command::checkout_lifecycle::loaded(&startup); + execution::run(root, prepared, options.json, chalk, output) + }; + if options.json { + workspace_command::execute_json(root, args, "add", execute) + } else { + workspace_command::execute_then(root, args, "add", execute) + } +} + +struct Prepared { + plan: Plan, + projection: projection::Projection, + capture: Option, + engine: Engine, +} diff --git a/native/cli/src/commands/add/cache.rs b/native/cli/src/commands/add/cache.rs new file mode 100644 index 000000000000..f99fc1f7d454 --- /dev/null +++ b/native/cli/src/commands/add/cache.rs @@ -0,0 +1,102 @@ +//! Link issues block dependency-cache saves; new [`WorkspaceComponent`] package manifests invalidate loader entries. +//! +//! Provides [`restrict`] and [`invalidate`] helpers for `bit add` and identity mutations. +use crate::commands::status::capture::Capture; +use bit_install::model::WorkspaceComponent; +use std::{ + collections::HashSet, + fs, io, + path::{Path, PathBuf}, +}; + +/// Blocks dependency-cache writes for newly captured [`WorkspaceComponent`]s whose `node_modules` links are absent. +pub(in crate::commands) fn restrict(root: &Path, capture: &mut Capture) { + let captured_ids: HashSet<&str> = capture.captured + .iter() + .filter(|row| row["directory"].as_str().map(str::trim) != Some(".")) + .filter_map(|row| row["identity"].as_str().map(identity)) + .collect(); + let node_modules = root.join("node_modules"); + let identities = capture.components + .iter() + .filter(|component| { + let package_name = component.package_name.trim(); + captured_ids.contains(identity(&component.identity)) + && (package_name.is_empty() || !node_modules.join(package_name).exists()) + }) + .map(|component| identity(&component.identity).to_owned()) + .collect::>(); + capture.cache.block_dependencies(&identities); +} + +#[inline] +fn identity(value: &str) -> &str { + let trimmed = value.trim(); + trimmed.split_once('@').map_or(trimmed, |(identity, _)| identity.trim()) +} + +/// Observed `node_modules` `package.json` preimages for tracked [`WorkspaceComponent`]s. +pub(super) struct Manifests(Vec<(PathBuf, Option)>); +impl Manifests { + pub(super) fn observe(root: &Path, components: &[WorkspaceComponent]) -> Self { + Self( + components + .iter() + .filter(|component| { + let root_dir = component.root_dir.trim(); + let package_name = component.package_name.trim(); + !root_dir.is_empty() + && root_dir != "." + && !package_name.is_empty() + && !component.files + .iter() + .any(|file| file.trim() == "package.json") + }) + .map(|component| { + let path = root + .join("node_modules") + .join(component.package_name.trim()) + .join("package.json"); + let before = serialized(&path); + (path, before) + }) + .collect(), + ) + } + #[inline] + pub(super) fn changed(&self) -> bool { + self.0 + .iter() + .any(|(path, before)| before.is_none() || serialized(path).as_ref() != before.as_ref()) + } +} +#[inline] +fn serialized(path: &Path) -> Option { + if fs::metadata(path).ok()?.len() > 4 * 1024 * 1024 { + return None; + } + let value: serde_json::Value = bit_object_import::parse_jsonc(&fs::read(path).ok()?).ok()?; + bit_object_import::stringify_json(&value, false).ok() +} + +pub(in crate::commands) fn invalidate( + root: &Path, + components: &[WorkspaceComponent], +) -> io::Result<()> { + let cache = bit_object_import::scope_path(root).join("cache/components/deps"); + for component in components + .iter() + .filter(|component| component.root_dir != ".") + { + let id = component.id.to_string_without_version(); + let key = match component.id.version + .as_deref() + .filter(|version| !version.is_empty() && !matches!(*version, "latest" | "head")) + { + Some(version) => format!("{id}@{version}"), + None => id, + }; + bit_install::dependencies::cache::remove_entry(&cache, &key)?; + } + Ok(()) +} diff --git a/native/cli/src/commands/add/cleanup.rs b/native/cli/src/commands/add/cleanup.rs new file mode 100644 index 000000000000..7ca80f35ff44 --- /dev/null +++ b/native/cli/src/commands/add/cleanup.rs @@ -0,0 +1,25 @@ +//! Consumer destruction cleans [`temporary`] files before publishing `.bitmap`. +//! +//! Removes `.bitTmp` when it exists as a live filesystem entry under the workspace root. +use std::{fs, io, path::Path}; + +/// Removes `.bitTmp` under `root` when present as a live filesystem entry. +#[inline] +pub(super) fn temporary(root: &Path) -> io::Result<()> { + // Published pathExists follows symlinks and treats inaccessible/missing paths + // as absent. In particular, a dangling temporary symlink is left untouched. + if has_temporary_path(root) { + super::super::status::cleanup_temporary(root)?; + } + Ok(()) +} + +/// Returns `true` when `root` is a non-empty absolute path whose `.bitTmp` entry exists. +#[inline] +fn has_temporary_path(root: &Path) -> bool { + root.is_absolute() + && root + .to_str() + .map_or(!root.as_os_str().is_empty(), |text| !text.trim().is_empty()) + && fs::metadata(root.join(".bitTmp")).is_ok() +} diff --git a/native/cli/src/commands/add/dependencies.rs b/native/cli/src/commands/add/dependencies.rs new file mode 100644 index 000000000000..297042bb014f --- /dev/null +++ b/native/cli/src/commands/add/dependencies.rs @@ -0,0 +1,85 @@ +//! [`ComponentDependencies`] and [`PackageDependency`] extraction from the shared native component-load receipt. +//! +//! Exposes [`packages`] to map captured component dependency rows by [`Lifecycle`]. +use bit_install::model::{ComponentDependencies, Lifecycle, PackageDependency}; +use serde_json::Value; +use std::io; + +/// Extracts [`ComponentDependencies`] keyed by bare component identity from captured load rows. +#[inline] +pub(in crate::commands) fn packages( + captured: &[Value], +) -> io::Result> { + captured + .iter() + .map(component_packages) + .collect() +} +/// Extracts the bare component identity and [`ComponentDependencies`] from a single captured `row`. +#[inline] +fn component_packages(row: &Value) -> io::Result<(String, ComponentDependencies)> { + let identity = row["identity"] + .as_str() + .map(str::trim) + .filter(|id| !id.is_empty()) + .or_else(|| { + row["id"] + .as_str() + .map(str::trim) + .filter(|id| !id.is_empty()) + }) + .map(|id| id.rsplit_once('@').map_or(id, |(base, _)| base.trim())) + .filter(|id| !id.is_empty()) + .ok_or_else(|| io::Error::other("tracking dependency identity"))?; + let mut result = ComponentDependencies::default(); + for (key, lifecycle) in [ + ("packageDependencies", Lifecycle::Runtime), + ("devPackageDependencies", Lifecycle::Dev), + ("peerPackageDependencies", Lifecycle::Peer), + ] { + let Some(packages) = row["current"] + .get(key) + .filter(|val| !val.is_null()) + else { + continue; + }; + let packages = packages + .as_object() + .ok_or_else(|| io::Error::other("tracking package dependencies"))?; + add_lifecycle_packages(packages, lifecycle, &mut result)?; + } + Ok((identity.to_owned(), result)) +} + +fn add_lifecycle_packages( + packages: &serde_json::Map, + lifecycle: Lifecycle, + result: &mut ComponentDependencies, +) -> io::Result<()> { + if packages.len() > 4096 { + return Err(io::Error::other("tracking package dependency count")); + } + result.packages.reserve(packages.len()); + for (name, spec) in packages { + let name = name.trim(); + let version = spec + .as_str() + .or_else(|| spec["version"].as_str()) + .map(str::trim) + .filter(|ver| { + !ver.is_empty() + && !name.is_empty() + && !name.chars().any(char::is_control) + && !ver.chars().any(char::is_control) + }) + .ok_or_else(|| io::Error::other("tracking package version"))?; + let optional = spec["optional"].as_bool().unwrap_or(false); + result.packages.push(PackageDependency { + name: name.to_owned(), + version: version.to_owned(), + lifecycle, + optional, + }); + } + Ok(()) +} diff --git a/native/cli/src/commands/add/diagnostics.rs b/native/cli/src/commands/add/diagnostics.rs new file mode 100644 index 000000000000..a1b91a846e71 --- /dev/null +++ b/native/cli/src/commands/add/diagnostics.rs @@ -0,0 +1,93 @@ +//! Released CI diagnostics ([`ci`]) include Node stacks, so predictable [`Projection`] errors delegate before providers. +//! +//! Checks [`links`] preconditions in CI environments prior to mutating `node_modules`. +use super::projection::Projection; +use std::{env, fs, io, path::Path}; +/// Returns `true` when the `CI` environment variable is set and non-empty. +#[inline] +pub(super) fn ci() -> bool { + env::var_os("CI").is_some_and(|value| !value.is_empty()) +} + +/// Validates that selected [`Projection`] package paths under `root/node_modules` are not blocked in CI. +pub(super) fn links(root: &Path, projection: &Projection) -> io::Result<()> { + if !ci() { + return Ok(()); + } + let node_modules = root.join("node_modules"); + for component in &projection.workspace.components { + let package_name = component.package_name.trim(); + if component.root_dir.trim() == "." + || package_name.is_empty() + || !projection.selected + .iter() + .any(|target| matches_target(target, &component.id.to_string_without_version())) + { + continue; + } + let package = node_modules.join(package_name); + if blocked_package(&package)? + || (!component.has_package_json && blocked_manifest(&package.join("package.json"))?) + { + return Err(io::Error::new( + io::ErrorKind::Unsupported, + "CI link diagnostics require the published stack", + )); + } + } + Ok(()) +} + +#[inline] +fn matches_target(target: &serde_json::Value, identity: &str) -> bool { + let expected = identity.trim(); + !expected.is_empty() + && target["identity"] + .as_str() + .map(str::trim) + .filter(|id| !id.is_empty()) + .or_else(|| { + target["id"] + .as_str() + .map(str::trim) + .filter(|id| !id.is_empty()) + }) + .is_some_and(|raw| { + raw.rsplit_once('@').map_or(raw, |(base, _)| base.trim()) == expected + }) +} +fn blocked_package(package: &Path) -> io::Result { + if metadata(package)?.is_some_and(|metadata| metadata.is_file()) { + return Ok(true); + } + for ancestor in package.ancestors().skip(1).take(32) { + match fs::metadata(ancestor) { + Ok(metadata) if metadata.is_file() => return Ok(true), + Ok(_) => {} + Err(error) + if matches!( + error.kind(), + io::ErrorKind::NotFound | io::ErrorKind::NotADirectory, + ) => {} + Err(error) => return Err(error), + } + } + Ok(false) +} +#[inline] +fn blocked_manifest(path: &Path) -> io::Result { + Ok(metadata(path)? + .is_some_and(|metadata| metadata.is_dir() || metadata.file_type().is_symlink())) +} +#[inline] +fn metadata(path: &Path) -> io::Result> { + match fs::symlink_metadata(path) { + Ok(metadata) => Ok(Some(metadata)), + Err(error) + if matches!(error.kind(), io::ErrorKind::NotFound | io::ErrorKind::NotADirectory) => + { + Ok(None) + } + Err(error) => Err(error), + } +} diff --git a/native/cli/src/commands/add/docs.rs b/native/cli/src/commands/add/docs.rs new file mode 100644 index 000000000000..c271c55d8f0f --- /dev/null +++ b/native/cli/src/commands/add/docs.rs @@ -0,0 +1,162 @@ +//! Native cache publication around the published semantic documentation parser ([`CatDocs`], [`cat_prepare`]). +//! +//! Exposes [`Prepared`] documentation staging and [`load`] execution for tracked components. +pub(in crate::commands) use cat::{CatDocs, cat_prepare}; + +mod cache_hits; +mod cat; +#[cfg(test)] +mod cat_cache_tests; +mod sources; + +use crate::commands::status::{cache, environments::Environments}; +use serde_json::{Value, json}; +use sources::{Source, observe}; +use std::{ + collections::BTreeMap, + io, + path::{Path, PathBuf}, +}; + +/// Prepares and publishes `ComponentFsCache` documentation entries for `components`. +#[inline] +pub(super) fn load( + root: &Path, + environment: &mut Environments, + components: &[Value], +) -> io::Result<()> { + prepare(root, environment, components, false)?.publish() +} + +/// Staged `ComponentFsCache` documentation entries and observed [`Source`] preimages ready for publication. +pub(in crate::commands) struct Prepared { + root: PathBuf, + components: Vec, + observed: BTreeMap, + directory: PathBuf, + entries: Vec<(String, String)>, + write_cache: bool, + deferred: bool, +} + +pub(super) fn prepare( + root: &Path, + environment: &mut Environments, + components: &[Value], + deferred: bool, +) -> io::Result { + let write_cache = !cache::no_fs_cache(root); + let observed = observe(root, components)?; + let directory = bit_object_import::scope_path(root).join("cache/components/docs"); + let plan = cache_hits::prepare(root, &directory, components, &observed, write_cache); + if !plan.hits.is_empty() { + environment.identity_call(root, "trackingCachedDocs", &json!({"entries":plan.hits}))?; + } + let writes = if plan.misses.is_empty() { + json!([]) + } else { + environment.tracking_docs(root, &plan.misses, deferred)? + }; + if observed != observe(root, components)? { + return Err(io::Error::other("tracking documentation sources changed")); + } + let parsed = writes.as_array().ok_or_else(|| io::Error::other("tracking docs receipt"))?; + let mut writes = plan.proven; + writes.extend(parsed.iter().cloned()); + order(root, components, &mut writes)?; + let entries = validate(&writes, &observed)?; + Ok(Prepared { + root: root.to_owned(), + components: components.to_vec(), + observed, + directory, + entries, + write_cache, + deferred, + }) +} + +impl Prepared { + #[inline] + pub(in crate::commands) fn source_proofs(&self) -> Vec { + self.observed + .iter() + .map(|(path, source)| { + bit_install::policy::scope::proof_from_bytes(path, Some(source.bytes())) + }) + .collect() + } + + #[inline] + pub(in crate::commands) fn publish(self) -> io::Result<()> { + // Deferred source proofs are consumed before onLoad. Its authorized edits must + // not replace the documentation the consumer already parsed from its old files. + if !self.deferred && self.observed != observe(&self.root, &self.components)? { + return Err(io::Error::other("prepared documentation sources changed")); + } + if !self.write_cache { + return Ok(()); + } + for (path, bytes) in self.entries { + bit_install::dependencies::cache::put(&self.directory, &path, &bytes)?; + } + Ok(()) + } +} +fn validate( + writes: &[Value], + observed: &BTreeMap, +) -> io::Result> { + if writes.len() > 16384 { + return Err(io::Error::other("tracking docs file count budget")); + } + let mut total = 0_usize; + let mut seen = std::collections::HashSet::with_capacity(writes.len()); + let mut entries = Vec::with_capacity(writes.len()); + for row in writes { + let path = row["path"] + .as_str() + .map(str::trim) + .filter(|path| !path.is_empty()) + .ok_or_else(|| io::Error::other("tracking docs path"))?; + let candidate = Path::new(path); + if !observed.contains_key(candidate) + || !seen.insert(candidate) + || row.get("docs").is_none_or(Value::is_null) + { + return Err(io::Error::other("tracking docs unknown source")); + } + let bytes = bit_object_import::stringify_json(&row["docs"], false)?; + total = total + .checked_add(bytes.len()) + .filter(|sum| *sum <= 32 * 1024 * 1024) + .ok_or_else(|| io::Error::other("tracking docs metadata budget"))?; + entries.push((path.to_owned(), bytes)); + } + Ok(entries) +} + +fn order(root: &Path, components: &[Value], writes: &mut [Value]) -> io::Result<()> { + let mut ranks = BTreeMap::new(); + for component in components { + for file in component["files"] + .as_array() + .into_iter() + .flatten() + .filter(|file| file["test"] != true) + { + let path = cache_hits::source_path(root, component, file)?; + let rank = ranks.len(); + ranks.entry(path).or_insert(rank); + } + } + writes.sort_by_key(|row| { + row["path"] + .as_str() + .map(str::trim) + .and_then(|path| ranks.get(Path::new(path))) + .copied() + .unwrap_or(usize::MAX) + }); + Ok(()) +} diff --git a/native/cli/src/commands/add/docs/cache_hits.rs b/native/cli/src/commands/add/docs/cache_hits.rs new file mode 100644 index 000000000000..68b06ef4abd9 --- /dev/null +++ b/native/cli/src/commands/add/docs/cache_hits.rs @@ -0,0 +1,187 @@ +//! `ComponentFsCache` cache keys and doc-parser's strict, fractional-millisecond [`Source`] staleness test. +//! +//! Partitions components into [`Plan`] hits, misses, and proven cache entries via [`prepare`]. +#[cfg(test)] +mod tests; + +use super::sources::Source; +use serde_json::{Value, json}; +use std::{ + collections::BTreeMap, + io, + path::{Path, PathBuf}, +}; +const MAX_METADATA: usize = 32 * 1024 * 1024; + +/// Partitioned `ComponentFsCache` documentation hits, misses, and proven cache entries. +pub(super) struct Plan { + pub hits: Vec, + pub misses: Vec, + pub proven: Vec, +} + +/// Builds a documentation cache [`Plan`] for `components`, falling back to full misses on cache corruption. +#[inline] +pub(super) fn prepare( + root: &Path, + cache: &Path, + components: &[Value], + observed: &BTreeMap, + enabled: bool, +) -> Plan { + // Integrity errors canonically clear the entire cache. Let the existing Host + // reader preserve that cleanup and sequential failure prefix for the whole batch. + partition(root, cache, components, observed, enabled) + .unwrap_or_else(|_| Plan { + hits: Vec::new(), + misses: components.to_vec(), + proven: Vec::new(), + }) +} +fn partition( + root: &Path, + cache: &Path, + components: &[Value], + observed: &BTreeMap, + enabled: bool, +) -> io::Result { + let mut plan = Plan { hits: Vec::new(), misses: Vec::new(), proven: Vec::new() }; + let mut bytes = 0; + for component in components { + let files = component["files"].as_array().ok_or_else(|| invalid("docs files"))?; + if files.len() > 16384 { + return Err(invalid("docs files")); + } + let mut misses = Vec::new(); + for file in files + .iter() + .filter(|file| file["test"] != true) + { + let path = source_path(root, component, file)?; + let source = observed.get(&path).ok_or_else(|| invalid("docs unknown source"))?; + if !classify(cache, &path, source, file, enabled, &mut plan, &mut bytes)? { + misses.push(file.clone()); + } + } + if !misses.is_empty() { + let mut pending = component.clone(); + pending["files"] = json!(misses); + plan.misses.push(pending); + } + } + Ok(plan) +} +#[inline] +pub(super) fn inspect( + root: &Path, + cache: &Path, + components: &[Value], + observed: &BTreeMap, +) -> io::Result { + partition(root, cache, components, observed, true) +} +fn classify( + cache: &Path, + path: &Path, + source: &Source, + file: &Value, + enabled: bool, + plan: &mut Plan, + bytes: &mut usize, +) -> io::Result { + if let Some(docs) = hit(cache, path, source.mtime_ms(), enabled)? { + *bytes = bytes.saturating_add(serde_json::to_vec(&docs)?.len()); + if *bytes > MAX_METADATA { + return Err(invalid("docs cache metadata budget")); + } + plan.hits.push(json!({"path":path,"docs":docs})); + return Ok(true); + } + let relative = file_relative_path(file).unwrap_or_default(); + if !bit_dependency_scanner::proves_empty_docs(source.bytes(), relative) { + return Ok(false); + } + *bytes = bytes.saturating_add(2); + if *bytes > MAX_METADATA { + return Err(invalid("docs cache metadata budget")); + } + let row = json!({"path":path,"docs":[]}); + plan.hits.push(row.clone()); + plan.proven.push(row); + Ok(true) +} + +fn hit(cache: &Path, path: &Path, mtime: f64, enabled: bool) -> io::Result> { + if !enabled { + return Ok(None); + } + let key = path + .to_str() + .map(str::trim) + .filter(|key| !key.is_empty()) + .ok_or_else(|| invalid("docs cache source key"))?; + let Some(entry) = bit_object_import::read_dependency_cache(cache, key)? else { + return Ok(None); + }; + let timestamp = &entry["timestamp"]; + if !bit_workspace_config::truthy(timestamp) { + return Ok(None); + } + let timestamp = timestamp + .as_f64() + .filter(|time| time.is_finite()) + .ok_or_else(|| invalid("docs cache timestamp requires published coercion"))?; + if mtime > timestamp { + return Ok(None); + } + if let Some(data) = entry["data"].as_str() { + return Ok(Some(bit_workspace_config::parse_jsonc(data.as_bytes())?)); + } + if entry["data"].is_object() || entry["data"].is_array() { + return Ok(Some(entry["data"].clone())); + } + Err(invalid("docs cache data")) +} +pub(super) fn source_path(root: &Path, component: &Value, file: &Value) -> io::Result { + let directory = non_empty_str(&component["directory"]) + .or_else(|| non_empty_str(&component["rootDir"])) + .ok_or_else(|| invalid("docs directory"))?; + let relative = file_relative_path(file).ok_or_else(|| invalid("docs relative path"))?; + if Path::new(directory).is_absolute() + || Path::new(relative).is_absolute() + || Path::new(directory) + .components() + .any(|part| matches!(part, std::path::Component::ParentDir)) + || Path::new(relative) + .components() + .any(|part| matches!(part, std::path::Component::ParentDir)) + { + return Err(invalid("docs relative path")); + } + // Node path.join normalizes both separator spellings on Windows and drops + // a workspace-root "." segment. Cache keys use that exact absolute spelling. + Ok(root + .join(directory) + .join(relative) + .components() + .collect()) +} + +#[inline] +fn file_relative_path(file: &Value) -> Option<&str> { + non_empty_str(file) + .or_else(|| non_empty_str(&file["relativePath"])) + .or_else(|| non_empty_str(&file["path"])) +} + +#[inline] +fn non_empty_str(value: &Value) -> Option<&str> { + value + .as_str() + .map(str::trim) + .filter(|text| !text.is_empty()) +} +#[inline] +fn invalid(message: &str) -> io::Error { + io::Error::other(message) +} diff --git a/native/cli/src/commands/add/docs/cache_hits/tests.rs b/native/cli/src/commands/add/docs/cache_hits/tests.rs new file mode 100644 index 000000000000..43cf1c453312 --- /dev/null +++ b/native/cli/src/commands/add/docs/cache_hits/tests.rs @@ -0,0 +1,120 @@ +//! Unit tests for component documentation cache [`hit`], [`prepare`], timestamps, and fallback. +//! +//! Verifies [`observe`]d source timestamps, cached metadata lookup, and stale-entry fallback. + +use super::{hit, prepare}; +use crate::commands::add::docs::sources::observe; +use serde_json::json; +use std::{fs, path::Path}; + +/// Seeds a `cacache` entry at `cache` for `path` with `docs` and returns the recorded timestamp. +fn seed(cache: &Path, path: &Path, docs: &str) -> f64 { + bit_install::dependencies::cache::put(cache, path.to_str().unwrap(), docs).unwrap(); + bit_object_import::read_dependency_cache(cache, path.to_str().unwrap()) + .unwrap() + .unwrap()["timestamp"] + .as_f64() + .unwrap() +} + +/// Verifies that [`hit`] accepts exact or older source timestamps and rejects fractionally newer timestamps. +#[test] +fn hit_accepts_arbitrary_metadata_and_exact_timestamp_but_not_fractionally_newer_source() { + let directory = tempfile::tempdir().unwrap(); + let cache = directory.path().join("docs"); + let path = directory.path().join("file.js"); + let timestamp = seed(&cache, &path, r#"{"react":{"description":"cached"}}"#); + let expected = Some(json!({"react":{"description":"cached"}})); + assert_eq!(hit(&cache, &path, timestamp, true).unwrap(), expected); + assert_eq!(hit(&cache, &path, timestamp - 0.25, true).unwrap(), expected); + assert!(hit(&cache, &path, timestamp + 0.25, true).unwrap().is_none()); +} + +#[test] +fn disabled_cache_does_not_even_open_the_cache_path() { + let directory = tempfile::tempdir().unwrap(); + let cache = directory.path().join("docs"); + fs::write(&cache, "not a cache directory").unwrap(); + assert!(hit(&cache, &directory.path().join("file.js"), 0.0, false).unwrap().is_none()); +} + +#[test] +fn malformed_cached_json_defers_the_entire_batch_to_the_published_reader() { + let directory = tempfile::tempdir().unwrap(); + let root = directory.path(); + let cache = root.join("docs"); + fs::write(root.join("first.js"), "module.exports=1;").unwrap(); + fs::write(root.join("second.js"), "module.exports=2;").unwrap(); + for name in ["first.js", "second.js"] { + fs::File::options() + .write(true) + .open(root.join(name)) + .unwrap() + .set_times( + fs::FileTimes::new() + .set_modified(std::time::UNIX_EPOCH + std::time::Duration::from_secs(1)), + ) + .unwrap(); + } + seed(&cache, &root.join("first.js"), "[]"); + seed(&cache, &root.join("second.js"), "invalid-json"); + let components = vec![ + json!({"directory":".","files":[{"relativePath":"first.js"},{"relativePath":"second.js"}]}), + ]; + let observed = observe(root, &components).unwrap(); + let plan = prepare(root, &cache, &components, &observed, true); + assert!(plan.hits.is_empty()); + assert!(plan.proven.is_empty()); + assert_eq!(plan.misses, components); +} + +#[test] +fn proven_empty_miss_produces_metadata_and_a_new_cache_write() { + let directory = tempfile::tempdir().unwrap(); + let root = directory.path(); + fs::write(root.join("file.js"), "module.exports=42;\n").unwrap(); + let components = vec![json!({"directory":".","files":[{"relativePath":"file.js"}]})]; + let observed = observe(root, &components).unwrap(); + let plan = prepare(root, &root.join("docs"), &components, &observed, true); + assert!(plan.misses.is_empty()); + assert_eq!(plan.hits, plan.proven); + assert_eq!(plan.proven, vec![json!({"path":root.join("file.js"),"docs":[]})]); +} + +#[test] +fn no_cache_uncertain_source_still_uses_the_published_parser() { + let directory = tempfile::tempdir().unwrap(); + let root = directory.path(); + fs::write(root.join("file.js"), "module.exports=()=>null;\n").unwrap(); + let components = vec![json!({"directory":".","files":[{"relativePath":"file.js"}]})]; + let observed = observe(root, &components).unwrap(); + let plan = prepare(root, &root.join("docs"), &components, &observed, false); + assert!(plan.hits.is_empty()); + assert!(plan.proven.is_empty()); + assert_eq!(plan.misses, components); +} + +#[test] +fn disabled_cache_proven_source_never_reads_or_creates_cache_files() { + let directory = tempfile::tempdir().unwrap(); + let root = directory.path(); + fs::write(root.join("file.js"), "module.exports=42;").unwrap(); + let cache = root.join("docs"); + let components = vec![json!({"directory":".","files":[{"relativePath":"file.js"}]})]; + let observed = observe(root, &components).unwrap(); + let plan = prepare(root, &cache, &components, &observed, false); + assert!(plan.misses.is_empty()); + assert_eq!(plan.proven.len(), 1); + assert!(!cache.exists()); +} + +#[test] +fn empty_component_list_produces_empty_docs_cache_plan() { + let directory = tempfile::tempdir().unwrap(); + let root = directory.path(); + let observed = observe(root, &[]).unwrap(); + let plan = prepare(root, &root.join("docs"), &[], &observed, true); + assert!(plan.hits.is_empty()); + assert!(plan.proven.is_empty()); + assert!(plan.misses.is_empty()); +} diff --git a/native/cli/src/commands/add/docs/cat.rs b/native/cli/src/commands/add/docs/cat.rs new file mode 100644 index 000000000000..f6b785e47aea --- /dev/null +++ b/native/cli/src/commands/add/docs/cat.rs @@ -0,0 +1,118 @@ +//! Cold `cat` documentation ([`CatDocs`], [`cat_prepare`]) uses the published parser without starting Harmony. +//! +//! Validates [`cache_hits`] and [`observe`]d sources before serving `cat-component` docs. +use super::{cache_hits, observe, order, validate}; +use crate::commands::status::cache; +use serde_json::Value; +use std::{ + collections::BTreeMap, + io, + path::{Path, PathBuf}, +}; + +/// Staged cold `cat-component` documentation state and observed source preimages. +pub(in crate::commands) struct CatDocs { + root: PathBuf, + components: Vec, + observed: BTreeMap, + directory: PathBuf, + plan: cache_hits::Plan, + host_published: bool, +} + +/// Cache corruption and ambiguous feature precedence must reach the published +/// consumer, whose `FsCache` owns its cleanup and option semantics. +pub(in crate::commands) fn cat_prepare(root: &Path, component: Value) -> Option { + if !root.is_absolute() + || root.as_os_str().is_empty() + || !component.is_object() + || cache::no_fs_cache(root) + { + return None; + } + let components = vec![component]; + let observed = observe(root, &components).ok()?; + let directory = bit_object_import::scope_path(root).join("cache/components/docs"); + let plan = cache_hits::inspect(root, &directory, &components, &observed).ok()?; + validate(&plan.proven, &observed).ok()?; + Some(CatDocs { + root: root.to_owned(), + components, + observed, + directory, + plan, + host_published: false, + }) +} + +impl CatDocs { + /// Returns the component entries whose documentation cache entries missed during [`cat_prepare`]. + #[inline] + pub(in crate::commands) fn misses(&self) -> &[Value] { + &self.plan.misses + } + + #[inline] + pub(in crate::commands) fn components(&self) -> &[Value] { + &self.components + } + + #[inline] + pub(in crate::commands) fn absorb(&mut self, receipt: &Value) -> io::Result> { + let error = parse_doc_receipt(receipt)?; + self.host_published = true; + Ok(error) + } + + pub(in crate::commands) fn publish(self) -> io::Result<()> { + if self.observed != observe(&self.root, &self.components)? { + return Err(io::Error::other("cat documentation sources changed")); + } + if self.host_published { + // The published FsCache already owns every parser/cache effect. Replaying + // writes here would change metadata timestamps and failure behavior. + return Ok(()); + } + let current = + cache_hits::inspect(&self.root, &self.directory, &self.components, &self.observed)?; + if current.hits != self.plan.hits + || current.misses != self.plan.misses + || current.proven != self.plan.proven + { + return Err(io::Error::other("cat documentation cache changed")); + } + let mut writes = self.plan.proven; + if writes.is_empty() { + return Ok(()); + } + order(&self.root, &self.components, &mut writes)?; + for (path, bytes) in validate(&writes, &self.observed)? { + // Published FsCache.saveDataInCache treats IO failures as nonfatal. + let _ = bit_install::dependencies::cache::put(&self.directory, &path, &bytes); + } + Ok(()) + } +} + +#[inline] +fn parse_doc_receipt(receipt: &Value) -> io::Result> { + if receipt["published"] != true + && !receipt["published"] + .as_str() + .is_some_and(|text| text.trim().eq_ignore_ascii_case("true")) + { + return Err(io::Error::other("cat documentation parser receipt")); + } + let error = &receipt["error"]; + if error.is_null() || error.as_bool() == Some(false) { + return Ok(None); + } + if let Some(message) = error + .as_str() + .or_else(|| error.get("message").and_then(Value::as_str)) + { + let trimmed = message.trim(); + return Ok((!trimmed.is_empty()).then(|| trimmed.to_owned())); + } + Err(io::Error::other("cat documentation parser receipt")) +} diff --git a/native/cli/src/commands/add/docs/cat_cache_tests.rs b/native/cli/src/commands/add/docs/cat_cache_tests.rs new file mode 100644 index 000000000000..87001291f70b --- /dev/null +++ b/native/cli/src/commands/add/docs/cat_cache_tests.rs @@ -0,0 +1,106 @@ +//! Unit tests for `cat-component` documentation cache [`cat_prepare`] preparation and reuse. +//! +//! Verifies that empty `cat-component` documentation entries are persisted once and reused on subsequent lookups. + +use super::cat_prepare; +use serde_json::json; +use std::{fs, path::Path}; + +/// Verifies that an empty `cat-component` source entry is persisted once and reused without rewriting. +#[test] +fn proven_empty_cat_source_is_written_once_then_reused_without_rewrite() { + let root = tempfile::tempdir().unwrap(); + fs::write(root.path().join("file.js"), "module.exports=42;\n").unwrap(); + fs::File::options() + .write(true) + .open(root.path().join("file.js")) + .unwrap() + .set_times( + fs::FileTimes::new() + .set_modified(std::time::UNIX_EPOCH + std::time::Duration::from_secs(1)), + ) + .unwrap(); + let component = json!({"directory":".","files":[{"relativePath":"file.js","test":false}]}); + cat_prepare(root.path(), component.clone()) + .unwrap() + .publish() + .unwrap(); + let cache = root.path().join(".bit/cache/components/docs"); + let key = root.path().join("file.js"); + let entry = + bit_object_import::read_dependency_cache(&cache, key.to_str().unwrap()).unwrap().unwrap(); + assert_eq!(entry["data"], "[]"); + let before = cache_bytes(&cache); + cat_prepare(root.path(), component) + .unwrap() + .publish() + .unwrap(); + assert_eq!(cache_bytes(&cache), before); +} + +/// Computes the total byte size of all regular files recursively under `directory`. +fn cache_bytes(directory: &Path) -> usize { + fs::read_dir(directory) + .unwrap() + .map(Result::unwrap) + .map(|entry| { + if entry.file_type().unwrap().is_dir() { + cache_bytes(&entry.path()) + } else { + fs::metadata(entry.path()).unwrap().len() as usize + } + }) + .sum() +} + +#[test] +fn rich_uncached_cat_source_requires_published_parser() { + let root = tempfile::tempdir().unwrap(); + fs::write(root.path().join("file.js"), "/** docs */\nmodule.exports=42;\n").unwrap(); + let component = json!({"directory":".","files":[{"relativePath":"file.js","test":false}]}); + let plan = cat_prepare(root.path(), component).unwrap(); + assert_eq!(plan.misses().len(), 1); + assert!( + !root + .path() + .join(".bit/cache/components/docs") + .exists(), + ); +} + +#[test] +fn published_parser_receipt_keeps_cache_writes_host_owned() { + let root = tempfile::tempdir().unwrap(); + fs::write(root.path().join("file.js"), "/** @public */\nmodule.exports=2;\n").unwrap(); + let component = json!({"directory":".","files":[{"relativePath":"file.js","test":false}]}); + let mut plan = cat_prepare(root.path(), component).unwrap(); + assert_eq!(plan.misses().len(), 1); + assert_eq!(plan.components().len(), 1); + assert_eq!( + plan.absorb(&json!({"published":true,"error":null})) + .unwrap(), + None, + ); + plan.publish().unwrap(); + let cache = root.path().join(".bit/cache/components/docs"); + assert!( + bit_object_import::read_dependency_cache( + &cache, + root.path() + .join("file.js") + .to_str() + .unwrap() + ) + .unwrap() + .is_none(), + ); +} + +#[test] +fn test_files_are_excluded_from_cat_docs_cache_misses() { + let root = tempfile::tempdir().unwrap(); + fs::write(root.path().join("file.spec.js"), "/** @public */\nmodule.exports=2;\n").unwrap(); + let component = json!({"directory":".","files":[{"relativePath":"file.spec.js","test":true}]}); + let plan = cat_prepare(root.path(), component).unwrap(); + assert!(plan.misses().is_empty()); +} diff --git a/native/cli/src/commands/add/docs/sources.rs b/native/cli/src/commands/add/docs/sources.rs new file mode 100644 index 000000000000..da848cff563c --- /dev/null +++ b/native/cli/src/commands/add/docs/sources.rs @@ -0,0 +1,109 @@ +//! Retain bounded [`Source`] bytes and exact modification times around documentation decisions. +//! +//! Enforces [`LIMIT`] across observed source files for `ComponentFsCache` staleness checks. +use serde_json::Value; +use std::{ + collections::BTreeMap, + fs, + io::{self, Read}, + path::{Path, PathBuf}, + time::{SystemTime, UNIX_EPOCH}, +}; +const LIMIT: u64 = 96 * 1024 * 1024; + +/// Observed source file bytes and filesystem modification timestamp. +#[derive(PartialEq)] +pub(super) struct Source { + bytes: Vec, + modified: SystemTime, +} +impl Source { + #[inline] + pub(super) fn bytes(&self) -> &[u8] { + &self.bytes + } + #[inline] + pub(super) fn mtime_ms(&self) -> f64 { + match self.modified.duration_since(UNIX_EPOCH) { + Ok(duration) => milliseconds(duration), + Err(error) => -milliseconds(error.duration()), + } + } +} +#[inline] +fn milliseconds(duration: std::time::Duration) -> f64 { + (duration.as_secs() as f64).mul_add(1000.0, f64::from(duration.subsec_nanos()) / 1_000_000.0) +} + +/// Observes non-test component source files under `root` within the shared [`LIMIT`] byte budget. +pub(super) fn observe(root: &Path, components: &[Value]) -> io::Result> { + let mut output = BTreeMap::new(); + let mut remaining = LIMIT; + for component in components { + let directory = component["directory"] + .as_str() + .or_else(|| component["rootDir"].as_str()) + .map(str::trim) + .filter(|dir| !dir.is_empty()) + .ok_or_else(|| io::Error::other("tracking docs directory"))?; + for file in + component["files"].as_array().ok_or_else(|| io::Error::other("tracking docs files"))? + { + if file["test"] == true { + continue; + } + let relative = relative_path(directory, file)?; + let path: PathBuf = root + .join(directory) + .join(relative) + .components() + .collect(); + let source = read_source(&path, &mut remaining)?; + output.insert(path, source); + if output.len() > 16384 { + return Err(io::Error::other("tracking docs file count budget")); + } + } + } + Ok(output) +} + +#[inline] +fn relative_path<'a>(directory: &str, file: &'a Value) -> io::Result<&'a str> { + let relative = file["relativePath"] + .as_str() + .or_else(|| file["path"].as_str()) + .or_else(|| file.as_str()) + .map(str::trim) + .filter(|rel| !rel.is_empty()) + .ok_or_else(|| io::Error::other("tracking docs relative path"))?; + if Path::new(directory).is_absolute() + || Path::new(relative).is_absolute() + || Path::new(directory) + .components() + .any(|part| matches!(part, std::path::Component::ParentDir)) + || Path::new(relative) + .components() + .any(|part| matches!(part, std::path::Component::ParentDir)) + { + return Err(io::Error::other("tracking docs relative path")); + } + Ok(relative) +} + +fn read_source(path: &Path, remaining: &mut u64) -> io::Result { + if !fs::symlink_metadata(path)?.is_file() { + return Err(io::Error::other("tracking docs nonordinary source")); + } + let file = fs::File::open(path)?; + let metadata = file.metadata()?; + let modified = metadata.modified()?; + let capacity = usize::try_from(metadata.len().min(*remaining)).unwrap_or(0); + let mut bytes = Vec::with_capacity(capacity); + file.take(*remaining + 1) + .read_to_end(&mut bytes)?; + *remaining = remaining + .checked_sub(bytes.len() as u64) + .ok_or_else(|| io::Error::other("tracking docs source budget"))?; + Ok(Source { bytes, modified }) +} diff --git a/native/cli/src/commands/add/execution.rs b/native/cli/src/commands/add/execution.rs new file mode 100644 index 000000000000..8a0af76a42a8 --- /dev/null +++ b/native/cli/src/commands/add/execution.rs @@ -0,0 +1,195 @@ +//! Component-load caches, native [`linking`], then one `.bitmap` [`Projection`] publication. +//! +//! Executes [`run`] to link [`WorkspaceComponent`]s and commit the [`Plan`] atomically. +use super::{ + Prepared, cache, cleanup, dependencies, linking, output, projection::Projection, remote, report, +}; +use crate::{ + commands::{ + color::Chalk, + install::JsHost, + status::capture, + workspace_command::{self, results::Report}, + }, + host::Host, +}; +use bit_install::model::WorkspaceComponent; +use bit_object_import::component_tracking::Plan; +use std::{io, path::Path, sync::Arc}; + +/// Publishes component caches, links [`WorkspaceComponent`]s, and commits the `.bitmap` [`Plan`]. +pub(super) fn run( + root: &Path, + mut prepared: Prepared, + json: bool, + chalk: Chalk, + output: &bit_install::output::Output, +) -> io::Result { + if let Some(error) = prepared.projection.load_error.as_deref().map(str::trim) + && !error.is_empty() + { + return Err(io::Error::other(error.to_owned())); + } + prepared.plan.validate()?; + let Some(mut capture) = prepared.capture.take() else { + cleanup::temporary(root)?; + return report(&prepared.plan, json, chalk); + }; + let host = publish(root, &mut prepared, &mut capture, json, chalk)?; + cleanup::temporary(root)?; + prepared.plan.commit(&workspace_command::file_id())?; + if !json { + remote::warn(&host, prepared.plan.components(), &prepared.projection, chalk, output); + } + report(&prepared.plan, json, chalk) +} + +/// Commits captured component caches, links [`WorkspaceComponent`]s, and returns the tracking [`Host`]. +fn publish( + root: &Path, + prepared: &mut Prepared, + capture: &mut capture::Capture, + json: bool, + chalk: Chalk, +) -> io::Result> { + let environment = capture::take_callback_environment(capture) + .ok_or_else(|| io::Error::other("tracking callback environment"))?; + let host = Arc::new( + environment + .into_tracking_host() + .ok_or_else(|| io::Error::other("tracking package host"))?, + ); + let mut components = components(&prepared.plan, &prepared.projection); + linking::overlay(&mut components, &capture.captured)?; + cache::restrict(root, capture); + validate(prepared, capture)?; + capture::commit(&mut prepared.engine, root, &prepared.projection.context, capture)?; + let manifests = cache::Manifests::observe(root, &components); + let linked = link(root, &prepared.projection, capture, &components, &host, json, chalk); + print!("{}", host.take_stdout()); + linked?; + if manifests.changed() { + cache::invalidate(root, &components)?; + } + Ok(host) +} + +fn components(plan: &Plan, projection: &Projection) -> Vec { + plan.components() + .iter() + .filter(|row| { + (non_empty_str(&row["rootDir"]).is_some() || non_empty_str(&row["directory"]).is_some()) + && row["files"] + .as_array() + .is_some_and(|files| !files.is_empty()) + }) + .filter_map(|row| { + let identity = non_empty_str(&row["identity"]).or_else(|| non_empty_str(&row["id"]))?; + let bare = identity.rsplit_once('@').map_or(identity, |(base, _)| base.trim()); + projection.workspace.components + .iter() + .find(|component| component.id.to_string_without_version().trim() == bare) + .cloned() + }) + .collect() +} + +#[inline] +fn non_empty_str(value: &serde_json::Value) -> Option<&str> { + value + .as_str() + .map(str::trim) + .filter(|text| !text.is_empty()) +} +#[inline] +fn validate(prepared: &mut Prepared, capture: &capture::Capture) -> io::Result<()> { + prepared.engine.validate_read_set( + &capture.dependency_read_set(), + "tracking component facts changed", + ) +} + +#[inline] +pub(super) fn link( + root: &Path, + projection: &Projection, + capture: &capture::Capture, + components: &[WorkspaceComponent], + host: &Arc, + json: bool, + chalk: Chalk, +) -> io::Result<()> { + link_rows(root, projection, &capture.captured, components, host, json, chalk) +} +pub(super) fn link_rows( + root: &Path, + projection: &Projection, + captured: &[serde_json::Value], + components: &[WorkspaceComponent], + host: &Arc, + json: bool, + chalk: Chalk, +) -> io::Result<()> { + let dependencies = dependencies::packages(captured)?.into_iter().collect(); + let adapter = JsHost::new(Arc::clone(host), json); + let config = bit_install::config::WorkspaceConfig::from_value( + root.join("workspace.jsonc"), + projection.context["workspace"].clone(), + )?; + let roots = config.root_components_path(root); + let aspects: Vec = serde_json::from_value(host.call( + "trackingAspectComponents", + &serde_json::json!({"components":captured}), + )?)?; + let aspect_env_ids = aspect_environments(components, &aspects); + let options = bit_install::links::ComponentLinkOptions { + has_root_components: config.dependency_resolver.has_root_components(), + root_components_dir: &roots, + dependencies: &dependencies, + cloud_domain: "bit.cloud", + windows: cfg!(windows), + aspect_env_ids: &aspect_env_ids, + }; + let runtime = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .worker_threads(2) + .build()?; + runtime + .block_on(bit_install::links::link_tracking_components( + root, components, &adapter, &options, + )) + .map_err(|error| output::link_error(linking::error(root, components, error), chalk))?; + Ok(()) +} +fn aspect_environments(components: &[WorkspaceComponent], aspects: &[String]) -> Vec { + let aspect_ids: std::collections::HashSet<&str> = aspects + .iter() + .map(|aspect| { + let trimmed = aspect.trim(); + trimmed.rsplit_once('@').map_or(trimmed, |(base, _)| base.trim()) + }) + .collect(); + let mut seen = std::collections::HashSet::new(); + components + .iter() + .filter(|component| { + !component.env_id.trim().is_empty() && { + let id = component.id.to_string_without_version(); + let id = id.trim(); + !id.is_empty() && aspect_ids.contains(id) + } + }) + .map(|component| component.env_id.trim()) + .filter(|env_id| seen.insert(*env_id)) + .map(str::to_owned) + .collect() +} +#[inline] +fn report(plan: &Plan, json: bool, chalk: Chalk) -> io::Result { + let data = if json { + serde_json::to_string_pretty(&report::json(plan.components(), plan.warnings()))? + } else { + report::text(plan.components(), plan.warnings(), chalk)? + }; + Ok(Report { data, code: 0, details: None }) +} diff --git a/native/cli/src/commands/add/linking.rs b/native/cli/src/commands/add/linking.rs new file mode 100644 index 000000000000..6b9b4214c4de --- /dev/null +++ b/native/cli/src/commands/add/linking.rs @@ -0,0 +1,113 @@ +//! Reuse native [`WorkspaceComponent`] links with the final component-load metadata and canonical IO errors. +//! +//! Provides [`overlay`] to synchronize captured files and main files onto workspace components. +#[cfg(test)] +mod tests; + +use bit_install::model::WorkspaceComponent; +use serde_json::Value; +use std::{fs, io, path::Path}; + +/// Overlays captured file lists, main files, and package names onto `components`. +#[inline] +pub(in crate::commands) fn overlay( + components: &mut [WorkspaceComponent], + captured: &[Value], +) -> io::Result<()> { + for component in components { + let expected = component.id.to_string_without_version(); + let Some(row) = captured.iter().find(|row| matches_captured(row, &expected)) else { + continue; + }; + apply_captured(component, row)?; + } + Ok(()) +} + +/// Applies captured file lists, `mainFile`, extensions, and `packageName` from `row` to `component`. +fn apply_captured(component: &mut WorkspaceComponent, row: &Value) -> io::Result<()> { + let files: Vec = serde_json::from_value(row["files"].clone())?; + if files.len() > 4096 + || files + .iter() + .any(|file| file.trim().is_empty()) + { + return Err(io::Error::other("tracking loaded files")); + } + component.files = files; + if let Some(version) = row.get("trackingVersion") { + component.id.version = version + .as_str() + .map(str::trim) + .filter(|ver| !ver.is_empty()) + .map(str::to_owned); + } + if let Some(main) = row["current"]["mainFile"] + .as_str() + .map(str::trim) + .filter(|main| !main.is_empty()) + { + main.clone_into(&mut component.main_file); + } + let extensions = row["current"]["extensions"] + .as_array() + .ok_or_else(|| io::Error::other("tracking loaded extensions"))?; + component.config = bit_install::workspace::aspects::from_model(extensions) + .into_iter() + .map(|aspect| (aspect.id, aspect.config)) + .collect(); + component.package_name = bit_install::workspace::component_package_name( + &component.id, + row["current"]["bindingPrefix"].as_str(), + &component.config, + ); + Ok(()) +} + +#[inline] +fn matches_captured(row: &Value, expected: &str) -> bool { + let expected = expected.trim(); + !expected.is_empty() + && row["identity"] + .as_str() + .map(str::trim) + .filter(|id| !id.is_empty()) + .or_else(|| { + row["id"] + .as_str() + .map(str::trim) + .filter(|id| !id.is_empty()) + }) + .map(|id| id.rsplit_once('@').map_or(id, |(bare, _)| bare.trim())) + .filter(|id| !id.is_empty()) + == Some(expected) +} + +pub(super) fn error(root: &Path, components: &[WorkspaceComponent], error: io::Error) -> io::Error { + if !mkdir_not_directory(&error) { + return error; + } + let node_modules = root.join("node_modules"); + let directory = components + .iter() + .find_map(|component| { + let package: std::path::PathBuf = + Path::new(&component.package_name).components().collect(); + let path = node_modules.join(package); + let blocker = path + .ancestors() + .find(|ancestor| { + fs::symlink_metadata(ancestor).is_ok_and(|metadata| metadata.is_file()) + })?; + Some(if cfg!(windows) { blocker.to_path_buf() } else { path }) + }); + directory.map_or(error, |path| { + io::Error::other(format!("ENOTDIR: not a directory, mkdir '{}'", path.display())) + }) +} + +#[inline] +fn mkdir_not_directory(error: &io::Error) -> bool { + error.kind() == io::ErrorKind::NotADirectory + || cfg!(windows) && error.raw_os_error() == Some(183) +} diff --git a/native/cli/src/commands/add/linking/tests.rs b/native/cli/src/commands/add/linking/tests.rs new file mode 100644 index 000000000000..de7e92dca6be --- /dev/null +++ b/native/cli/src/commands/add/linking/tests.rs @@ -0,0 +1,32 @@ +//! Preserve unrelated [`error`] values while translating proven blocked-parent [`mkdir_not_directory`] failures. +//! +//! Also verifies platform-specific package segment separators. +use super::{error, mkdir_not_directory}; +use std::{io, path::Path}; + +/// Verifies that raw OS error `183` (`ERROR_ALREADY_EXISTS`) qualifies for `mkdir_not_directory` only on Windows. +#[test] +fn existing_windows_files_only_qualify_for_mkdir_translation_on_windows() { + assert_eq!(mkdir_not_directory(&io::Error::from_raw_os_error(183)), cfg!(windows)); + assert!(mkdir_not_directory(&io::Error::from(io::ErrorKind::NotADirectory))); + assert!(!mkdir_not_directory(&io::Error::other("callback failure"))); +} + +/// Verifies that [`error`] preserves the original OS error when no blocked parent path exists. +#[test] +fn without_a_proven_blocker_original_error_is_preserved() { + let actual = error(Path::new("."), &[], io::Error::from_raw_os_error(183)); + assert_eq!(actual.raw_os_error(), Some(183)); +} + +#[test] +fn package_segments_use_platform_separators() { + let path: std::path::PathBuf = Path::new("@scope/second").components().collect(); + assert_eq!(path, Path::new("@scope").join("second")); + assert_eq!(path.to_str().unwrap(), &format!("@scope{}second", std::path::MAIN_SEPARATOR)); +} + +#[test] +fn permission_denied_error_does_not_qualify_for_mkdir_not_directory_translation() { + assert!(!mkdir_not_directory(&io::Error::from(io::ErrorKind::PermissionDenied))); +} diff --git a/native/cli/src/commands/add/mutation.rs b/native/cli/src/commands/add/mutation.rs new file mode 100644 index 000000000000..d0f5a175cbc6 --- /dev/null +++ b/native/cli/src/commands/add/mutation.rs @@ -0,0 +1,180 @@ +//! Retain published [`ProjectedLinker`] providers while native identity operations materialize and link files. +//! +//! Coordinates [`cached`], [`controls`], [`fork`], [`inbound`], [`installation`], [`load`], [`removal`], and [`selectors`]. +mod cached; +mod controls; +mod fork; +mod inbound; +mod installation; +mod load; +mod removal; +mod retained; +mod selection; +mod selectors; + +use super::{cache, execution, linking}; +use crate::{ + commands::{color::Chalk, status::environments::Environments}, + host::{Host, callback_error::CallbackError}, +}; +use serde_json::{Value, json}; +use std::{io, path::Path, sync::Arc}; + +/// Retained provider and source-spool session shared across native workspace identity mutations. +pub(in crate::commands) struct ProjectedLinker { + environment: Option, + stdout: String, + retained: retained::Retained, + installation: Option, +} + +impl ProjectedLinker { + /// Load extensions while their original workspace paths still exist. + pub(in crate::commands) fn prepare(root: &Path) -> io::Result { + if !root.is_absolute() || !has_root_text(root) { + return Err(io::Error::other("identity workspace root is empty")); + } + let mut linker = Self { + environment: Some(Environments::for_tracking().for_checkout()), + stdout: String::new(), + retained: retained::Retained::default(), + installation: None, + }; + linker.call(root, "identityBootstrap", &json!({}))?; + Ok(linker) + } + + /// Takes and clears any accumulated provider stdout captured during identity operations. + #[inline] + pub(in crate::commands) fn take_stdout(&mut self) -> String { + std::mem::take(&mut self.stdout) + } + + /// Match a consumer reload while retaining callback spool ownership. + #[inline] + pub(in crate::commands) fn forget_loaded(&mut self) { + self.retained.forget_loaded(); + } + + /// Read native source and dependency metadata on the same hydrated provider session. + pub(in crate::commands) fn capture( + &mut self, + root: &Path, + bitmap: &Value, + identities: &[String], + ) -> io::Result> { + if !has_root_text(root) { + return self.finish(Err(io::Error::other("identity workspace root is empty"))); + } + let result = + load::capture(root, bitmap, identities, &mut self.environment, &mut self.stdout); + let result = result.and_then(|capture| self.retained.keep(capture)); + self.finish(result) + } + + /// Transfer immutable native capture proofs to a retained parent-operation plan. + #[inline] + pub(in crate::commands) fn captured_read_set(&self) -> Vec { + self.retained.read_receipts() + } + + /// Revalidate retained native facts before a consumer publishes workspace mutations. + #[inline] + pub(in crate::commands) fn validate_captures(&self) -> io::Result<()> { + self.retained.validate() + } + + /// Link the materialized projection before the caller publishes its retained bitmap. + #[inline] + pub(in crate::commands) fn link( + &mut self, + root: &Path, + bitmap: &Value, + identities: &[String], + json: bool, + chalk: Chalk, + ) -> io::Result<()> { + let result = self.link_inner(root, bitmap, identities, json, chalk); + self.finish(result) + } + + /// Invoke only registered extension, AST or compiler behavior through the retained host. + pub(in crate::commands) fn call( + &mut self, + root: &Path, + method: &str, + params: &Value, + ) -> io::Result { + let method = method.trim(); + if !has_root_text(root) || method.is_empty() { + return Err(io::Error::other("identity provider method is empty")); + } + let result = self.environment + .as_mut() + .ok_or_else(|| io::Error::other("identity provider session unavailable"))? + .identity_call(root, method, params) + .map(|(value, stdout)| { + self.stdout.push_str(&stdout); + value + }); + self.finish(result) + } + + fn finish(&mut self, result: io::Result) -> io::Result { + if let Err(error) = &result { + print!("{}", self.take_stdout()); + if let Some(callback) = + error.get_ref().and_then(|error| error.downcast_ref::()) + { + print!("{}{}", callback.startup, callback.output); + } + } + result + } + + fn link_inner( + &mut self, + root: &Path, + bitmap: &Value, + identities: &[String], + json: bool, + chalk: Chalk, + ) -> io::Result<()> { + let mut loaded = + load::prepare(root, bitmap, identities, &mut self.environment, &mut self.stdout)?; + load::enrich(&mut loaded)?; + let host = load::host(&mut loaded.capture)?; + let mut components = selection::components(&loaded.projection, identities); + selection::seed_environments(&mut components, &host, &loaded.capture.captured)?; + linking::overlay(&mut components, &loaded.capture.captured)?; + load::commit(root, &mut loaded)?; + let manifests = cache::Manifests::observe(root, &components); + let linked = execution::link( + root, + &loaded.projection, + &loaded.capture, + &components, + &host, + json, + chalk, + ); + let linked = + linked.and_then(|()| load::invalidate_changed(root, &components, &manifests, &host)); + self.restore(host)?; + linked + } + + fn restore(&mut self, host: Arc) -> io::Result<()> { + self.stdout.push_str(&host.take_stdout()); + let host = Arc::try_unwrap(host) + .map_err(|_| io::Error::other("identity provider session still borrowed"))?; + self.environment = Some(Environments::from_host(host).for_checkout()); + Ok(()) + } +} + +#[inline] +fn has_root_text(root: &Path) -> bool { + root.to_str() + .map_or(!root.as_os_str().is_empty(), |text| !text.trim().is_empty()) +} diff --git a/native/cli/src/commands/add/mutation/cached.rs b/native/cli/src/commands/add/mutation/cached.rs new file mode 100644 index 000000000000..a643866cd9eb --- /dev/null +++ b/native/cli/src/commands/add/mutation/cached.rs @@ -0,0 +1,161 @@ +//! New-component rename ([`ProjectedLinker`]) follows the published loaded-class cache across path materialization. +//! +//! Implements `capture_cached` to reuse retained component rows and refresh missing entries. +use super::{ProjectedLinker, cache, execution, linking, load, selection}; +use crate::commands::color::Chalk; +use serde_json::{Value, json}; +use std::{io, path::Path, sync::Arc}; + +impl ProjectedLinker { + /// Captures or reuses retained component rows for `identities` against `bitmap`. + #[inline] + pub(in crate::commands) fn capture_cached( + &mut self, + root: &Path, + bitmap: &Value, + identities: &[String], + ) -> io::Result> { + let result = self.capture_cached_inner(root, bitmap, identities); + self.finish(result) + } + fn capture_cached_inner( + &mut self, + root: &Path, + bitmap: &Value, + identities: &[String], + ) -> io::Result> { + let missing = self.retained.missing(identities); + if !missing.is_empty() { + let known = identities + .iter() + .map(|id| id.trim()) + .filter(|id| !id.is_empty() && !missing.iter().any(|item| item == id)) + .map(str::to_owned) + .collect::>(); + // Missing captures may need existing classes, then project a fresh + // bitmap. Seed before loading and rebind all classes afterwards. + self.cached_classes(root, &self.retained.rows(&known)?)?; + } + let invalidate = self.retained.start(); + if !missing.is_empty() { + let captured = load::capture_cached( + root, + bitmap, + &missing, + &mut self.environment, + &mut self.stdout, + invalidate, + )?; + self.retained.keep(captured)?; + } + let rows = self.retained.rows(identities)?; + self.cached_classes(root, &rows)?; + Ok(rows) + } + /// Links `identities` using retained component rows and cached provider classes. + #[inline] + pub(in crate::commands) fn link_retained( + &mut self, + root: &Path, + bitmap: &Value, + identities: &[String], + json: bool, + chalk: Chalk, + ) -> io::Result<()> { + let result = self.link_cached_inner(root, bitmap, identities, json, chalk); + self.finish(result) + } + fn link_cached_inner( + &mut self, + root: &Path, + bitmap: &Value, + identities: &[String], + json: bool, + chalk: Chalk, + ) -> io::Result<()> { + let (_, projection) = load::projected(root, bitmap, identities)?; + let mut components = selection::components(&projection, identities); + let rows = refreshed(self.retained.rows(identities)?, &components)?; + let stdout = self.environment + .as_mut() + .ok_or_else(|| io::Error::other("retained identity environment"))? + .project_tracking_retained(root, bitmap, &projection.components)?; + self.stdout.push_str(&stdout); + let environment = self.environment + .take() + .ok_or_else(|| io::Error::other("retained identity environment"))?; + let host = Arc::new( + environment + .into_tracking_host() + .ok_or_else(|| io::Error::other("retained identity host"))?, + ); + let linked = link_rows(root, &projection, &rows, &mut components, &host, json, chalk); + self.restore(host)?; + linked + } + #[inline] + fn cached_classes(&mut self, root: &Path, rows: &[Value]) -> io::Result<()> { + if rows.is_empty() { + return Ok(()); + } + let (_, stdout) = self.environment + .as_mut() + .ok_or_else(|| io::Error::other("retained identity environment"))? + .identity_call(root, "identityUseCached", &json!({"components":rows}))?; + self.stdout.push_str(&stdout); + Ok(()) + } +} +fn refreshed( + mut rows: Vec, + components: &[bit_install::model::WorkspaceComponent], +) -> io::Result> { + let mut by_id = std::collections::HashMap::with_capacity(components.len()); + for component in components { + by_id.insert( + component.id + .to_string_without_version() + .trim() + .to_owned(), + component, + ); + } + for row in &mut rows { + let identity = row["identity"] + .as_str() + .map(str::trim) + .filter(|id| !id.is_empty()) + .or_else(|| { + row["id"] + .as_str() + .map(str::trim) + .filter(|id| !id.is_empty()) + }) + .map(|id| id.rsplit_once('@').map_or(id, |(base, _)| base.trim())) + .filter(|id| !id.is_empty()); + let component = identity + .and_then(|id| by_id.get(id).copied()) + .ok_or_else(|| io::Error::other("retained identity projection missing"))?; + row["directory"] = json!(component.root_dir.trim()); + row["files"] = json!(component.files); + row["current"]["mainFile"] = json!(component.main_file.trim()); + } + Ok(rows) +} + +#[inline] +fn link_rows( + root: &Path, + projection: &super::super::projection::Projection, + rows: &[Value], + components: &mut [bit_install::model::WorkspaceComponent], + host: &Arc, + json: bool, + chalk: Chalk, +) -> io::Result<()> { + selection::seed_environments(components, host, rows)?; + linking::overlay(components, rows)?; + let manifests = cache::Manifests::observe(root, components); + execution::link_rows(root, projection, rows, components, host, json, chalk)?; + load::invalidate_changed(root, components, &manifests, host) +} diff --git a/native/cli/src/commands/add/mutation/controls.rs b/native/cli/src/commands/add/mutation/controls.rs new file mode 100644 index 000000000000..1f357106c878 --- /dev/null +++ b/native/cli/src/commands/add/mutation/controls.rs @@ -0,0 +1,119 @@ +//! Retain current [`Capture`] control facts across callbacks and later authoritative [`Engine`] captures. +//! +//! Exposes [`validate`] and [`retain`] for `identityControlReadSet` verification. +#[cfg(test)] +mod tests; + +use crate::commands::{engine::Engine, status::capture::Capture}; +use serde_json::{Value, json}; +use std::{ + fs, + io::{self, Read}, + path::Path, +}; + +const KEY: &str = "identityControlReadSet"; +const MAX_CONTROL: u64 = 32 * 1024 * 1024; + +/// Validates that the workspace control read-set in `context` remains unchanged on disk. +#[inline] +pub(super) fn validate(engine: &mut Engine, context: &Value) -> io::Result<()> { + engine.validate_read_set( + context["controlReadSet"] + .as_array() + .map_or(&[], Vec::as_slice), + "identity controls changed before component loading", + ) +} + +/// Refreshes and retains the current on-disk control file preimages into `capture`. +pub(super) fn retain(context: &Value, capture: &mut Capture) -> io::Result<()> { + let current = context["controlReadSet"] + .as_array() + .into_iter() + .flatten() + .map(|proof| { + let path = + proof_path(proof).ok_or_else(|| io::Error::other("identity control proof path"))?; + refresh(Path::new(path)) + }) + .collect::>>()?; + // A callback may intentionally update configuration. Keep its new preimage; + // subsequent captures re-read the authoritative context rather than old facts. + capture.environment_facts[KEY] = json!(current); + Ok(()) +} + +#[inline] +fn proof_path(proof: &Value) -> Option<&str> { + proof + .as_str() + .map(str::trim) + .filter(|path| !path.is_empty()) + .or_else(|| { + proof["path"] + .as_str() + .map(str::trim) + .filter(|path| !path.is_empty()) + }) + .or_else(|| { + proof["filePath"] + .as_str() + .map(str::trim) + .filter(|path| !path.is_empty()) + }) + .filter(|path| { + let candidate = Path::new(path); + candidate.is_absolute() + && !path.chars().any(char::is_control) + && !candidate + .components() + .any(|part| matches!(part, std::path::Component::ParentDir)) + }) +} + +#[inline] +pub(super) fn reads(facts: &Value) -> &[Value] { + facts[KEY] + .as_array() + .map_or(&[], Vec::as_slice) +} + +pub(super) fn refresh(path: &Path) -> io::Result { + match fs::symlink_metadata(path) { + Ok(metadata) + if !metadata.is_file() + || metadata.file_type().is_symlink() + || metadata.len() > MAX_CONTROL => + { + return Err(io::Error::other("identity control file shape or budget")); + } + Ok(_) => {} + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => return Err(error), + } + let file = match fs::File::open(path) { + Ok(file) => file, + Err(error) if error.kind() == io::ErrorKind::NotFound => { + return Ok(bit_install::policy::scope::proof_from_bytes(path, None)); + } + Err(error) => return Err(error), + }; + bounded_proof(path, file) +} + +#[inline] +fn bounded_proof(path: &Path, file: fs::File) -> io::Result { + let metadata = file.metadata()?; + if !metadata.is_file() { + return Err(io::Error::other("identity control file shape")); + } + let capacity = usize::try_from(metadata.len().min(MAX_CONTROL)).unwrap_or(0); + let mut bytes = Vec::with_capacity(capacity); + file.take(MAX_CONTROL + 1) + .read_to_end(&mut bytes)?; + if bytes.len() as u64 > MAX_CONTROL { + return Err(io::Error::other("identity control file budget")); + } + Ok(bit_install::policy::scope::proof_from_bytes(path, Some(&bytes))) +} diff --git a/native/cli/src/commands/add/mutation/controls/tests.rs b/native/cli/src/commands/add/mutation/controls/tests.rs new file mode 100644 index 000000000000..4542a7f5ddb9 --- /dev/null +++ b/native/cli/src/commands/add/mutation/controls/tests.rs @@ -0,0 +1,51 @@ +//! Unit tests for control file [`refresh`] and [`bounded_proof`] preimage proofs and symlink rejection. +//! +//! Verifies missing-file hash representation, size-budget enforcement, and non-regular file rejection. + +use super::{bounded_proof, refresh}; +use std::fs; + +/// Verifies that [`refresh`] and [`bounded_proof`] record absence and reject non-regular or oversized files. +#[test] +fn controls_retain_absence_and_reject_nonregular_or_over_budget_files() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("component.json"); + assert!(refresh(&path).unwrap()["hash"].is_null()); + fs::write(&path, "{}").unwrap(); + let first = refresh(&path).unwrap(); + assert_eq!(first, bit_install::policy::scope::read_proof(&path).unwrap()); + fs::write(&path, r#"{"changed":true}"#).unwrap(); + assert_ne!(refresh(&path).unwrap()["hash"], first["hash"]); + fs::OpenOptions::new() + .write(true) + .open(&path) + .unwrap() + .set_len(32 * 1024 * 1024 + 1) + .unwrap(); + assert!(refresh(&path).is_err()); + // Bypass the initial metadata guard, as when the file grows after it. + assert!(bounded_proof(&path, fs::File::open(&path).unwrap()).is_err()); + assert!(refresh(directory.path()).is_err()); +} + +#[cfg(unix)] +#[test] +fn a_control_symlink_cannot_replace_a_regular_preimage() { + let directory = tempfile::tempdir().unwrap(); + let target = directory.path().join("outside.json"); + let path = directory.path().join("component.json"); + fs::write(&target, "{}").unwrap(); + std::os::unix::fs::symlink(&target, &path).unwrap(); + assert!(refresh(&path).is_err()); +} + +/// Verifies that an empty control file hashes identically to [`bit_install::policy::scope::read_proof`]. +#[test] +fn empty_control_file_hashes_consistently_with_scope_read_proof() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("empty.json"); + fs::write(&path, b"").unwrap(); + let proof = refresh(&path).unwrap(); + assert!(proof["hash"].is_string()); + assert_eq!(proof, bit_install::policy::scope::read_proof(&path).unwrap()); +} diff --git a/native/cli/src/commands/add/mutation/fork.rs b/native/cli/src/commands/add/mutation/fork.rs new file mode 100644 index 000000000000..c3148e2a8290 --- /dev/null +++ b/native/cli/src/commands/add/mutation/fork.rs @@ -0,0 +1,58 @@ +//! Advance only the `.bitmap` preimage acknowledged by a completed native fork publication on [`ProjectedLinker`]. +//! +//! Validates and updates the retained `.bitmap` control entry via [`controls`] after `fork`. +use super::{ProjectedLinker, controls}; +use crate::commands::engine::Engine; +use serde_json::Value; +use std::{io, path::Path}; + +impl ProjectedLinker { + /// Captures and validates the current `.bitmap` preimage before a native `fork` write. + #[inline] + pub(in crate::commands) fn fork_bitmap_preimage(&self, root: &Path) -> io::Result { + let path = bitmap_path(root)?; + self.retained.validate()?; + controls::refresh(&path) + } + + /// Verifies and advances the retained `.bitmap` preimage from `before` to `bitmap` after `fork`. + pub(in crate::commands) fn fork_bitmap_committed( + &mut self, + root: &Path, + before: &Value, + bitmap: &Value, + ) -> io::Result<()> { + let path = bitmap_path(root)?; + if before["path"] + .as_str() + .map(str::trim) + .map(Path::new) + != Some(path.as_path()) + { + return Err(io::Error::other("fork bitmap preimage path")); + } + let bytes = bit_object_import::render_workspace_bitmap(bitmap)?; + let after = bit_install::policy::scope::proof_from_bytes(&path, Some(&bytes)); + if controls::refresh(&path)? != after { + return Err(io::Error::other("fork bitmap publication changed")); + } + Engine::new()? + .validate_read_set(std::slice::from_ref(&after), "fork bitmap publication changed")?; + self.retained.advance_bitmap(before, &after)?; + self.retained.validate() + } +} + +#[inline] +fn bitmap_path(root: &Path) -> io::Result { + if !root.is_absolute() + || root + .as_os_str() + .as_encoded_bytes() + .iter() + .all(u8::is_ascii_whitespace) + { + return Err(io::Error::other("fork bitmap workspace root is empty")); + } + Ok(root.join(".bitmap")) +} diff --git a/native/cli/src/commands/add/mutation/inbound.rs b/native/cli/src/commands/add/mutation/inbound.rs new file mode 100644 index 000000000000..e724ce9724d7 --- /dev/null +++ b/native/cli/src/commands/add/mutation/inbound.rs @@ -0,0 +1,151 @@ +//! A retained import on [`ProjectedLinker`] advances only exact content proofs acknowledged by its native publisher. +//! +//! Supports `prepare_checkout_docs`, `project_installation_workspace`, and proof advancement for inbound mutations. +use super::ProjectedLinker; +use serde_json::Value; +use std::{io, path::Path}; + +impl ProjectedLinker { + /// Checkout's filesystem consumer parses documentation before loading dependencies. + pub(in crate::commands) fn prepare_checkout_docs( + root: &Path, + environment: &mut crate::commands::status::environments::Environments, + identities: &[String], + deferred: bool, + ) -> io::Result { + let bitmap = bit_object_import::parse_jsonc(&std::fs::read(root.join(".bitmap"))?)?; + let (mut engine, projection) = super::load::projected(root, &bitmap, identities)?; + super::controls::validate(&mut engine, &projection.context)?; + super::super::docs::prepare(root, environment, &projection.loading_components(), deferred) + } + + /// `ComponentWriter` updates the live bitmap before its retained Host installs. + #[inline] + pub(in crate::commands) fn project_installation_workspace( + root: &Path, + environment: &mut crate::commands::status::environments::Environments, + ) -> io::Result { + let mut engine = crate::commands::engine::Engine::new()?; + Self::project_installation_workspace_with_engine(root, environment, &mut engine) + } + + /// The retained switch session also owns the fresh post-write projection. + pub(in crate::commands) fn project_installation_workspace_with_engine( + root: &Path, + environment: &mut crate::commands::status::environments::Environments, + engine: &mut crate::commands::engine::Engine, + ) -> io::Result { + let bitmap = bit_object_import::parse_jsonc(&std::fs::read(root.join(".bitmap"))?)?; + let projection = super::load::projected_with_engine(root, &bitmap, &[], engine)?; + super::controls::validate(engine, &projection.context)?; + environment.project_tracking_retained(root, &bitmap, &projection.components) + } + /// Captures component rows for an inbound import, refreshing retained versions when `retain_loaded` is set. + pub(in crate::commands) fn capture_import( + &mut self, + root: &Path, + bitmap: &Value, + identities: &[String], + retain_loaded: bool, + ) -> io::Result> { + if retain_loaded { + self.refresh_import_projection(root, bitmap, identities)?; + } + let environment = self.environment + .take() + .ok_or_else(|| io::Error::other("import callback environment"))?; + self.environment = + Some(environment.for_import_callbacks(retain_loaded && self.retained.has_started())); + if retain_loaded { + self.capture_cached(root, bitmap, identities) + } else { + self.capture(root, bitmap, identities) + } + } + + fn refresh_import_projection( + &mut self, + root: &Path, + bitmap: &Value, + identities: &[String], + ) -> io::Result<()> { + let (mut engine, projection) = super::load::projected(root, bitmap, identities)?; + super::controls::validate(&mut engine, &projection.context)?; + for component in &projection.workspace.components { + self.retained.import_version( + &component.id.to_string_without_version(), + component.id.version.as_deref(), + ); + } + if !self.retained.missing(identities).is_empty() { + return Ok(()); + } + let environment = self.environment + .as_mut() + .ok_or_else(|| io::Error::other("import callback environment"))?; + self.stdout.push_str(&environment.project_tracking_retained( + root, + bitmap, + &projection.components, + )?); + Ok(()) + } + + pub(in crate::commands) fn import_project_workspace(&mut self, root: &Path) -> io::Result<()> { + let bitmap = bit_object_import::parse_jsonc(&std::fs::read(root.join(".bitmap"))?)?; + let (mut engine, projection) = super::load::projected(root, &bitmap, &[])?; + super::controls::validate(&mut engine, &projection.context)?; + let environment = + self.environment.as_mut().ok_or_else(|| io::Error::other("import bitmap session"))?; + self.stdout.push_str(&environment.project_tracking_retained( + root, + &bitmap, + &projection.components, + )?); + Ok(()) + } + + pub(in crate::commands) fn import_docs( + &mut self, + root: &Path, + ids: &[String], + ) -> io::Result<()> { + let components = Self::import_documentation_components(root, ids)?; + let environment = self.environment + .as_mut() + .ok_or_else(|| io::Error::other("import documentation session"))?; + super::super::docs::load(root, environment, &components) + } + + pub(in crate::commands) fn import_documentation_components( + root: &Path, + identities: &[String], + ) -> io::Result> { + let normalized = identities + .iter() + .map(|id| strip_version(id.trim())) + .filter(|id| !id.is_empty()) + .collect::>(); + let (_, projection) = super::load::projected( + root, + &bit_object_import::parse_jsonc(&std::fs::read(root.join(".bitmap"))?)?, + &normalized, + )?; + Ok(projection.loading_components()) + } + + #[inline] + pub(in crate::commands) fn import_acknowledge(&mut self, writes: &[Value]) -> io::Result<()> { + self.retained.acknowledge_import(writes)?; + self.retained.validate() + } +} + +#[inline] +fn strip_version(id: &str) -> String { + let trimmed = id.trim(); + match trimmed.rsplit_once('@') { + Some((base, _)) => base.trim().to_owned(), + None => trimmed.to_owned(), + } +} diff --git a/native/cli/src/commands/add/mutation/installation.rs b/native/cli/src/commands/add/mutation/installation.rs new file mode 100644 index 000000000000..b1e6f1091063 --- /dev/null +++ b/native/cli/src/commands/add/mutation/installation.rs @@ -0,0 +1,73 @@ +//! Reuse [`ProjectedLinker`] identity providers when bulk renaming invokes the native [`Installer`]. +//! +//! Bootstraps [`Environments`] and installation [`bit_install::HostInfo`] prior to worker execution in `bit-cli`. +use super::ProjectedLinker; +use crate::commands::{installation::Installer, status::environments::Environments}; +use serde_json::json; +use std::{ffi::OsString, io, path::Path, sync::Arc}; + +impl ProjectedLinker { + /// Prepare process facts before native inventory or dependency worker threads start. + pub(in crate::commands) fn prepare_with_installation( + root: &Path, + args: &[OsString], + ) -> io::Result { + if !root.is_absolute() + || root + .to_str() + .map_or(root.as_os_str().is_empty(), |text| text.trim().is_empty()) + { + return Err(io::Error::other("identity installation workspace root is empty")); + } + Installer::validate_workspace(root)?; + let mut environment = Environments::for_tracking().for_checkout(); + let (info, stdout) = environment.identity_installation_info(root, args)?; + let mut linker = Self { + environment: Some(environment), + stdout, + retained: super::retained::Retained::default(), + installation: Some(info), + }; + linker.call(root, "identityBootstrap", &json!({}))?; + Ok(linker) + } + + /// `RenamingMain.installDepsGracefully` uses the original provider session. + #[inline] + pub(in crate::commands) fn install_scope_rename(&mut self, root: &Path) -> io::Result<()> { + self.finish(Installer::validate_workspace(root))?; + self.with_installer(|installer| installer.install_scope_rename(root)) + } + + /// Runs post-fork dependency installation on the retained provider session. + #[inline] + pub(in crate::commands) fn install_fork(&mut self, root: &Path) -> io::Result<()> { + self.finish(Installer::validate_workspace(root))?; + self.with_installer(|installer| installer.install_fork(root)) + } + + /// Keep native recovery imports and installation on the original provider session. + pub(in crate::commands) fn with_installer( + &mut self, + action: impl FnOnce(Installer) -> io::Result, + ) -> io::Result { + let mut info = self.installation + .clone() + .ok_or_else(|| { + io::Error::other("identity installation facts were not prepared before workers") + })?; + let file_id = crate::commands::workspace_command::file_id(); + if file_id.trim().is_empty() { + return Err(io::Error::other("identity installation file id is empty")); + } + info.file_id = file_id; + let host = self.environment + .take() + .and_then(Environments::into_tracking_host) + .ok_or_else(|| io::Error::other("identity installation provider session"))?; + let host = Arc::new(host); + let result = action(Installer::from_host(Arc::clone(&host), info)); + self.restore(host)?; + self.finish(result) + } +} diff --git a/native/cli/src/commands/add/mutation/load.rs b/native/cli/src/commands/add/mutation/load.rs new file mode 100644 index 000000000000..5676c1a0f73d --- /dev/null +++ b/native/cli/src/commands/add/mutation/load.rs @@ -0,0 +1,306 @@ +//! Native [`Projection`] capture and [`cache`] publication shared by workspace identity operations. +//! +//! Provides [`Loaded`] and [`prepare`] to capture component metadata and documentation. +use super::{ + super::{cache, docs, projection::Projection}, + controls, selection, +}; +use crate::{ + commands::{ + engine::Engine, + status::{ + capture::{self, CaptureOptions}, + environments::Environments, + }, + workspace_command, + }, + host::Host, +}; +use serde_json::{Value, json}; +use std::{io, path::Path, sync::Arc}; + +/// Captured [`Engine`], [`Projection`], and [`capture::Capture`] state for an identity operation. +pub(super) struct Loaded { + pub engine: Engine, + pub projection: Projection, + pub capture: capture::Capture, +} + +/// Prepares [`Loaded`] component metadata and invalidates the dependency cache for `identities`. +#[inline] +pub(super) fn prepare( + root: &Path, + bitmap: &Value, + identities: &[String], + environment: &mut Option, + stdout: &mut String, +) -> io::Result { + prepare_with(root, bitmap, identities, environment, stdout, true) +} +fn prepare_with( + root: &Path, + bitmap: &Value, + identities: &[String], + environment: &mut Option, + stdout: &mut String, + invalidate: bool, +) -> io::Result { + let cache = bit_install::dependencies::cache::invalidation::deps_dir(root); + if invalidate { + bit_install::dependencies::cache::invalidation::invalidate(root, &cache)?; + } + let (mut engine, projection) = projected(root, bitmap, identities)?; + controls::validate(&mut engine, &projection.context)?; + let environments = project(root, bitmap, &projection, environment, stdout, !invalidate)?; + let home = workspace_command::home(); + let mut capture = capture::capture_with( + &mut engine, + root, + &home, + &projection.context, + &projection.selected, + CaptureOptions { + environments: Some(environments.for_checkout()), + allow_special_states: true, + ..Default::default() + }, + )?; + controls::retain(&projection.context, &mut capture)?; + stdout.push_str(&capture::callback_startup_stdout(&capture)); + stdout.push_str(&capture::callback_load_stdout(&capture)); + Ok(Loaded { engine, projection, capture }) +} + +#[inline] +pub(super) fn projected( + root: &Path, + bitmap: &Value, + identities: &[String], +) -> io::Result<(Engine, Projection)> { + let mut engine = Engine::new()?; + let projection = projected_with_engine(root, bitmap, identities, &mut engine)?; + Ok((engine, projection)) +} + +#[inline] +pub(super) fn projected_with_engine( + root: &Path, + bitmap: &Value, + identities: &[String], + engine: &mut Engine, +) -> io::Result { + let home = workspace_command::home(); + let context = + engine.request(&json!({"kind":"trackingContext","root":root,"home":home,"bitmap":bitmap}))?; + selection::prepare(root, bitmap, identities, context) +} + +fn project( + root: &Path, + bitmap: &Value, + projection: &Projection, + environment: &mut Option, + stdout: &mut String, + retain_loaded: bool, +) -> io::Result { + let mut environments = environment + .take() + .ok_or_else(|| io::Error::other("identity provider session unavailable"))?; + let result = if retain_loaded { + environments.project_tracking_retained(root, bitmap, &projection.components) + } else { + environments.project_tracking(root, bitmap, &projection.components) + }; + stdout.push_str(&result.map_err(|error| environments.failure(error))?); + if let Some(error) = projection.load_error.as_deref().map(str::trim) + && !error.is_empty() + { + return Err(io::Error::other(error.to_owned())); + } + docs::load(root, &mut environments, &projection.loading_components())?; + Ok(environments) +} + +#[inline] +pub(super) fn commit(root: &Path, loaded: &mut Loaded) -> io::Result<()> { + cache::restrict(root, &mut loaded.capture); + loaded.engine.validate_read_set( + &loaded.capture.dependency_read_set(), + "identity component facts changed", + )?; + capture::commit(&mut loaded.engine, root, &loaded.projection.context, &loaded.capture) +} + +#[inline] +pub(super) fn host(capture: &mut capture::Capture) -> io::Result> { + let environment = capture::take_callback_environment(capture) + .ok_or_else(|| io::Error::other("identity callback environment"))?; + Ok(Arc::new( + environment + .into_tracking_host() + .ok_or_else(|| io::Error::other("identity package host"))?, + )) +} + +#[inline] +pub(super) fn capture( + root: &Path, + bitmap: &Value, + identities: &[String], + environment: &mut Option, + stdout: &mut String, +) -> io::Result { + capture_with(root, bitmap, identities, environment, stdout, true, true) +} +#[inline] +pub(super) fn capture_cached( + root: &Path, + bitmap: &Value, + identities: &[String], + environment: &mut Option, + stdout: &mut String, + invalidate: bool, +) -> io::Result { + capture_with(root, bitmap, identities, environment, stdout, invalidate, true) +} +fn capture_with( + root: &Path, + bitmap: &Value, + identities: &[String], + environment: &mut Option, + stdout: &mut String, + invalidate: bool, + hydrate: bool, +) -> io::Result { + let mut loaded = prepare_with(root, bitmap, identities, environment, stdout, invalidate)?; + commit(root, &mut loaded)?; + finish_capture(loaded, environment, stdout, hydrate) +} + +fn finish_capture( + mut loaded: Loaded, + environment: &mut Option, + stdout: &mut String, + hydrate: bool, +) -> io::Result { + if hydrate { + enrich(&mut loaded)?; + } + let host = host(&mut loaded.capture)?; + stdout.push_str(&host.take_stdout()); + let host = Arc::try_unwrap(host) + .map_err(|_| io::Error::other("identity capture host still borrowed"))?; + *environment = Some(Environments::from_host(host).for_checkout()); + for row in &mut loaded.capture.captured { + let row_id = row_identity(row); + if let Some(component) = loaded.capture.components + .iter() + .find(|component| { + let comp_id = component.identity.trim(); + let bare = comp_id.rsplit_once('@').map_or(comp_id, |(base, _)| base.trim()); + row_id == Some(bare) + }) + { + row["packageName"] = json!(component.package_name); + row["componentIdentity"] = json!(component.identity); + row["componentDependencies"] = json!(component.dependencies); + } + } + Ok(loaded.capture) +} + +pub(super) fn capture_consumer( + root: &Path, + bitmap: &Value, + identities: &[String], + environment: &mut Option, + stdout: &mut String, +) -> io::Result { + let mut loaded = prepare_with(root, bitmap, identities, environment, stdout, false)?; + let histories: Value = loaded.engine.request(&json!({"kind":"statusModels","root":root,"history":true,"stageHistory":true,"historyTargetsOnly":true,"includeHeads":true, + "currentLaneId":loaded.projection.context["currentLaneId"], + "targets":loaded.projection.selected.iter().map(|row|json!({"identity":row_identity(row),"version":row["version"]})).collect::>()}))?; + loaded.projection.context["pendingHistory"] = histories + .get("pendingHistory") + .cloned() + .unwrap_or(json!([])); + commit(root, &mut loaded)?; + let targets = loaded.capture.comparisons + .iter() + .cloned() + .map(|mut row| { + if let Some(row) = row.as_object_mut() { + row.remove("trackingVersion"); + } + row + }) + .collect::>(); + let compared: Value = + loaded.engine.request(&json!({"kind":"statusCompare","root":root,"targets":targets}))?; + for row in &mut loaded.capture.captured { + let row_id = row_identity(row); + row["modified"] = compared["comparisons"] + .as_array() + .and_then(|rows| { + rows.iter() + .find(|other| row_identity(other) == row_id) + }) + .map_or(Value::Bool(false), |row| row["modified"].clone()); + } + finish_capture(loaded, environment, stdout, true) +} + +pub(super) fn enrich(loaded: &mut Loaded) -> io::Result<()> { + let rows = crate::commands::status::environments::hydration_rows( + &loaded.capture, + &loaded.projection.context, + )?; + for row in rows { + let row_id = row_identity(&row) + .ok_or_else(|| io::Error::other("cached hydration identity"))? + .to_owned(); + let Value::Object(map) = row else { + return Err(io::Error::other("cached hydration receipt")); + }; + let current = loaded.capture.captured + .iter_mut() + .find(|current| row_identity(current) == Some(row_id.as_str())) + .ok_or_else(|| io::Error::other("cached hydration identity"))?; + current + .as_object_mut() + .ok_or_else(|| io::Error::other("cached hydration row"))? + .extend(map); + } + Ok(()) +} + +#[inline] +fn row_identity(row: &Value) -> Option<&str> { + row["identity"] + .as_str() + .map(str::trim) + .filter(|id| !id.is_empty()) + .or_else(|| { + row["id"] + .as_str() + .map(str::trim) + .filter(|id| !id.is_empty()) + }) + .map(|id| id.rsplit_once('@').map_or(id, |(base, _)| base.trim())) + .filter(|id| !id.is_empty()) +} + +/// `NodeModuleLinker` clears loaded classes when generated package metadata changes. +#[inline] +pub(super) fn invalidate_changed( + root: &Path, + components: &[bit_install::model::WorkspaceComponent], + manifests: &cache::Manifests, + host: &Host, +) -> io::Result<()> { + if manifests.changed() { + cache::invalidate(root, components)?; + host.call("identityClearLoadCache", &json!({}))?; + } + Ok(()) +} diff --git a/native/cli/src/commands/add/mutation/removal.rs b/native/cli/src/commands/add/mutation/removal.rs new file mode 100644 index 000000000000..61a293c46c32 --- /dev/null +++ b/native/cli/src/commands/add/mutation/removal.rs @@ -0,0 +1,94 @@ +//! Legacy status loads on [`ProjectedLinker`] share component classes and comparisons with earlier selectors. +//! +//! Implements `capture_consumers` for component removal and deprecation status checks. +use super::{ProjectedLinker, load}; +use crate::commands::{engine::Engine, workspace_command}; +use serde_json::{Value, json}; +use std::{io, path::Path}; +impl ProjectedLinker { + /// Captures consumer status rows for `ids` against the projected `bitmap`. + #[inline] + pub(in crate::commands) fn capture_consumers( + &mut self, + root: &Path, + bitmap: &Value, + ids: &[String], + ) -> io::Result> { + let result = self.consumer_status(root, bitmap, ids); + self.finish(result) + } + /// Loads missing consumer components and compares status for `ids` against `bitmap`. + fn consumer_status( + &mut self, + root: &Path, + bitmap: &Value, + ids: &[String], + ) -> io::Result> { + if !root.is_absolute() + || root + .as_os_str() + .as_encoded_bytes() + .iter() + .all(u8::is_ascii_whitespace) + { + return Err(io::Error::other("consumer status workspace root is empty")); + } + let normalized = normalize_ids(ids); + let ids = normalized.as_slice(); + let missing = self.retained.missing(ids); + if !missing.is_empty() { + let capture = load::capture_consumer( + root, + bitmap, + &missing, + &mut self.environment, + &mut self.stdout, + )?; + self.retained.start(); + self.retained.keep(capture)?; + } + let unchecked = unchecked_ids(&self.retained.rows(ids)?); + if !unchecked.is_empty() { + let mut engine = Engine::new()?; + let context = engine.request(&json!({"kind":"trackingContext","root":root, + "home":workspace_command::home(),"bitmap":bitmap}))?; + self.compare_status(&mut engine, root, &context, &unchecked)?; + } + self.retained.rows(ids) + } +} + +#[inline] +fn normalize_ids(ids: &[String]) -> Vec { + let mut seen = std::collections::HashSet::with_capacity(ids.len()); + ids.iter() + .map(|id| { + let trimmed = id.trim(); + trimmed.rsplit_once('@').map_or(trimmed, |(base, _)| base.trim()) + }) + .filter(|id| !id.is_empty() && seen.insert(*id)) + .map(str::to_owned) + .collect() +} + +#[inline] +fn unchecked_ids(rows: &[Value]) -> Vec { + rows.iter() + .filter(|row| !row["modified"].is_boolean()) + .filter_map(|row| { + row["identity"] + .as_str() + .map(str::trim) + .filter(|id| !id.is_empty()) + .or_else(|| { + row["id"] + .as_str() + .map(str::trim) + .filter(|id| !id.is_empty()) + }) + .map(|id| id.rsplit_once('@').map_or(id, |(base, _)| base.trim())) + .filter(|id| !id.is_empty()) + .map(str::to_owned) + }) + .collect() +} diff --git a/native/cli/src/commands/add/mutation/retained.rs b/native/cli/src/commands/add/mutation/retained.rs new file mode 100644 index 000000000000..96a3e7da311e --- /dev/null +++ b/native/cli/src/commands/add/mutation/retained.rs @@ -0,0 +1,193 @@ +//! Keep callback [`Retained`] source spools alive until identity publication and tooling finish. +//! +//! Tracks [`Capture`] instances, control preimages, and [`super::selectors::Facts`] across reloads. +mod import; +#[cfg(test)] +mod tests; + +use crate::commands::status::capture::Capture; +use serde_json::Value; +use std::{collections::HashMap, fs, io, path::Path}; + +/// Retained component [`Capture`]s, source spools, read-set proofs, and selector [`super::selectors::Facts`]. +#[derive(Default)] +pub(super) struct Retained { + captures: Vec, + bytes: u64, + rows: HashMap, + started: bool, + read_set: Vec, + controls: HashMap, + pub(super) facts: super::selectors::Facts, +} + +impl Retained { + /// Clears loaded component rows and selector facts while keeping callback spool ownership. + #[inline] + pub(super) fn forget_loaded(&mut self) { + // Keep source spool ownership and its aggregate budget across reloads. + self.rows.clear(); + self.facts = super::selectors::Facts::default(); + self.started = false; + } + pub(super) fn advance_bitmap(&mut self, before: &Value, after: &Value) -> io::Result<()> { + let target_path = trimmed_str(&before["path"]) + .ok_or_else(|| io::Error::other("fork bitmap preimage path"))?; + if self.read_set + .iter() + .chain(self.controls.values()) + .filter(|proof| trimmed_str(&proof["path"]) == Some(target_path)) + .any(|proof| proof != before) + { + return Err(io::Error::other("fork bitmap preimage changed")); + } + for proof in self.read_set.iter_mut().chain(self.controls.values_mut()) { + if trimmed_str(&proof["path"]) == Some(target_path) { + *proof = after.clone(); + } + } + Ok(()) + } + #[inline] + pub(super) const fn has_started(&self) -> bool { + self.started + } + #[inline] + pub(super) fn start(&mut self) -> bool { + let first = !self.started; + self.started = true; + first + } + pub(super) fn missing(&self, identities: &[String]) -> Vec { + identities + .iter() + .map(|identity| identity.trim()) + .filter(|identity| !identity.is_empty() && !self.rows.contains_key(*identity)) + .map(str::to_owned) + .collect() + } + pub(super) fn rows(&self, identities: &[String]) -> io::Result> { + identities + .iter() + .map(|identity| { + self.rows + .get(identity.trim()) + .cloned() + .ok_or_else(|| io::Error::other("retained identity component missing")) + }) + .collect() + } + pub(super) fn keep_controls(&mut self, proofs: &[Value]) -> io::Result<()> { + for proof in proofs { + let path = trimmed_str(&proof["path"]) + .ok_or_else(|| io::Error::other("identity control proof path"))?; + self.controls.insert(path.into(), proof.clone()); + } + Ok(()) + } + #[inline] + pub(super) fn add_proofs(&mut self, proofs: Vec) { + self.read_set.extend(proofs); + } + pub(super) fn status(&mut self, compared: &Value, ids: &[String]) { + let comparisons = compared["comparisons"].as_array(); + for id in ids { + let id = id.trim(); + let Some(row) = self.rows.get_mut(id) else { continue }; + row["modified"] = comparisons + .and_then(|rows| { + rows.iter() + .find(|row| row_id(row) == Some(id)) + }) + .map_or(Value::Bool(false), |row| row["modified"].clone()); + } + } + #[inline] + pub(super) fn read_receipts(&self) -> Vec { + self.read_set + .iter() + .cloned() + .chain(self.controls.values().cloned()) + .collect() + } + #[inline] + pub(super) fn validate(&self) -> io::Result<()> { + let reads = self.read_receipts(); + crate::commands::engine::Engine::new()? + .validate_read_set(&reads, "identity component facts changed before publication") + } + pub(super) fn keep(&mut self, capture: Capture) -> io::Result> { + self.read_set.extend(capture.dependency_read_set()); + self.facts.keep(&capture); + self.keep_controls(super::controls::reads(&capture.environment_facts))?; + let bytes = overlay_bytes(&capture)?; + self.bytes = self.bytes + .checked_add(bytes) + .filter(|bytes| *bytes <= 96 * 1024 * 1024) + .ok_or_else(|| io::Error::other("identity callback source budget"))?; + let rows = capture.captured.clone(); + for row in &rows { + let identity = + row_id(row).ok_or_else(|| io::Error::other("retained component identity"))?; + self.rows.insert(identity.into(), row.clone()); + } + // Even unchanged callback sources carry a root descriptor which later + // native comparisons validate. Its directory must outlive those facts. + if rows + .iter() + .any(|row| trimmed_str(&row["sourceOverlayRoot"]).is_some()) + { + self.captures.push(capture); + } + Ok(rows) + } +} + +#[inline] +fn row_id(row: &Value) -> Option<&str> { + trimmed_str(&row["identity"]) + .or_else(|| trimmed_str(&row["id"])) + .map(|id| id.rsplit_once('@').map_or(id, |(base, _)| base.trim())) + .filter(|id| !id.is_empty()) +} + +#[inline] +fn trimmed_str(value: &Value) -> Option<&str> { + value + .as_str() + .map(str::trim) + .filter(|text| !text.is_empty()) +} + +fn overlay_bytes(capture: &Capture) -> io::Result { + let mut total = 0_u64; + for row in &capture.captured { + for path in row["sourceOverlays"] + .as_object() + .into_iter() + .flat_map(|files| files.values()) + { + let path = + trimmed_str(path).ok_or_else(|| io::Error::other("identity callback source path"))?; + let root = trimmed_str(&row["sourceOverlayRoot"]) + .ok_or_else(|| io::Error::other("identity callback source root"))?; + let source_path = Path::new(path); + let root_path = Path::new(root); + let metadata = fs::symlink_metadata(source_path)?; + if !source_path.is_absolute() + || !root_path.is_absolute() + || source_path + .components() + .any(|part| matches!(part, std::path::Component::ParentDir)) + || !source_path.starts_with(root_path) + || !metadata.is_file() + { + return Err(io::Error::other("identity callback source outside private spool")); + } + total = total + .checked_add(metadata.len()) + .ok_or_else(|| io::Error::other("identity callback source budget"))?; + } + } + Ok(total) +} diff --git a/native/cli/src/commands/add/mutation/retained/import.rs b/native/cli/src/commands/add/mutation/retained/import.rs new file mode 100644 index 000000000000..3b99da4f89ef --- /dev/null +++ b/native/cli/src/commands/add/mutation/retained/import.rs @@ -0,0 +1,155 @@ +//! Acknowledge only [`Retained`] content preimages consumed by exact native import publications. +//! +//! Refreshes versioned component rows and advances retained file proofs after import writes in `bit-cli`. +#[cfg(test)] +mod tests; + +use super::Retained; +use serde_json::{Value, json}; +use std::{collections::HashSet, io, path::Path}; + +impl Retained { + /// A different tracked version needs fresh facts; old proof/spool owners remain retained. + pub(in crate::commands::add::mutation) fn import_version( + &mut self, + identity: &str, + version: Option<&str>, + ) { + let trimmed = identity.trim(); + let (bare, inline) = trimmed + .rsplit_once('@') + .map_or((trimmed, None), |(base, ver)| (base.trim(), Some(ver.trim()))); + if bare.is_empty() { + return; + } + let current = self.rows + .get(bare) + .or_else(|| self.rows.get(trimmed)) + .and_then(|row| row["version"].as_str()) + .map(str::trim) + .filter(|value| !value.is_empty() && !matches!(*value, "latest" | "head")); + let requested = version + .map(str::trim) + .or(inline) + .filter(|value| !value.is_empty() && !matches!(*value, "latest" | "head")); + if current != requested { + self.rows.remove(bare); + self.rows.remove(trimmed); + } + } + + /// Advances retained file content proofs matching `writes` after a native import commit. + pub(in crate::commands::add::mutation) fn acknowledge_import( + &mut self, + writes: &[Value], + ) -> io::Result<()> { + let replacements = self.import_replacements(writes)?; + for (before, after) in replacements { + let expected_path = content_path(&before)?; + for proof in self.read_set.iter_mut().chain(self.controls.values_mut()) { + if content(proof) + && content_path(proof).ok() == Some(expected_path) + && proof["hash"] == before["hash"] + { + proof["hash"] = after["hash"].clone(); + } + } + } + Ok(()) + } + fn import_replacements(&self, writes: &[Value]) -> io::Result> { + let mut seen = HashSet::with_capacity(writes.len()); + let mut replacements = Vec::new(); + for write in writes { + let (before, after) = receipt(write)?; + let path = before["path"].as_str().ok_or_else(|| invalid("import write path"))?; + if !seen.insert(Path::new(path).to_owned()) { + return Err(invalid("duplicate import write path")); + } + if !self.matching_content(&before)? { + continue; + } + let actual = super::super::controls::refresh(Path::new(path))?; + if actual != after { + return Err(invalid("import published content changed")); + } + replacements.push((before, after)); + } + Ok(replacements) + } + fn matching_content(&self, before: &Value) -> io::Result { + let expected_path = content_path(before)?; + let mut matched = false; + for proof in self.read_set.iter().chain(self.controls.values()) { + if !content(proof) || content_path(proof)? != expected_path { + continue; + } + if proof["hash"] != before["hash"] { + return Err(invalid("import write preimage changed")); + } + matched = true; + } + Ok(matched) + } +} +#[inline] +fn content_path(proof: &Value) -> io::Result<&Path> { + proof["path"] + .as_str() + .map(str::trim) + .filter(|path| !path.is_empty()) + .map(Path::new) + .filter(|path| path.is_absolute()) + .ok_or_else(|| invalid("import content proof path")) +} +#[inline] +fn content(proof: &Value) -> bool { + proof + .as_object() + .is_some_and(|proof| { + proof.len() == 2 && proof.contains_key("path") && proof.contains_key("hash") + }) +} +fn receipt(write: &Value) -> io::Result<(Value, Value)> { + let row = write.as_object().ok_or_else(|| invalid("import write receipt"))?; + let path = write["path"] + .as_str() + .map(str::trim) + .filter(|path| !path.is_empty() && Path::new(path).is_absolute()) + .ok_or_else(|| invalid("import write path"))?; + if row.len() != 3 + || !row.contains_key("beforeHash") + || !row.contains_key("afterHash") + || !hash(&write["beforeHash"]) + || !hash(&write["afterHash"]) + { + return Err(invalid("import write digest")); + } + Ok(( + json!({"path":path,"hash":normalize_hash(&write["beforeHash"])}), + json!({"path":path,"hash":normalize_hash(&write["afterHash"])}), + )) +} +#[inline] +fn normalize_hash(value: &Value) -> Value { + value + .as_str() + .map_or_else(|| value.clone(), |text| Value::String(text.trim().to_owned())) +} +#[inline] +fn hash(value: &Value) -> bool { + value.is_null() + || value + .as_str() + .map(str::trim) + .is_some_and(|hash| { + hash.len() == 64 + && hash + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + }) +} +#[inline] +fn invalid(message: &str) -> io::Error { + io::Error::other(message) +} diff --git a/native/cli/src/commands/add/mutation/retained/import/tests.rs b/native/cli/src/commands/add/mutation/retained/import/tests.rs new file mode 100644 index 000000000000..62333028a3dd --- /dev/null +++ b/native/cli/src/commands/add/mutation/retained/import/tests.rs @@ -0,0 +1,176 @@ +//! Unit tests for acknowledging imported object and file publications in [`Retained`] readsets. +//! +//! Verifies [`write`] receipt transitions and preservation of unrelated [`Retained`] readset entries. + +use super::{Retained, json}; +use std::fs; + +/// Builds a synthetic import write receipt transitioning `path` from `before` bytes to `after` bytes. +fn write(path: &std::path::Path, before: &[u8], after: &[u8]) -> serde_json::Value { + let original = bit_install::policy::scope::proof_from_bytes(path, Some(before)); + let published = bit_install::policy::scope::proof_from_bytes(path, Some(after)); + json!({"path":path,"beforeHash":original["hash"],"afterHash":published["hash"]}) +} +/// Verifies that exact owned content receipts advance while preserving unrelated read-set entries. +#[test] +fn exact_owned_content_advances_without_discarding_other_receipts() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("source.js"); + fs::write(&path, b"new").unwrap(); + let before = bit_install::policy::scope::proof_from_bytes(&path, Some(b"old")); + let unrelated = json!({"path":directory.path().join("other.js"),"hash":null}); + let metadata = json!({"path":path,"hash":null,"metadata":{"kind":"file","realPath":path}}); + let mut retained = Retained { + read_set: vec![before, unrelated.clone(), metadata.clone()], + ..Default::default() + }; + retained + .acknowledge_import(&[write(&path, b"old", b"new")]) + .unwrap(); + assert_eq!( + retained.read_set, + vec![ + bit_install::policy::scope::proof_from_bytes(&path, Some(b"new")), + unrelated, + metadata, + ], + ); +} +#[test] +fn a_conflicting_preimage_does_not_partially_advance_an_earlier_receipt() { + let directory = tempfile::tempdir().unwrap(); + let first = directory.path().join("first.js"); + let second = directory.path().join("second.js"); + fs::write(&first, b"new").unwrap(); + fs::write(&second, b"new").unwrap(); + let mut retained = Retained { + read_set: vec![ + bit_install::policy::scope::proof_from_bytes(&first, Some(b"old")), + bit_install::policy::scope::proof_from_bytes(&second, Some(b"unexpected")), + ], + ..Default::default() + }; + let original = retained.read_set.clone(); + assert!( + retained + .acknowledge_import(&[write(&first, b"old", b"new"), write(&second, b"old", b"new")]) + .is_err(), + ); + assert_eq!(retained.read_set, original); +} +#[test] +fn changed_published_bytes_are_rejected_and_absent_receipts_are_not_invented() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("source.js"); + fs::write(&path, b"concurrent").unwrap(); + let before = bit_install::policy::scope::proof_from_bytes(&path, Some(b"old")); + let mut retained = Retained { read_set: vec![before.clone()], ..Default::default() }; + assert!( + retained + .acknowledge_import(&[write(&path, b"old", b"new")]) + .is_err(), + ); + assert_eq!(retained.read_set, vec![before]); + let unrelated = directory.path().join("not-captured.js"); + retained + .acknowledge_import(&[write(&unrelated, b"old", b"new")]) + .unwrap(); + assert_eq!(retained.read_set.len(), 1); +} +#[cfg(unix)] +#[test] +fn a_symlink_cannot_acknowledge_a_regular_publication_even_with_matching_bytes() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("source.js"); + let alias = directory.path().join("alias.js"); + fs::write(&alias, b"new").unwrap(); + std::os::unix::fs::symlink(&alias, &path).unwrap(); + let mut retained = Retained { + read_set: vec![bit_install::policy::scope::proof_from_bytes(&path, Some(b"old"))], + ..Default::default() + }; + assert!( + retained + .acknowledge_import(&[write(&path, b"old", b"new")]) + .is_err(), + ); +} + +#[test] +fn changing_import_versions_invalidates_only_the_reusable_row() { + let source = json!({"path":"/workspace/source.js","hash":"retained"}); + let row = json!({"identity":"acme/comp","version":"0.0.1"}); + let other = json!({"identity":"acme/other","version":"0.0.1"}); + let mut retained = + Retained { read_set: vec![source.clone()], started: true, ..Default::default() }; + retained.rows.insert("acme/comp".into(), row.clone()); + retained.rows.insert("acme/other".into(), other.clone()); + retained.import_version("acme/comp", Some("0.0.1")); + assert_eq!(retained.rows["acme/comp"], row); + retained.import_version("acme/comp", Some("0.0.2")); + assert!(!retained.rows.contains_key("acme/comp")); + assert_eq!(retained.rows["acme/other"], other); + assert_eq!(retained.read_set, vec![source]); + assert!(retained.started); +} + +#[cfg(windows)] +#[test] +fn windows_separator_equivalence_advances_hashes_without_rewriting_proof_paths() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("source.js"); + fs::write(&path, b"new").unwrap(); + let forward = path + .to_str() + .unwrap() + .replace('\\', "/"); + let mut proof = bit_install::policy::scope::proof_from_bytes(&path, Some(b"old")); + proof["path"] = json!(forward); + let mut retained = Retained { read_set: vec![proof], ..Default::default() }; + retained + .acknowledge_import(&[write(&path, b"old", b"new")]) + .unwrap(); + let mut expected = bit_install::policy::scope::proof_from_bytes(&path, Some(b"new")); + expected["path"] = json!(forward); + assert_eq!(retained.read_set, vec![expected]); + let mut duplicate = write(&path, b"new", b"new"); + duplicate["path"] = json!(forward); + let before = retained.read_set.clone(); + assert!( + retained + .acknowledge_import(&[write(&path, b"new", b"new"), duplicate]) + .is_err(), + ); + assert_eq!(retained.read_set, before); +} + +#[test] +fn malformed_acknowledgments_cannot_advance_a_valid_content_proof() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("source.js"); + fs::write(&path, b"new").unwrap(); + let before = bit_install::policy::scope::proof_from_bytes(&path, Some(b"old")); + let mut retained = Retained { read_set: vec![before.clone()], ..Default::default() }; + for field in ["beforeHash", "afterHash", "path"] { + let mut receipt = write(&path, b"old", b"new"); + receipt[field] = json!("invalid"); + assert!( + retained + .acknowledge_import(&[receipt]) + .is_err(), + ); + assert_eq!(retained.read_set, vec![before.clone()]); + } + retained + .acknowledge_import(&[]) + .unwrap(); + assert_eq!(retained.read_set, vec![before]); +} + +#[test] +fn import_version_none_removes_cached_component_row() { + let mut retained = Retained::default(); + retained.rows.insert("acme/comp".into(), json!({"identity":"acme/comp","version":"0.0.1"})); + retained.import_version("acme/comp", None); + assert!(!retained.rows.contains_key("acme/comp")); +} diff --git a/native/cli/src/commands/add/mutation/retained/tests.rs b/native/cli/src/commands/add/mutation/retained/tests.rs new file mode 100644 index 000000000000..e0c35761fb65 --- /dev/null +++ b/native/cli/src/commands/add/mutation/retained/tests.rs @@ -0,0 +1,45 @@ +//! Unit tests for advancing `.bitmap` readset and control receipts in [`Retained`]. +//! +//! Verifies non-conflicting `.bitmap` receipt advancement and conflict rejection across [`Retained`] state. + +use super::Retained; +use serde_json::json; + +/// Verifies that `advance_bitmap` updates `.bitmap` receipts while preserving unrelated read-set entries. +#[test] +fn acknowledged_bitmap_changes_preserve_all_other_read_receipts() { + let before = json!({"path":"/workspace/.bitmap","hash":"before"}); + let after = json!({"path":"/workspace/.bitmap","hash":"after"}); + let source = json!({"path":"/workspace/comp/index.js","hash":"source"}); + let mut retained = Retained { + read_set: vec![before.clone(), source.clone(), before.clone()], + ..Default::default() + }; + retained.controls.insert("/workspace/.bitmap".into(), before.clone()); + retained.advance_bitmap(&before, &after).unwrap(); + assert_eq!(retained.read_set, vec![after.clone(), source, after.clone()]); + assert_eq!(retained.controls["/workspace/.bitmap"], after); +} + +/// Verifies that a conflicting `.bitmap` receipt rejects the entire advance without partial updates. +#[test] +fn a_conflicting_bitmap_receipt_rejects_the_whole_advance() { + let before = json!({"path":"/workspace/.bitmap","hash":"before"}); + let conflicting = json!({"path":"/workspace/.bitmap","hash":"unexpected"}); + let after = json!({"path":"/workspace/.bitmap","hash":"after"}); + let mut retained = + Retained { read_set: vec![before.clone(), conflicting], ..Default::default() }; + let original = retained.read_set.clone(); + assert!(retained.advance_bitmap(&before, &after).is_err()); + assert_eq!(retained.read_set, original); +} + +#[test] +fn advance_bitmap_updates_controls_even_when_read_set_is_empty() { + let before = json!({"path":"/workspace/.bitmap","hash":"before"}); + let after = json!({"path":"/workspace/.bitmap","hash":"after"}); + let mut retained = Retained::default(); + retained.controls.insert("/workspace/.bitmap".into(), before.clone()); + retained.advance_bitmap(&before, &after).unwrap(); + assert_eq!(retained.controls["/workspace/.bitmap"], after); +} diff --git a/native/cli/src/commands/add/mutation/selection.rs b/native/cli/src/commands/add/mutation/selection.rs new file mode 100644 index 000000000000..1cc68d29dc79 --- /dev/null +++ b/native/cli/src/commands/add/mutation/selection.rs @@ -0,0 +1,114 @@ +//! Project the selected persisted identities into [`Projection`] without inventing new-component versions. +//! +//! Builds [`Workspace`] and [`WorkspaceConfig`] projections via [`prepare`]. +use super::super::projection::{self, Projection}; +use bit_install::{config::WorkspaceConfig, model::WorkspaceComponent, workspace::Workspace}; +use serde_json::{Value, json}; +use std::{io, path::Path}; + +/// Projects `identities` from `bitmap` and `context` into a [`Projection`] for component loading. +pub(super) fn prepare( + root: &Path, + bitmap: &Value, + identities: &[String], + context: Value, +) -> io::Result { + let config = + WorkspaceConfig::from_value(root.join("workspace.jsonc"), context["workspace"].clone())?; + let core: Vec = serde_json::from_str(include_str!( + "../../../../../../scopes/harmony/testing/load-aspect/core-aspects-ids.json" + ))?; + let workspace = Workspace::load_projected(root, &config, &core, bitmap)?; + let expected = identities + .iter() + .map(|target| { + let trimmed = target.trim(); + trimmed.rsplit_once('@').map_or(trimmed, |(base, _)| base.trim()) + }) + .filter(|target| !target.is_empty()) + .collect::>(); + let selected = workspace.components + .iter() + .filter_map(|component| { + let id = component.id.to_string_without_version(); + expected.contains(id.trim()).then(|| { + json!({ + "identity": id, + "rootDir": component.root_dir, + "mainFile": component.main_file, + "files": component.files, + "loadVersion": component.id.version.as_deref().map(str::trim).filter(|version| !version.is_empty()).unwrap_or("latest"), + }) + }) + }) + .collect::>(); + if selected.len() != expected.len() { + return Err(io::Error::other("identity link selection changed")); + } + projection::prepare_loaded(root, bitmap, &selected, context, workspace) +} + +/// Selects [`WorkspaceComponent`] entries from `projection` in the order of `identities`. +pub(super) fn components( + projection: &Projection, + identities: &[String], +) -> Vec { + let mut by_id = std::collections::HashMap::with_capacity(projection.workspace.components.len()); + for component in &projection.workspace.components { + by_id.insert( + component.id + .to_string_without_version() + .trim() + .to_owned(), + component, + ); + } + identities + .iter() + .map(|identity| { + let trimmed = identity.trim(); + trimmed.rsplit_once('@').map_or(trimmed, |(base, _)| base.trim()) + }) + .filter(|identity| !identity.is_empty()) + .filter_map(|identity| by_id.get(identity).copied().cloned()) + .collect() +} + +/// Seed actual loaded classes before resolving registered environment identities. +pub(super) fn seed_environments( + components: &mut [WorkspaceComponent], + host: &crate::host::Host, + captured: &[Value], +) -> io::Result<()> { + let facts = host.call( + "identityLinkFacts", + &json!({"components":captured,"ids":components.iter().map(|component| &component.env_id).collect::>()}), + )?; + for component in components { + component.env_id = extract_registered_env(&facts, &component.env_id) + .ok_or_else(|| io::Error::other("identity registered environment receipt"))?; + } + Ok(()) +} + +#[inline] +fn extract_registered_env(facts: &Value, env_id: &str) -> Option { + let entry = facts + .get(env_id) + .or_else(|| facts.get(env_id.trim()))?; + entry + .as_str() + .or_else(|| { + entry + .as_object() + .and_then(|object| { + object + .get("id") + .or_else(|| object.get("resolvedId")) + }) + .and_then(Value::as_str) + }) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_owned) +} diff --git a/native/cli/src/commands/add/mutation/selectors.rs b/native/cli/src/commands/add/mutation/selectors.rs new file mode 100644 index 000000000000..3feab56c17a6 --- /dev/null +++ b/native/cli/src/commands/add/mutation/selectors.rs @@ -0,0 +1,175 @@ +//! Workspace state patterns ([`Facts`]) execute each loading criterion lazily, in published order. +//! +//! Coordinates [`code`], [`facts`], and [`grammar`] evaluation on [`ProjectedLinker`]. +pub(super) use facts::Facts; + +mod code; +mod facts; +mod grammar; + +use super::{ProjectedLinker, controls}; +use crate::commands::{ + diff::selection, engine::Engine, patterns, remove::preparation, workspace_command, +}; +use bit_install::output::Output; +use serde_json::{Value, json}; +use std::{io, path::Path}; + +impl ProjectedLinker { + /// Returns `true` when `pattern` contains a stateful component selector criterion. + #[inline] + pub(in crate::commands) fn state_pattern(pattern: &str) -> bool { + let trimmed = pattern.trim(); + !trimmed.is_empty() && selection::stateful(trimmed) + } + /// Prove native context eligibility before any configured provider starts. + #[inline] + pub(in crate::commands) fn preflight_selection(root: &Path, bitmap: &Value) -> io::Result<()> { + let mut engine = Engine::new()?; + let context: Value = engine.request(&json!({"kind":"trackingContext","root":root, + "home":workspace_command::home(),"bitmap":bitmap}))?; + controls::validate(&mut engine, &context) + } + + /// Evaluates `pattern` against workspace components, loading state predicates lazily in published order. + pub(in crate::commands) fn select_workspace( + &mut self, + root: &Path, + bitmap: &Value, + pattern: &str, + output: &Output, + ) -> io::Result> { + let pattern = pattern.trim(); + if !Self::state_pattern(pattern) { + return preparation::ids(root, pattern); + } + let context = self.selection_context(root, bitmap)?; + let request = Request { root, bitmap, context: &context, output }; + let targets = context["targets"] + .as_array() + .ok_or_else(|| io::Error::other("selector targets"))? + .iter() + .filter(|row| row["locallyRemoved"] != true) + .cloned() + .collect::>(); + let parts = patterns::split(pattern); + let mut selected = grammar::ordinary(&targets, &parts)?; + for branch in parts.iter().filter(|part| Self::state_pattern(part)) { + let mut pool = targets.clone(); + for criterion in branch + .split(" AND ") + .map(str::trim) + .filter(|criterion| !criterion.is_empty()) + { + pool = self.criterion(&request, pool, criterion)?; + } + grammar::union(&mut selected, pool); + } + if selected.is_empty() { + return Err(grammar::no_matches(pattern)); + } + Ok(grammar::identities(&selected)) + } + + fn selection_context(&mut self, root: &Path, bitmap: &Value) -> io::Result { + let mut engine = Engine::new()?; + let context: Value = engine.request(&json!({"kind":"trackingContext","root":root, + "home":workspace_command::home(),"bitmap":bitmap}))?; + controls::validate(&mut engine, &context)?; + self.retained.keep_controls( + context["controlReadSet"] + .as_array() + .map_or(&[], Vec::as_slice), + )?; + Ok(context) + } + + fn criterion( + &mut self, + request: &Request<'_>, + targets: Vec, + entered: &str, + ) -> io::Result> { + grammar::validate(entered)?; + let applied = grammar::applied(entered); + if applied == "$env:" { + return Err(io::Error::other("selector environment name is empty")); + } + let ids = grammar::identities(&targets); + if ids.is_empty() { + return Ok(Vec::new()); + } + let loads = grammar::loads(entered); + if loads { + self.load_criterion(request, &ids)?; + } + let compared = self.compare_criterion(request, entered, &ids)?; + let rows = if loads { self.retained.rows(&ids)? } else { Vec::new() }; + selection::criterion( + request.context, + targets, + &applied, + &rows, + &self.retained.facts.environments, + &compared, + ) + } + + fn load_criterion(&mut self, request: &Request<'_>, ids: &[String]) -> io::Result<()> { + request.output.status(&format!("loading {} component(s)", ids.len())); + let missing = self.retained.missing(ids); + if !missing.is_empty() { + let chalk = + crate::commands::color::Chalk::detect(&std::env::args_os().collect::>()); + crate::commands::delete::loading::validate( + request.root, + request.bitmap, + &missing, + chalk, + )?; + } + self.capture_cached(request.root, request.bitmap, ids)?; + print!("{}", self.take_stdout()); + Ok(()) + } + + fn compare_criterion( + &mut self, + request: &Request<'_>, + entered: &str, + ids: &[String], + ) -> io::Result { + let mut engine = Engine::new()?; + match entered.trim() { + "$modified" => self.compare_status(&mut engine, request.root, request.context, ids), + "$codeModified" => { + let (result, proofs) = + code::compare(&mut engine, request.root, request.bitmap, request.context, ids)?; + self.retained.add_proofs(proofs); + Ok(result) + } + _ => Ok(json!({"comparisons":[]})), + } + } + + #[inline] + pub(super) fn compare_status( + &mut self, + engine: &mut Engine, + root: &Path, + context: &Value, + ids: &[String], + ) -> io::Result { + facts::history(engine, root, context, ids)?; + let compared = self.retained.facts.compare(engine, root, ids)?; + self.retained.status(&compared, ids); + Ok(compared) + } +} + +struct Request<'a> { + root: &'a Path, + bitmap: &'a Value, + context: &'a Value, + output: &'a Output, +} diff --git a/native/cli/src/commands/add/mutation/selectors/code.rs b/native/cli/src/commands/add/mutation/selectors/code.rs new file mode 100644 index 000000000000..5150d3f74e85 --- /dev/null +++ b/native/cli/src/commands/add/mutation/selectors/code.rs @@ -0,0 +1,142 @@ +//! File-only predicates use native [`Engine`] source hashing without class or dependency-cache loads. +//! +//! Exposes [`compare`] to evaluate `$codeModified` against stored [`FIELDS`]. +use super::super::{controls, load}; +use crate::commands::engine::Engine; +use serde_json::{Map, Value, json}; +use std::{io, path::Path}; + +const FIELDS: [&str; 9] = [ + "mainFile", + "dependencies", + "devDependencies", + "packageDependencies", + "devPackageDependencies", + "peerPackageDependencies", + "bindingPrefix", + "overrides", + "extensions", +]; + +/// Compares workspace file hashes against stored model [`FIELDS`] to evaluate `$codeModified`. +pub(super) fn compare( + engine: &mut Engine, + root: &Path, + bitmap: &Value, + context: &Value, + ids: &[String], +) -> io::Result<(Value, Vec)> { + let (_, projection) = load::projected(root, bitmap, ids)?; + let mut known = std::collections::HashSet::with_capacity(ids.len()); + known.extend(ids.iter().map(|id| id.trim())); + let targets = projection.workspace.components + .iter() + .filter(|component| known.contains(component.id.to_string_without_version().as_str())) + .filter_map(|component| target(context, component).transpose()) + .collect::>>()?; + let before = source_proofs(root, &targets)?; + let result = engine.request(&json!({"kind":"statusCompare","root":root,"targets":targets}))?; + if source_proofs(root, &targets)? != before { + return Err(io::Error::other("state predicate sources changed")); + } + Ok((result, before)) +} +/// Builds a `statusCompare` target entry for `component` from stored model [`FIELDS`] in `context`. +#[inline] +fn target( + context: &Value, + component: &bit_install::model::WorkspaceComponent, +) -> io::Result> { + let Some(version) = component.id.version + .as_deref() + .map(str::trim) + .filter(|version| !version.is_empty()) + else { + return Ok(None); + }; + let id = component.id.to_string_without_version(); + let stored = stored(context, &id, version)?; + let current: Map = stored[1] + .as_object() + .ok_or_else(|| io::Error::other("code predicate fields"))? + .iter() + .filter(|(name, _)| FIELDS.contains(&name.trim())) + .map(|(key, value)| (key.trim().to_owned(), value.clone())) + .collect(); + Ok(Some( + json!({"identity":id,"version":version,"directory":component.root_dir,"files":component.files,"current":current}), + )) +} +#[inline] +fn stored<'a>(context: &'a Value, id: &str, version: &str) -> io::Result<&'a Value> { + let id = id.trim(); + let version = version.trim(); + let model = context["models"] + .as_array() + .into_iter() + .flatten() + .find(|model| { + let scope = model["scope"] + .as_str() + .map(str::trim) + .unwrap_or_default(); + let name = model["name"] + .as_str() + .map(str::trim) + .unwrap_or_default(); + if scope.is_empty() { + name == id + } else { + id.strip_prefix(scope) + .and_then(|rest| rest.strip_prefix('/')) + == Some(name) + } + }) + .ok_or_else(|| io::Error::other("code predicate model"))?; + let hash = if version.is_empty() || matches!(version, "latest" | "head") { + model["head"].as_str().map_or(version, str::trim) + } else { + model["versions"][version] + .as_str() + .or_else(|| model["orphanedVersions"][version].as_str()) + .map_or(version, str::trim) + }; + let stored = context["versions"] + .as_array() + .into_iter() + .flatten() + .find(|row| row[0].as_str().map(str::trim) == Some(hash)) + .ok_or_else(|| io::Error::other("code predicate version"))?; + Ok(stored) +} + +#[inline] +fn source_proofs(root: &Path, targets: &[Value]) -> io::Result> { + targets + .iter() + .flat_map(|row| { + let directory = root.join( + row["directory"] + .as_str() + .map(str::trim) + .unwrap_or_default(), + ); + row["files"] + .as_array() + .into_iter() + .flatten() + .filter_map(Value::as_str) + .map(str::trim) + .filter(|file| { + let path = Path::new(file); + !file.is_empty() + && !path.is_absolute() + && !path + .components() + .any(|part| matches!(part, std::path::Component::ParentDir)) + }) + .map(move |file| directory.join(file)) + }) + .map(|path| controls::refresh(&path)) + .collect() +} diff --git a/native/cli/src/commands/add/mutation/selectors/facts.rs b/native/cli/src/commands/add/mutation/selectors/facts.rs new file mode 100644 index 000000000000..4e60d26b16d4 --- /dev/null +++ b/native/cli/src/commands/add/mutation/selectors/facts.rs @@ -0,0 +1,116 @@ +//! Loaded-class [`Facts`] comparison inputs survive subsequent selector and mutation cache hits. +//! +//! Retains per-component comparison rows and environment mappings from [`Capture`]. +use crate::commands::{engine::Engine, status::capture::Capture}; +use serde_json::{Value, json}; +use std::{collections::HashMap, io, path::Path}; + +/// Retained per-component comparison rows and environment metadata across selector evaluations. +#[derive(Default)] +pub(in crate::commands::add::mutation) struct Facts { + comparisons: HashMap, + pub(super) environments: Value, +} +impl Facts { + /// Retains comparison rows and environment metadata from `capture` for subsequent selector checks. + pub(in crate::commands::add::mutation) fn keep(&mut self, capture: &Capture) { + for row in &capture.comparisons { + let Some(id) = row["identity"] + .as_str() + .map(str::trim) + .filter(|id| !id.is_empty()) + .or_else(|| { + row["id"] + .as_str() + .map(str::trim) + .filter(|id| !id.is_empty()) + }) + .map(|id| id.rsplit_once('@').map_or(id, |(base, _)| base.trim())) + .filter(|id| !id.is_empty()) + else { + continue; + }; + let mut row = row.clone(); + if let Some(fields) = row.as_object_mut() { + fields.remove("trackingVersion"); + } + self.comparisons.insert(id.into(), row); + } + let Some(facts) = capture.environment_facts.as_object() else { + return; + }; + if self.environments.is_null() { + self.environments = json!({}); + } + if let Some(map) = self.environments.as_object_mut() { + map.extend(facts.clone()); + } + } + #[inline] + pub(super) fn compare( + &self, + engine: &mut Engine, + root: &Path, + ids: &[String], + ) -> io::Result { + let targets = ids + .iter() + .map(|id| { + self.comparisons + .get(id.trim()) + .ok_or_else(|| io::Error::other("selector comparison target missing")) + }) + .collect::>>()?; + engine.request(&json!({"kind":"statusCompare","root":root,"targets":targets})) + } +} + +pub(super) fn history( + engine: &mut Engine, + root: &Path, + context: &Value, + ids: &[String], +) -> io::Result<()> { + let mut known = std::collections::HashSet::with_capacity(ids.len()); + known.extend(ids.iter().map(|id| id.trim())); + let targets = context["targets"] + .as_array() + .into_iter() + .flatten() + .filter(|row| { + row["identity"] + .as_str() + .map(str::trim) + .is_some_and(|id| known.contains(id)) + }) + .map(|row| json!({"identity":row["identity"],"version":row["version"]})) + .collect::>(); + let state: Value = engine.request(&json!({"kind":"statusModels","root":root,"history":true, + "stageHistory":true,"historyTargetsOnly":true,"includeHeads":true, + "currentLaneId":context["currentLaneId"],"targets":targets}))?; + let plans = state["pendingHistory"] + .as_array() + .ok_or_else(|| io::Error::other("selector history plans"))?; + commit_history(engine, root, plans) +} + +#[inline] +fn commit_history(engine: &mut Engine, root: &Path, plans: &[Value]) -> io::Result<()> { + if plans.is_empty() { + return Ok(()); + } + let checked: Value = + engine.request(&json!({"kind":"statusHistoryValidate","root":root,"plans":plans}))?; + if checked["fresh"] != true { + return Err(io::Error::other("selector history changed")); + } + let result: Value = + engine.request(&json!({"kind":"statusHistoryCommit","root":root,"plans":plans}))?; + if result["written"] + .as_u64() + .is_none_or(|written| written > plans.len() as u64) + { + return Err(io::Error::other("selector history receipt")); + } + Ok(()) +} diff --git a/native/cli/src/commands/add/mutation/selectors/grammar.rs b/native/cli/src/commands/add/mutation/selectors/grammar.rs new file mode 100644 index 000000000000..5014a9b96ab9 --- /dev/null +++ b/native/cli/src/commands/add/mutation/selectors/grammar.rs @@ -0,0 +1,128 @@ +//! Preserve `ScopeMain`'s ordinary-then-state union and ordered AND syntax via [`patterns`]. +//! +//! Validates [`STATES`] criteria and `$env:` multi-parameter state selectors via [`validate`]. +#[cfg(test)] +mod tests; + +use crate::commands::patterns; +use serde_json::Value; +use std::io; + +const STATES: [&str; 9] = [ + "new", + "modified", + "deprecated", + "deleted", + "internal", + "snappedOnMain", + "softTagged", + "codeModified", + "localOnly", +]; + +/// Validates a single `$state` or `$env:` selector criterion against supported [`STATES`]. +pub(super) fn validate(criterion: &str) -> io::Result<()> { + let Some(state) = criterion.trim().strip_prefix('$') else { return Ok(()) }; + if let Some((name, _)) = state.split_once(':') { + let name = name.trim(); + if name == "env" { + return Ok(()); + } + return Err(io::Error::other(format!( + "byMultiParamState expect the state to be one of the following: ['env'], got {name}", + ))); + } + if STATES.contains(&state) { + return Ok(()); + } + Err(io::Error::other(format!( + "state {state} is not recognized, possible values: {}", + STATES.join(", "), + ))) +} +/// Returns `true` when `criterion` requires loading full component classes (`$modified`, `$deprecated`, `$deleted`, or `$env:`). +#[inline] +pub(super) fn loads(criterion: &str) -> bool { + let criterion = criterion.trim(); + matches!(criterion, "$modified" | "$deprecated" | "$deleted") || criterion.starts_with("$env:") +} +#[inline] +pub(super) fn identities(targets: &[Value]) -> Vec { + targets + .iter() + .filter_map(|row| row_identity(row).map(str::to_owned)) + .collect() +} +pub(super) fn ordinary(targets: &[Value], parts: &[String]) -> io::Result> { + let parts = parts + .iter() + .filter(|part| !part.starts_with('$') && !part.contains(" AND ")) + .cloned() + .collect::>(); + let ids: Vec<&str> = targets + .iter() + .filter_map(row_identity) + .collect(); + let matched = patterns::extended::matching(&ids, &parts)?; + Ok(matched + .iter() + .filter_map(|id| { + targets + .iter() + .find(|row| row_identity(row) == Some(*id)) + }) + .cloned() + .collect()) +} +pub(super) fn union(selected: &mut Vec, targets: Vec) { + for row in targets { + let Some(row_id) = row_identity(&row) else { + continue; + }; + if !selected + .iter() + .any(|known| row_identity(known) == Some(row_id)) + { + selected.push(row); + } + } +} + +#[inline] +fn row_identity(row: &Value) -> Option<&str> { + row["identity"] + .as_str() + .map(str::trim) + .filter(|id| !id.is_empty()) + .or_else(|| { + row["id"] + .as_str() + .map(str::trim) + .filter(|id| !id.is_empty()) + }) + .map(|id| id.rsplit_once('@').map_or(id, |(base, _)| base.trim())) + .filter(|id| !id.is_empty()) +} +#[inline] +pub(super) fn no_matches(pattern: &str) -> io::Error { + io::Error::new( + io::ErrorKind::InvalidInput, + format!(r#"unable to find any component matching the "{pattern}" pattern"#), + ) +} + +#[inline] +pub(super) fn applied(entered: &str) -> String { + let entered = entered.trim(); + if let Some(env) = entered.strip_prefix("$env:") { + format!( + "$env:{}", + env.split(':') + .next() + .unwrap_or_default() + .trim(), + ) + } else { + entered.into() + } +} diff --git a/native/cli/src/commands/add/mutation/selectors/grammar/tests.rs b/native/cli/src/commands/add/mutation/selectors/grammar/tests.rs new file mode 100644 index 000000000000..1c3a9a18e5f8 --- /dev/null +++ b/native/cli/src/commands/add/mutation/selectors/grammar/tests.rs @@ -0,0 +1,38 @@ +//! Unit tests for selector state grammar [`validate`], [`ordinary`], and [`union`] ordering. +//! +//! Verifies [`applied`] `$env` parameter normalization and [`identities`] deduplication order. + +use super::{applied, identities, ordinary, union, validate}; +use serde_json::json; + +/// Verifies that ordinary pattern matches precede state selector results and [`union`] preserves first occurrence. +#[test] +fn ordinary_matches_precede_state_results_and_union_keeps_first_occurrence() { + let pool = vec![json!({"identity":"scope/a"}), json!({"identity":"scope/b"})]; + let parts = crate::commands::patterns::split("$new,**/b"); + let mut selected = ordinary(&pool, &parts).unwrap(); + union(&mut selected, pool.clone()); + union(&mut selected, pool); + assert_eq!(identities(&selected), ["scope/b", "scope/a"]); +} + +/// Verifies that `$env` uses only its first parameter and unrecognized state selectors fail with canonical errors. +#[test] +fn env_uses_only_the_first_parameter_and_invalid_state_errors_are_canonical() { + assert_eq!(applied("$env:scope/env:ignored"), "$env:scope/env"); + assert_eq!( + validate("$invalid").unwrap_err().to_string(), + "state invalid is not recognized, possible values: new, modified, deprecated, deleted, internal, snappedOnMain, softTagged, codeModified, localOnly", + ); + assert_eq!( + validate("$other:value").unwrap_err().to_string(), + "byMultiParamState expect the state to be one of the following: ['env'], got other", + ); +} + +#[test] +fn valid_single_and_env_state_selectors_pass_validation() { + for selector in ["$new", "$modified", "$deprecated", "$env:scope/env"] { + assert!(validate(selector).is_ok(), "{selector}"); + } +} diff --git a/native/cli/src/commands/add/options.rs b/native/cli/src/commands/add/options.rs new file mode 100644 index 000000000000..240378a28c60 --- /dev/null +++ b/native/cli/src/commands/add/options.rs @@ -0,0 +1,117 @@ +//! Published `AddCmd` [`Options`] flags, preserving yargs string-valued override semantics. +//! +//! Parses CLI arguments into [`Tracking`] options, environment overrides, and `--json` mode via [`parse`]. +use bit_object_import::component_tracking::Options as Tracking; +use serde_json::json; +/// Parsed `bit add` CLI options, including [`Tracking`] parameters, `--env`, and `--json`. +pub(super) struct Options { + pub tracking: Tracking, + pub env: Option, + pub json: bool, +} +/// Parses `args` into [`Options`], returning `None` when any flag or value falls outside native grammar. +pub(super) fn parse(args: &[&str]) -> Option { + let mut options = Options { + tracking: Tracking { config: json!({}), ..Default::default() }, + env: None, + json: false, + }; + let mut args = args.iter(); + while let Some(arg) = args.next() { + if *arg == "--" { + for item in args { + is_valid_value(item).then_some(())?; + options.tracking.paths.push((*item).to_owned()); + } + break; + } + apply_arg(&mut options, arg, &mut args)?; + } + Some(options) +} + +fn apply_arg( + options: &mut Options, + arg: &str, + args: &mut std::slice::Iter<'_, &str>, +) -> Option<()> { + if consume_value(options, arg, args)? { + return Some(()); + } + match arg { + "--no-color" | "--no-color=true" | "--no-color=false" => {} + "-o" | "--override" if !options.tracking.override_files => { + let _ = value(args)?; + options.tracking.override_files = true; + } + "--root" | "--root=true" => options.tracking.workspace_root = true, + "--root=false" => options.tracking.workspace_root = false, + "-j" | "--json" | "--json=true" => options.json = true, + "--json=false" => options.json = false, + path if is_valid_value(path) => { + options.tracking.paths.push(path.to_owned()); + } + _ => return None, + } + Some(()) +} + +fn consume_value( + options: &mut Options, + arg: &str, + args: &mut std::slice::Iter<'_, &str>, +) -> Option { + if let Some(handled) = consume_inline_value(options, arg) { + return handled; + } + let Some(slot) = string_option(options, arg) else { + return Some(false); + }; + if slot.is_some() { + return None; + } + *slot = Some(value(args)?); + Some(true) +} + +fn consume_inline_value(options: &mut Options, arg: &str) -> Option> { + if let Some(val) = arg + .strip_prefix("--override=") + .or_else(|| arg.strip_prefix("-o=")) + { + if options.tracking.override_files || !is_valid_value(val) { + return Some(None); + } + options.tracking.override_files = true; + return Some(Some(true)); + } + let (flag, inline) = arg.split_once('=')?; + let slot = string_option(options, flag)?; + if slot.is_some() || !is_valid_value(inline) { + return Some(None); + } + *slot = Some(inline.to_owned()); + Some(Some(true)) +} +#[inline] +fn string_option<'a>(options: &'a mut Options, arg: &str) -> Option<&'a mut Option> { + match arg { + "-i" | "--id" => Some(&mut options.tracking.id), + "-m" | "--main" => Some(&mut options.tracking.main), + "-n" | "--namespace" => Some(&mut options.tracking.namespace), + "-s" | "--scope" => Some(&mut options.tracking.scope), + "-e" | "--env" => Some(&mut options.env), + _ => None, + } +} +#[inline] +fn value(args: &mut std::slice::Iter<'_, &str>) -> Option { + args.next() + .copied() + .filter(|arg| is_valid_value(arg)) + .map(str::to_owned) +} +#[inline] +fn is_valid_value(val: &str) -> bool { + !val.trim().is_empty() && !val.starts_with('-') +} diff --git a/native/cli/src/commands/add/output.rs b/native/cli/src/commands/add/output.rs new file mode 100644 index 000000000000..291c8211518f --- /dev/null +++ b/native/cli/src/commands/add/output.rs @@ -0,0 +1,63 @@ +//! Handler-specific [`Outcome`] error rendering without restarting a command after provider effects. +//! +//! Formats [`failure`] responses in text and `--json` modes with [`colors`] and [`Startup`] hooks. +mod colors; + +use crate::{ + commands::{color::Chalk, status::autosync::Startup, workspace_command}, + native::Outcome, +}; +use std::{ffi::OsString, io, path::Path}; +/// Renders a `bit add` failure [`Outcome`] after provider effects in text or `--json` mode. +pub(super) fn failure( + root: &Path, + args: &[OsString], + json: bool, + startup: &Startup, + error: io::Error, +) -> io::Result { + let error = colors::error(error, Chalk::detect(args)); + if root + .as_os_str() + .as_encoded_bytes() + .iter() + .all(u8::is_ascii_whitespace) + { + return Err(error); + } + if !json { + return workspace_command::checkout_lifecycle::failure(root, args, "add", startup, error); + } + workspace_command::checkout_lifecycle::before(startup)?; + let callback = downcast_callback(&error); + if let Some(callback) = callback + && !callback.startup.is_empty() + { + print!("{}", callback.startup); + } + let captured = callback.map_or_else(String::new, |callback| callback.output.clone()); + workspace_command::execute_json(root, args, "add", |_| { + workspace_command::checkout_lifecycle::loaded(startup); + print!("{captured}"); + Err(error) + }) +} + +/// Walks the error source chain of `error` to locate an embedded [`crate::host::callback_error::CallbackError`]. +#[inline] +fn downcast_callback(error: &io::Error) -> Option<&crate::host::callback_error::CallbackError> { + std::iter::successors( + error + .get_ref() + .map(|inner| inner as &(dyn std::error::Error + 'static)), + |inner| (*inner).source(), + ) + .take(32) + .find_map(|inner| inner.downcast_ref::()) +} + +/// Released filesystem error formatting nests red within the command handler's red. +#[inline] +pub(super) fn link_error(error: io::Error, chalk: Chalk) -> io::Error { + colors::native_io(error, chalk) +} diff --git a/native/cli/src/commands/add/output/colors.rs b/native/cli/src/commands/add/output/colors.rs new file mode 100644 index 000000000000..11febfea0180 --- /dev/null +++ b/native/cli/src/commands/add/output/colors.rs @@ -0,0 +1,91 @@ +//! Inner [`Chalk`] colors of the released tracking exceptions, before the CLI's outer red error. +//! +//! Exposes [`error`] and [`native_io`] to style invalid-input and filesystem diagnostics. +#[cfg(test)] +mod tests; + +use crate::commands::color::Chalk; +use std::{error::Error, fmt, io}; + +const NO_FILES: &str = "warning: no files to add"; +const IGNORED: &str = ", the following files were ignored: "; + +/// Applies inner [`Chalk`] styling to `InvalidInput` tracking errors when colors are enabled. +pub(super) fn error(error: io::Error, chalk: Chalk) -> io::Error { + if !chalk.enabled() || error.kind() != io::ErrorKind::InvalidInput { + return error; + } + let message = error.to_string(); + let Some(message) = message_color(&message, chalk) else { + return error; + }; + io::Error::new(error.kind(), Colored { message, source: error }) +} +/// Wraps native filesystem IO errors (`ENOTDIR`, `EISDIR`, `EPERM`) in inner red [`Chalk`] styling. +pub(super) fn native_io(error: io::Error, chalk: Chalk) -> io::Error { + if !chalk.enabled() || has_colored_or_callback(&error) { + return error; + } + let message = error.to_string(); + if !filesystem_message(&message) { + return error; + } + io::Error::new(error.kind(), Colored { message: chalk.red(&message), source: error }) +} +#[inline] +fn has_colored_or_callback(error: &io::Error) -> bool { + std::iter::successors( + error + .get_ref() + .map(|inner| inner as &(dyn Error + 'static)), + |inner| (*inner).source(), + ) + .take(32) + .any(|inner| inner.is::() || inner.is::()) +} +#[inline] +fn filesystem_message(message: &str) -> bool { + let trimmed = message.trim_start(); + trimmed.starts_with("ENOTDIR: not a directory, mkdir '") + || trimmed.starts_with("EISDIR: illegal operation on a directory, rename '") + || trimmed.starts_with("EPERM: operation not permitted,") +} +fn message_color(message: &str, chalk: Chalk) -> Option { + let message = message.trim(); + if message == NO_FILES { + return Some(chalk.yellow(NO_FILES)); + } + if let Some(files) = message + .strip_prefix(NO_FILES) + .and_then(|suffix| suffix.strip_prefix(IGNORED)) + .map(str::trim) + .filter(|files| !files.is_empty()) + { + return Some(format!( + "{}{}", + chalk.yellow(NO_FILES), + chalk.yellow(&format!("{IGNORED}{}", chalk.bold(files))), + )); + } + if message.starts_with(r#"directory ""#) && message.ends_with(r#"" is empty, no files to add"#) + { + return Some(chalk.yellow(message)); + } + None +} + +#[derive(Debug)] +struct Colored { + message: String, + source: io::Error, +} +impl fmt::Display for Colored { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.message) + } +} +impl Error for Colored { + fn source(&self) -> Option<&(dyn Error + 'static)> { + Some(&self.source) + } +} diff --git a/native/cli/src/commands/add/output/colors/tests.rs b/native/cli/src/commands/add/output/colors/tests.rs new file mode 100644 index 000000000000..116894a87ac9 --- /dev/null +++ b/native/cli/src/commands/add/output/colors/tests.rs @@ -0,0 +1,110 @@ +//! Unit tests for ANSI color formatting of `add` warnings and errors via [`error`] and [`native_io`]. +//! +//! Verifies [`Chalk`] styling of ignored-file warnings and [`CallbackError`] preservation. + +use super::{Chalk, error, native_io}; +use crate::host::callback_error::CallbackError; +use std::io; + +/// Verifies that ignored-file warnings keep separate yellow segments and bold filenames inside red [`Chalk`] wrapping. +#[test] +fn ignored_files_keep_separate_yellow_sections_and_bold_names_inside_red() { + let chalk = Chalk::new(true); + let message = "warning: no files to add, the following files were ignored: bar/a.js, bar/b.js"; + let colored = error(io::Error::new(io::ErrorKind::InvalidInput, message), chalk); + assert_eq!(colored.kind(), io::ErrorKind::InvalidInput); + assert_eq!( + chalk.red(&colored.to_string()), + "\x1b[31m\x1b[33mwarning: no files to add\x1b[39m\x1b[31m\x1b[33m, the following files were ignored: \x1b[1mbar/a.js, bar/b.js\x1b[22m\x1b[39m\x1b[31m\x1b[39m", + ); + assert_eq!( + colored + .get_ref() + .unwrap() + .source() + .unwrap() + .to_string(), + message, + ); +} + +/// Verifies that an empty-directory warning preserves yellow styling inside the outer red [`Chalk`] wrapper. +#[test] +fn empty_directory_keeps_yellow_inside_the_outer_red() { + let chalk = Chalk::new(true); + let colored = error( + io::Error::new( + io::ErrorKind::InvalidInput, + r#"directory "/tmp/empty" is empty, no files to add"#, + ), + chalk, + ); + assert_eq!( + chalk.red(&colored.to_string()), + "\x1b[31m\x1b[33mdirectory \"/tmp/empty\" is empty, no files to add\x1b[39m\x1b[31m\x1b[39m", + ); +} + +#[test] +fn callback_errors_retain_their_type_and_both_output_phases() { + let callback = CallbackError::wrap( + io::Error::new(io::ErrorKind::InvalidInput, "warning: no files to add"), + "startup".to_owned(), + "loaded".to_owned(), + ); + let colored = error(callback, Chalk::new(true)); + assert_eq!(colored.kind(), io::ErrorKind::Other); + let callback = colored + .get_ref() + .unwrap() + .downcast_ref::() + .unwrap(); + assert_eq!(callback.startup, "startup"); + assert_eq!(callback.output, "loaded"); + assert_eq!(colored.to_string(), "warning: no files to add"); +} + +#[test] +fn native_filesystem_errors_keep_the_released_double_red_style() { + let chalk = Chalk::new(true); + let message = "ENOTDIR: not a directory, mkdir '/tmp/workspace/node_modules/@scope/component'"; + let colored = native_io(io::Error::other(message), chalk); + assert_eq!(colored.kind(), io::ErrorKind::Other); + assert_eq!( + chalk.red(&colored.to_string()), + "\x1b[31m\x1b[31mENOTDIR: not a directory, mkdir '/tmp/workspace/node_modules/@scope/component'\x1b[39m\x1b[31m\x1b[39m", + ); + assert_eq!( + colored + .get_ref() + .unwrap() + .source() + .unwrap() + .to_string(), + message, + ); +} + +#[test] +fn callback_filesystem_messages_keep_the_callback_error_type() { + let message = "EPERM: operation not permitted, callback fixture"; + let callback = + CallbackError::wrap(io::Error::other(message), "startup".to_owned(), "loaded".to_owned()); + let colored = native_io(callback, Chalk::new(true)); + let callback = colored + .get_ref() + .unwrap() + .downcast_ref::() + .unwrap(); + assert_eq!(callback.startup, "startup"); + assert_eq!(callback.output, "loaded"); + assert_eq!(colored.to_string(), message); +} + +#[test] +fn uncolored_chalk_leaves_warning_text_without_ansi_escapes() { + let chalk = Chalk::new(false); + let message = "warning: no files to add, the following files were ignored: a.js"; + let colored = error(io::Error::new(io::ErrorKind::InvalidInput, message), chalk); + assert_eq!(colored.to_string(), message); +} diff --git a/native/cli/src/commands/add/preparation.rs b/native/cli/src/commands/add/preparation.rs new file mode 100644 index 000000000000..21aca7b8377b --- /dev/null +++ b/native/cli/src/commands/add/preparation.rs @@ -0,0 +1,162 @@ +//! Pure [`Prepared`] eligibility precedes provider [`Startup`] and authoritative projected loading. +//! +//! Runs `trackingContext` and [`Plan`] preparation via [`prepare`] before invoking Node providers. +use super::{Prepared, diagnostics, docs, options, projection}; +use crate::commands::{ + engine::Engine, + status::{ + autosync::Startup, + capture::{self, CaptureOptions}, + environments::Environments, + }, + workspace_command, +}; +use bit_object_import::component_tracking::{Options as Tracking, Plan}; +use serde_json::{Value, json}; +use std::{io, path::Path}; + +/// Prepares a [`Prepared`] `bit add` plan and captures projected component metadata before mutations. +pub(super) fn prepare( + root: &Path, + cwd: &Path, + options: &options::Options, + startup: &mut Startup, +) -> io::Result { + let mut engine = Engine::new()?; + let home = workspace_command::home(); + let (context, preflight) = context(&mut engine, root, cwd, &home, options)?; + if diagnostics::ci() + && options.env + .as_deref() + .is_some_and(|env| !env.trim().is_empty()) + { + let probe = + projection::prepare(root, preflight.bitmap(), preflight.components(), context.clone())?; + diagnostics::links(root, &probe)?; + } + let mut environments = Environments::for_tracking(); + let tracking = environment_options(root, options, &mut environments, startup)?; + let plan = Plan::prepare( + root, + cwd, + &tracking, + &json!({"workspace":context["workspace"],"models":context["models"]}), + )?; + let projection = projection::prepare(root, plan.bitmap(), plan.components(), context)?; + if !startup.committed { + diagnostics::links(root, &projection)?; + } + let capture = load(&mut engine, root, &home, &plan, &projection, environments, startup)?; + Ok(Prepared { plan, projection, capture, engine }) +} + +/// Resolves effective workspace configuration, preflight [`Plan`], and `trackingContext` from `engine`. +#[inline] +fn context( + engine: &mut Engine, + root: &Path, + cwd: &Path, + home: &Path, + options: &options::Options, +) -> io::Result<(Value, Plan)> { + let workspace = bit_object_import::effective_workspace_configuration(root, home)?; + let preflight = Plan::prepare(root, cwd, &options.tracking, &json!({"workspace":workspace}))?; + let context = engine.request( + &json!({"kind":"trackingContext","root":root,"home":home,"bitmap":preflight.bitmap()}), + )?; + Ok((context, preflight)) +} + +fn environment_options( + root: &Path, + options: &options::Options, + environments: &mut Environments, + startup: &mut Startup, +) -> io::Result { + let mut tracking = options.tracking.clone(); + if let Some(env) = options.env + .as_deref() + .map(str::trim) + .filter(|env| !env.is_empty()) + { + startup.committed = true; + let facts = environments.tracking_env_config(root, env)?; + tracking.config = build_env_tracking_config(&facts)?; + } + Ok(tracking) +} + +#[inline] +fn build_env_tracking_config(facts: &Value) -> io::Result { + let configured = extract_env_id(facts, "configuredId") + .filter(|id| !id.chars().any(char::is_control)) + .ok_or_else(|| io::Error::other("tracking environment identity"))?; + let resolved = extract_env_id(facts, "resolvedId") + .filter(|id| !id.chars().any(char::is_control)) + .unwrap_or(configured); + Ok(json!({(configured):{},"teambit.envs/envs":{"env":resolved}})) +} + +#[inline] +fn extract_env_id<'a>(facts: &'a Value, key: &str) -> Option<&'a str> { + let value = facts.get(key)?; + value + .as_str() + .map(str::trim) + .filter(|text| !text.is_empty()) + .or_else(|| { + value + .get("id") + .and_then(Value::as_str) + .map(str::trim) + .filter(|text| !text.is_empty()) + }) + .or_else(|| { + value + .get("resolvedId") + .and_then(Value::as_str) + .map(str::trim) + .filter(|text| !text.is_empty()) + }) +} + +fn load( + engine: &mut Engine, + root: &Path, + home: &Path, + plan: &Plan, + projection: &projection::Projection, + mut environments: Environments, + startup: &mut Startup, +) -> io::Result> { + if plan.components().is_empty() { + return Ok(None); + } + startup.committed = true; + startup.stdout.push_str(&environments.project_tracking( + root, + plan.bitmap(), + &projection.components, + )?); + if projection.load_error + .as_deref() + .is_some_and(|err| !err.trim().is_empty()) + { + return Ok(None); + } + docs::load(root, &mut environments, &projection.loading_components())?; + let capture = capture::capture_with( + engine, + root, + home, + &projection.context, + &projection.selected, + CaptureOptions { + environments: Some(environments.for_checkout()), + allow_special_states: true, + ..Default::default() + }, + )?; + workspace_command::checkout_lifecycle::retain(Some(startup), &capture); + Ok(Some(capture)) +} diff --git a/native/cli/src/commands/add/projection.rs b/native/cli/src/commands/add/projection.rs new file mode 100644 index 000000000000..f2643a78c6e3 --- /dev/null +++ b/native/cli/src/commands/add/projection.rs @@ -0,0 +1,211 @@ +//! Project the retained add `.bitmap` ([`Projection`]) into the existing native [`Workspace`] component loader. +//! +//! Exposes [`prepare`] to construct [`WorkspaceComponent`] rows from staged `.bitmap` updates. +use bit_install::{config::WorkspaceConfig, model::WorkspaceComponent, workspace::Workspace}; +use serde_json::{Value, json}; +use std::{io, path::Path}; + +/// Projected [`Workspace`] state and selected component entries for a staged `.bitmap`. +pub(super) struct Projection { + pub workspace: Workspace, + pub context: Value, + pub selected: Vec, + pub components: Vec, + pub load_error: Option, +} + +impl Projection { + /// Returns the projected component entries corresponding to `self.selected`. + #[inline] + pub(super) fn loading_components(&self) -> Vec { + self.selected + .iter() + .filter_map(|target| { + let target_id = row_identity(target)?; + self.components + .iter() + .find(|component| row_identity(component) == Some(target_id)) + .cloned() + }) + .collect() + } +} + +pub(super) fn prepare( + root: &Path, + bitmap: &Value, + added: &[Value], + context: Value, +) -> io::Result { + let config = + WorkspaceConfig::from_value(root.join("workspace.jsonc"), context["workspace"].clone())?; + let core: Vec = serde_json::from_str(include_str!( + "../../../../../scopes/harmony/testing/load-aspect/core-aspects-ids.json" + )) + .map_err(io::Error::other)?; + let workspace = Workspace::load_projected(root, &config, &core, bitmap)?; + prepare_loaded(root, bitmap, added, context, workspace) +} + +pub(super) fn prepare_loaded( + root: &Path, + bitmap: &Value, + added: &[Value], + mut context: Value, + workspace: Workspace, +) -> io::Result { + let previous = context["targets"] + .as_array() + .cloned() + .unwrap_or_default(); + let mut targets = workspace.components + .iter() + .map(|component| target(component, bitmap, added, &previous)) + .collect::>(); + let load_error = controls(root, &mut targets, added)?; + let selected = select(added, &targets); + let components = components(&workspace, added); + context["targets"] = json!(targets); + Ok(Projection { workspace, context, selected, components, load_error }) +} + +fn target( + component: &WorkspaceComponent, + bitmap: &Value, + added: &[Value], + previous: &[Value], +) -> Value { + let identity = component.id.to_string_without_version(); + let mut target = previous + .iter() + .find(|target| row_identity(target) == Some(identity.as_str())) + .cloned() + .unwrap_or_else(|| json!({"config":null,"packageJson":null})); + target["identity"] = json!(identity); + target["directory"] = json!(component.root_dir); + target["version"] = json!(component.id.version.as_deref().unwrap_or_default()); + tracking_version(&mut target, added); + target["mainFile"] = json!(component.main_file); + target["scoped"] = json!(component.id.scope.is_some()); + target["localOnly"] = json!(false); + target["softTagged"] = json!(false); + target["locallyRemoved"] = json!(false); + target["componentConfig"] = bitmap + .as_object() + .and_then(|entries| { + entries + .values() + .find(|entry| { + entry["rootDir"].as_str().map(str::trim) == Some(component.root_dir.trim()) + }) + }) + .and_then(|entry| entry.get("config")) + .cloned() + .unwrap_or(Value::Null); + target +} + +fn controls(root: &Path, targets: &mut [Value], added: &[Value]) -> io::Result> { + let mut error = None; + for target in targets { + let directory = target["directory"] + .as_str() + .map(str::trim) + .filter(|dir| !dir.is_empty()) + .ok_or_else(|| io::Error::other("tracking target directory"))?; + let component_dir = root.join(directory); + let config = optional_json(&component_dir.join("component.json"))?; + if malformed_selected(&config, target, added) { + error = Some("Cannot read properties of undefined (reading 'scope')".to_owned()); + } + target["config"] = component_config(config); + target["packageJson"] = optional_json(&component_dir.join("package.json"))?; + } + Ok(error) +} +#[inline] +fn malformed_selected(config: &Value, target: &Value, added: &[Value]) -> bool { + let target_id = row_identity(target); + !config.is_null() + && config.get("componentId").is_none() + && added + .iter() + .any(|row| { + row_identity(row) == target_id + && (row["rootDir"].is_string() || row["directory"].is_string()) + }) +} +fn select(added: &[Value], targets: &[Value]) -> Vec { + added + .iter() + .filter(|row| { + (row["rootDir"].is_string() || row["directory"].is_string()) + && row["files"] + .as_array() + .is_some_and(|files| !files.is_empty()) + }) + .filter_map(|row| { + let row_id = row_identity(row)?; + targets + .iter() + .find(|target| row_identity(target) == Some(row_id)) + .cloned() + }) + .collect() +} +fn components(workspace: &Workspace, added: &[Value]) -> Vec { + workspace.components.iter().map(|component|{ + let mut row=json!({"identity":component.id.to_string_without_version(),"directory":component.root_dir,"mainFile":component.main_file,"files":component.files.iter().map(|file|json!({"relativePath":file,"name":file.rsplit('/').next().unwrap_or(file),"test":false})).collect::>()}); + tracking_version(&mut row,added); + row + }).collect() +} +#[inline] +fn tracking_version(row: &mut Value, added: &[Value]) { + let Some(load_version) = row_identity(row) + .and_then(|row_id| { + added + .iter() + .find(|added| row_identity(added) == Some(row_id)) + .map(|added| added["loadVersion"].clone()) + }) + else { + return; + }; + row["trackingVersion"] = load_version; +} +#[inline] +fn row_identity(row: &Value) -> Option<&str> { + row["identity"] + .as_str() + .map(str::trim) + .filter(|id| !id.is_empty()) + .or_else(|| { + row["id"] + .as_str() + .map(str::trim) + .filter(|id| !id.is_empty()) + }) + .map(|id| id.rsplit_once('@').map_or(id, |(base, _)| base.trim())) + .filter(|id| !id.is_empty()) +} +#[inline] +fn optional_json(path: &Path) -> io::Result { + match std::fs::read(path) { + Ok(bytes) => bit_object_import::parse_jsonc(&bytes), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(Value::Null), + Err(error) => Err(error), + } +} +#[inline] +fn component_config(value: Value) -> Value { + if value.is_null() { + return value; + } + let mut result = + json!({"extensions":value.get("extensions").cloned().unwrap_or_else(||json!({}))}); + if let Some(propagate) = value.get("propagate") { + result["propagate"] = propagate.clone(); + } + result +} diff --git a/native/cli/src/commands/add/remote.rs b/native/cli/src/commands/add/remote.rs new file mode 100644 index 000000000000..2a4100bc187e --- /dev/null +++ b/native/cli/src/commands/add/remote.rs @@ -0,0 +1,104 @@ +//! Best-effort remote [`Host`] collision facts after the tracked [`Projection`] `.bitmap` is persisted. +//! +//! Emits [`warn`] diagnostics when newly tracked components collide with remote scope IDs. +use super::projection::Projection; +use crate::{ + commands::{color::Chalk, workspace_command::format::Formatter}, + host::Host, +}; +use serde_json::{Value, json}; +/// Emits best-effort remote component collision warnings via `host` after `.bitmap` publication. +pub(super) fn warn( + host: &Host, + added: &[Value], + projection: &Projection, + chalk: Chalk, + output: &bit_install::output::Output, +) { + let ids = candidates(added, projection); + if ids.is_empty() { + return; + } + let Ok(ids) = host.call("trackingRemoteExistence", &json!({"ids":ids})) else { + return; + }; + let Some(ids) = ids + .as_array() + .filter(|ids| !ids.is_empty()) + else { + return; + }; + let rendered = ids + .iter() + .filter_map(remote_id_str) + .collect::>(); + let Some(example) = rendered.first() else { + return; + }; + let name = example.split_once('/').map_or(example.as_str(), |(_, name)| name); + let formatter = Formatter::new(chalk); + let rows = rendered + .iter() + .map(|id| formatter.item(id)) + .collect::>() + .join("\n"); + let text = format!( + "the following newly added component(s) already exist on the remote scope with the same id:\n{rows}\nif you meant to work on the existing component, remove your local one and run \"bit import {example}\".\nif this is a new, unrelated component, rename yours to avoid the clash, e.g. \"bit rename {name} \" (or \"bit rename {name} {name} --scope \" to change only the scope).", + ); + output.warning(&text); +} + +/// Formats a remote component identifier from `value` as `/` or ``. +#[inline] +fn remote_id_str(value: &Value) -> Option { + if let Some(id) = non_empty_str(value) + .or_else(|| non_empty_str(&value["id"])) + .or_else(|| non_empty_str(&value["identity"])) + { + return Some(id.to_owned()); + } + let name = non_empty_str(&value["name"]).or_else(|| non_empty_str(&value["id"]["name"]))?; + Some( + non_empty_str(&value["scope"]) + .or_else(|| non_empty_str(&value["id"]["scope"])) + .filter(|scope| !scope.contains('/')) + .map_or_else(|| name.to_owned(), |scope| format!("{scope}/{name}")), + ) +} + +#[inline] +fn non_empty_str(value: &Value) -> Option<&str> { + value + .as_str() + .map(str::trim) + .filter(|text| !text.is_empty()) +} + +fn candidates(added: &[Value], projection: &Projection) -> Vec { + let unscoped: std::collections::HashSet = projection.workspace + .components + .iter() + .filter(|component| component.id.scope.is_none()) + .map(|component| { + component.id + .to_string_without_version() + .trim() + .to_owned() + }) + .collect(); + let mut seen = std::collections::HashSet::new(); + added + .iter() + .filter_map(|row| { + let identity = row["identity"] + .as_str() + .or_else(|| row["id"].as_str()) + .map(str::trim) + .map(|id| id.rsplit_once('@').map_or(id, |(base, _)| base.trim())) + .filter(|id| !id.is_empty())?; + (unscoped.contains(identity) && seen.insert(identity)).then(|| { + Value::String(identity.to_owned()) + }) + }) + .collect() +} diff --git a/native/cli/src/commands/add/report.rs b/native/cli/src/commands/add/report.rs new file mode 100644 index 000000000000..86a2e5a50d3f --- /dev/null +++ b/native/cli/src/commands/add/report.rs @@ -0,0 +1,152 @@ +//! Published `AddCmd` [`json`] and [`Formatter`] report with exact warning and section order. +//! +//! Renders [`text`] and [`json`] summaries for tracked components and ignored-file warnings. +use crate::commands::{color::Chalk, workspace_command::format::Formatter}; +use serde_json::{Value, json}; +use std::io; +/// Formats the `--json` payload for `bit add` from `components` and `warnings`. +#[inline] +pub(super) fn json(components: &[Value], warnings: &Value) -> Value { + json!({"addedComponents":components.iter().map(|row|json!({"id":row["id"],"files":row["files"]})).collect::>(),"warnings":warnings}) +} +/// Formats human-readable `bit add` terminal output from `components` and `warnings`. +pub(super) fn text(components: &[Value], warnings: &Value, chalk: Chalk) -> io::Result { + let formatter = Formatter::new(chalk); + let warning = warning(warnings, &formatter)?; + if components.len() > 1 { + return Ok(format!( + "{warning}{}", + formatter.success_summary(&format!("tracking {} new components", components.len())), + )); + } + let mut sections = vec![warning]; + for component in components { + sections.push(component_text(component, &formatter)?); + } + Ok(sections + .into_iter() + .filter(|text| !text.is_empty()) + .collect::>() + .join("\n\n")) +} +fn component_text(component: &Value, formatter: &Formatter) -> io::Result { + let id = component_id(component)?; + let files = component["files"] + .as_array() + .ok_or_else(|| io::Error::other("tracking component files"))?; + if files.is_empty() { + return Ok(format!( + "{} could not track component {}: no files to track", + formatter.error_symbol(), + formatter.chalk.bold(&id), + )); + } + let mut lines = Vec::with_capacity(files.len() + 1); + lines.push(formatter.title(&format!("tracking component {}", formatter.chalk.bold(&id)))); + for file in files { + let file = file + .as_str() + .map(str::trim) + .filter(|file| !file.is_empty()) + .or_else(|| { + file["relativePath"] + .as_str() + .map(str::trim) + .filter(|file| !file.is_empty()) + }) + .or_else(|| { + file["path"] + .as_str() + .map(str::trim) + .filter(|file| !file.is_empty()) + }) + .ok_or_else(|| io::Error::other("tracking file path"))?; + lines.push(formatter.item(&format!("added {file}"))); + } + Ok(lines.join("\n")) +} + +#[inline] +fn component_id(component: &Value) -> io::Result { + if let Some(id) = component["id"] + .as_str() + .map(str::trim) + .filter(|id| !id.is_empty()) + .or_else(|| { + component["identity"] + .as_str() + .map(str::trim) + .filter(|id| !id.is_empty()) + }) + { + return Ok(id.to_owned()); + } + let name = component["id"]["name"] + .as_str() + .map(str::trim) + .filter(|name| !name.is_empty()) + .ok_or_else(|| io::Error::other("tracking component identity"))?; + Ok(component["id"]["scope"] + .as_str() + .map(str::trim) + .filter(|scope| !scope.is_empty()) + .map_or_else(|| name.to_owned(), |scope| format!("{scope}/{name}"))) +} +fn warning(value: &Value, format: &Formatter) -> io::Result { + let mut warning = already_used(value, format)?; + if let Some(empty) = value["emptyDirectory"] + .as_array() + .filter(|rows| !rows.is_empty()) + { + warning.push_str(&format.title(&format!( + "{} empty or excluded directories", + format.warn_symbol(), + ))); + for directory in empty { + let directory = directory + .as_str() + .map(str::trim) + .filter(|dir| !dir.is_empty()) + .ok_or_else(|| io::Error::other("tracking empty directory"))?; + warning.push('\n'); + warning.push_str(&Formatter::item_with( + &format.chalk.bold(directory), + &format.warn_symbol(), + )); + } + warning.push('\n'); + } + Ok(warning) +} + +fn already_used(value: &Value, format: &Formatter) -> io::Result { + let mut result = Vec::new(); + let Some(already) = value["alreadyUsed"].as_object() else { + return Ok(String::new()); + }; + for (id, files) in already { + let id = id.trim(); + if id.is_empty() { + return Err(io::Error::other("tracking used component")); + } + let files = files + .as_array() + .ok_or_else(|| io::Error::other("tracking used files"))? + .iter() + .map(|file| { + file.as_str() + .map(str::trim) + .filter(|file| !file.is_empty()) + .ok_or_else(|| io::Error::other("tracking used file")) + }) + .collect::>>()?; + result.push(Formatter::item_with( + &format!( + "files {} already used by component: {id}", + format.chalk.bold(&files.join(", ")), + ), + &format.warn_symbol(), + )); + } + Ok(if result.is_empty() { String::new() } else { format!("{}\n", result.join("\n")) }) +} diff --git a/native/cli/src/commands/app_cmd.rs b/native/cli/src/commands/app_cmd.rs new file mode 100644 index 000000000000..0c384afcd890 --- /dev/null +++ b/native/cli/src/commands/app_cmd.rs @@ -0,0 +1,389 @@ +//! Native `bit app [subcommand]` (`scopes/harmony/application/app.cmd.ts`, alias `apps`, `list` default). +//! +//! Implements `app list` via [`Workspace`] and [`table`] and delegates `app run` to [`run_cmd`]. + +mod help; +#[cfg(test)] +mod tests; + +use self::help::{app_help_output, app_run_help_name, unsupported_flag_help}; +use super::{ + Command, + color::Chalk, + list::table, + run_cmd, + scope_set::selection, + script::active_component_count, + workspace_command::{self, results::Report}, +}; +use crate::{native::Outcome, workspace}; +use bit_install::{ + config::{WorkspaceConfig, aspect_id_without_version}, + model::WorkspaceComponent, + workspace::{BitmapEntry, CORE_ENVS, Workspace}, +}; +use bit_object_import::parse_jsonc; +use serde_json::json; +use std::{ + env, + ffi::OsString, + fs, + io::{self, Write}, + path::Path, + process::ExitCode, +}; + +/// Native `bit app` (alias `apps`) command descriptor. +pub const COMMAND: Command = Command { name: "app", aliases: &["apps"], run }; + +pub(super) const GLOBAL_HELP: &str = "Global\n -h, --help show help\n --log print log messages to the screen, options are: [trace, debug, info, warn, error, fatal], the default is info\n --safe-mode useful when it fails to load normally. it skips loading aspects from workspace.jsonc, and for legacy-commands it initializes only the CLI aspect"; + +const CORE_ASPECT_IDS: &str = + include_str!("../../../../scopes/harmony/testing/load-aspect/core-aspects-ids.json"); + +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub(super) struct Options { + pub json: bool, +} + +fn run(args: &[OsString]) -> io::Result { + let Some(argv) = workspace_command::utf8_args(args) else { + return Ok(Outcome::Declined("app arguments are not UTF-8".into())); + }; + let chalk = Chalk::detect(args); + if let Some(name) = app_run_help_name(&argv) { + io::stdout() + .lock() + .write_all(run_cmd::command_help(&format!("{name} run"), chalk).as_bytes())?; + return Ok(Outcome::Exit(ExitCode::SUCCESS)); + } + if let Some(help) = app_help_output(&argv, chalk) { + io::stdout().lock().write_all(help.as_bytes())?; + return Ok(Outcome::Exit(ExitCode::SUCCESS)); + } + if let Some(help) = unsupported_flag_help(&argv, chalk) { + io::stdout().lock().write_all(help.as_bytes())?; + return Ok(Outcome::Exit(ExitCode::FAILURE)); + } + if is_supported_run(&argv[1..]) { + return run_supported_run(args); + } + let Some(options) = parse_options(&argv[1..]) else { + return Ok(Outcome::Declined("outside native app grammar".into())); + }; + let cwd = env::current_dir()?; + let Some(root) = workspace::root(&cwd) else { + return run_outside_workspace(args, &argv); + }; + if let Some(apps) = discover_workspace_apps(&root) { + return emit_apps_outcome(&root, args, &options, &apps); + } + Ok(Outcome::Declined("app list delegated".into())) +} + +fn run_supported_run(args: &[OsString]) -> io::Result { + let cwd = env::current_dir()?; + let count = if let Some(root) = workspace::root(&cwd) { + let Some(count) = standard_nonapp_component_count(&root) else { + return Ok(Outcome::Declined("app run with discoverable apps delegated".into())); + }; + count + } else { + 0 + }; + run_missing_app(args, count) +} + +pub(super) fn standard_nonapp_component_count(root: &Path) -> Option { + if !discover_workspace_apps(root).is_some_and(|apps| apps.is_empty()) { + return None; + } + let raw = fs::read(root.join(".bitmap")).ok()?; + let bitmap = parse_jsonc(&raw).ok()?; + Some(active_component_count(&bitmap)) +} + +fn is_supported_run(args: &[&str]) -> bool { + let ["run" | "c", rest @ ..] = args else { + return false; + }; + let Some(options) = run_cmd::parse_options(rest) else { + return false; + }; + options.app_name + .as_deref() + .is_none_or(|name| { + let name = name.trim(); + !name.is_empty() + && name + .bytes() + .all(|byte| { + byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'/' | b'.') + }) + }) +} + +pub(super) fn run_missing_app(args: &[OsString], component_count: usize) -> io::Result { + let output = workspace_command::loader_output(Chalk::detect(args), false); + output.restart(r#"running command "run"..."#); + if component_count > 0 { + output.status(&format!("loading {component_count} component(s)")); + } + output.stop(); + io::stdout().lock().write_all(b" no apps found\n")?; + Ok(Outcome::Exit(ExitCode::FAILURE)) +} + +fn run_outside_workspace(args: &[OsString], argv: &[&str]) -> io::Result { + match &argv[1..] { + ["list", "-j" | "--json"] | ["list", "-j" | "--json", "true"] => { + io::stdout().lock().write_all(b"[]")?; + } + ["list", flag] if inline_json_value(flag) == Some("true") => { + io::stdout().lock().write_all(b"[]")?; + } + ["list", "-j" | "--json", "false"] => { + let chalk = Chalk::detect(args); + writeln!(io::stdout().lock(), r#"- running command "list"..."#)?; + writeln!(io::stdout().lock(), "{}", chalk.yellow("no apps found"))?; + } + ["list", flag] if inline_json_value(flag).is_some() => { + let chalk = Chalk::detect(args); + writeln!(io::stdout().lock(), r#"- running command "list"..."#)?; + writeln!(io::stdout().lock(), "{}", chalk.yellow("no apps found"))?; + } + [] | ["list"] => { + let chalk = Chalk::detect(args); + let label = if argv.len() == 1 { "app" } else { "list" }; + let output = workspace_command::loader_output(chalk, false); + output.restart(&format!(r#"running command "{label}"..."#)); + output.stop(); + writeln!(io::stdout().lock(), "{}", chalk.yellow("no apps found"))?; + } + _ => return Ok(Outcome::Declined("app outside workspace grammar delegated".into())), + } + Ok(Outcome::Exit(ExitCode::SUCCESS)) +} + +fn emit_apps_outcome( + root: &Path, + args: &[OsString], + options: &Options, + apps: &[(String, String)], +) -> io::Result { + let chalk = Chalk::detect(args); + if options.json { + let items: Vec<_> = apps + .iter() + .map(|(id, name)| json!({ "id": id, "name": name })) + .collect(); + let data = serde_json::to_string_pretty(&items).unwrap_or_else(|_| "[]".to_owned()); + return workspace_command::execute_json(root, args, "app", move |_| { + Ok(Report { data, code: 0, details: None }) + }); + } + let data = if apps.is_empty() { + chalk.yellow("no apps found") + } else { + let rows: Vec> = apps + .iter() + .map(|(id, name)| vec![chalk.white(id), name.clone()]) + .collect(); + table::render(&["id", "name"], &rows, table::colors(args)) + }; + if env::var_os("CI").is_some_and(|value| !value.is_empty()) + || args + .windows(2) + .any(|pair| matches!(pair[0].to_str(), Some("-j" | "--json")) && pair[1] == "false") + || args + .iter() + .any(|arg| { + arg.to_str() + .and_then(inline_json_value) + .is_some_and(|value| value != "true") + }) + { + let label = if args.len() == 1 { "app" } else { "list" }; + writeln!(io::stdout().lock(), r#"- running command "{label}"..."#)?; + } + workspace_command::execute_without_loader(root, args, "app", move |_| { + Ok(Report { data, code: 0, details: None }) + }) +} + +pub(super) fn is_empty_app_workspace(root: &Path) -> io::Result { + if let Some(apps) = discover_workspace_apps(root) { + return Ok(apps.is_empty()); + } + let ws_config = parse_jsonc(&fs::read(root.join("workspace.jsonc"))?)?; + if super::build_cmd::is_bitmap_auto_sync(&ws_config) { + return Ok(false); + } + if has_custom_root_aspects(&ws_config) { + return Ok(false); + } + let bitmap = parse_jsonc(&fs::read(root.join(".bitmap"))?)?; + Ok(active_component_count(&bitmap) == 0) +} + +#[inline] +fn has_custom_root_aspects(ws_config: &serde_json::Value) -> bool { + ws_config + .as_object() + .is_some_and(|obj| { + obj.keys() + .any(|key| { + let trimmed = key.trim(); + !trimmed.is_empty() + && !trimmed.starts_with('$') + && !trimmed.starts_with("teambit.") + }) + }) +} + +pub(super) fn discover_workspace_apps(root: &Path) -> Option> { + let raw_ws = parse_jsonc(&fs::read(root.join("workspace.jsonc")).ok()?).ok()?; + if super::build_cmd::is_bitmap_auto_sync(&raw_ws) || has_custom_root_aspects(&raw_ws) { + return None; + } + let raw_bitmap = parse_jsonc(&fs::read(root.join(".bitmap")).ok()?).ok()?; + if active_component_count(&raw_bitmap) == 0 { + return Some(Vec::new()); + } + let ws_config = WorkspaceConfig::load(root).ok()?; + let core_ids: Vec = serde_json::from_str(CORE_ASPECT_IDS).ok()?; + let ws = Workspace::load(root, &ws_config, &core_ids).ok()?; + let mut apps = Vec::new(); + for (entry, comp) in ws.active() { + collect_component_apps(entry, comp, &mut apps)?; + } + Some(apps) +} + +fn collect_component_apps( + entry: &BitmapEntry, + comp: &WorkspaceComponent, + apps: &mut Vec<(String, String)>, +) -> Option<()> { + let bare_env = aspect_id_without_version(&comp.env_id); + if !CORE_ENVS.contains(&bare_env) { + return None; + } + for rel_file in &comp.files { + if is_app_plugin_file(rel_file) { + let rel_path = Path::new(rel_file); + if rel_path.is_absolute() + || rel_path + .components() + .any(|part| matches!(part, std::path::Component::ParentDir)) + { + return None; + } + let content = fs::read_to_string(comp.dir.join(rel_path)).ok()?; + let app_name = extract_app_name(&content)?; + apps.push((selection::entry_full_id(entry), app_name)); + } + } + Some(()) +} + +#[inline] +fn is_app_plugin_file(rel_file: &str) -> bool { + rel_file.contains(".bit-app.") + || rel_file.contains(".app-root.") + || rel_file.contains(".node-app.") + || rel_file.contains(".react-app.") +} + +pub(super) fn extract_app_name(content: &str) -> Option { + for line in content.lines() { + let trimmed = line.trim(); + if trimmed.starts_with("//") || trimmed.starts_with("/*") { + continue; + } + if let Some(name) = extract_name_from_line(trimmed) { + return Some(name); + } + } + None +} + +fn extract_name_from_line(line: &str) -> Option { + let mut rest = line; + while let Some(pos) = rest.find("name:") { + let valid_prefix = pos == 0 + || !rest.as_bytes()[pos - 1].is_ascii_alphanumeric() + && rest.as_bytes()[pos - 1] != b'_' + && rest.as_bytes()[pos - 1] != b'$'; + let after = rest[pos + "name:".len()..].trim_start(); + if valid_prefix && let Some(extracted) = parse_quoted_literal(after) { + return Some(extracted); + } + rest = &rest[pos + "name:".len()..]; + } + None +} + +#[inline] +fn parse_quoted_literal(input: &str) -> Option { + let quote = input.chars().next()?; + if !matches!(quote, '\'' | '"' | '`') { + return None; + } + let rest = &input[quote.len_utf8()..]; + let end = rest.find(quote)?; + let val = &rest[..end]; + if val.trim().is_empty() || val.contains("${") || val.contains('\\') { + return None; + } + Some(val.to_owned()) +} + +pub(super) fn parse_options(args: &[&str]) -> Option { + let mut json = false; + let mut saw_list = false; + let mut args = args.iter().copied().peekable(); + while let Some(arg) = args.next() { + match arg { + "--" => { + consume_trailing_app_list(args, &mut saw_list)?; + break; + } + "--no-color" => return None, + "-j" | "--json" + if saw_list + && args + .peek() + .is_some_and(|next| matches!(*next, "true" | "false")) => + { + json = args.next() == Some("true"); + } + "-j" | "--json" => json = true, + arg if saw_list && inline_json_value(arg).is_some() => { + json = inline_json_value(arg) == Some("true"); + } + "list" if !saw_list => saw_list = true, + _ => return None, + } + } + Some(Options { json }) +} + +fn consume_trailing_app_list<'a>( + args: impl Iterator, + saw_list: &mut bool, +) -> Option<()> { + for rest in args { + (rest == "list" && !*saw_list).then_some(())?; + *saw_list = true; + } + Some(()) +} + +#[inline] +fn inline_json_value(flag: &str) -> Option<&str> { + flag.strip_prefix("--json=") + .or_else(|| flag.strip_prefix("-j=")) + .map(str::trim) + .filter(|value| !value.is_empty()) +} diff --git a/native/cli/src/commands/app_cmd/help.rs b/native/cli/src/commands/app_cmd/help.rs new file mode 100644 index 000000000000..52bbc58aaf52 --- /dev/null +++ b/native/cli/src/commands/app_cmd/help.rs @@ -0,0 +1,171 @@ +//! Released `bit app` command [`GLOBAL_HELP`], subcommand help, and rejected-option output. +//! +//! Exposes [`app_help_output`], [`app_run_help_name`], and [`unsupported_flag_help`]. + +mod json_color; + +use super::{Chalk, GLOBAL_HELP, inline_json_value, run_cmd}; + +#[inline] +fn command_name<'a>(argv: &[&'a str]) -> Option<&'a str> { + argv.first() + .copied() + .map(str::trim) + .filter(|name| !name.is_empty()) +} + +/// Returns the command spelling when `argv` invokes `app run --help` (or `app c -h`). +#[inline] +pub(super) fn app_run_help_name<'a>(argv: &[&'a str]) -> Option<&'a str> { + let name = command_name(argv)?; + let ["run" | "c", rest @ ..] = &argv[1..] else { + return None; + }; + run_cmd::is_help_form(rest).then_some(name) +} + +/// Renders `bit app --help` or `bit app list --help` output when requested in `argv`. +#[inline] +pub(super) fn app_help_output(argv: &[&str], chalk: Chalk) -> Option { + let name = command_name(argv)?; + match &argv[1..] { + ["--help" | "-h"] => Some(app_command_help(name, chalk)), + ["list", "--help" | "-h"] => Some(list_command_help(name, chalk)), + _ => None, + } +} + +pub(super) fn unsupported_flag_help(argv: &[&str], chalk: Chalk) -> Option { + let name = command_name(argv)?; + match &argv[1..] { + ["run" | "c", flag @ ("--ssr" | "--json" | "--no-color")] => { + return Some(run_flag_help(name, flag, chalk)); + } + ["run" | "c", app_name, flag @ ("--ssr" | "--json" | "--no-color")] + if !app_name.trim().is_empty() && !app_name.starts_with('-') => + { + return Some(run_flag_help(name, flag, chalk)); + } + _ => {} + } + let (list, args) = match &argv[1..] { + ["list", rest @ ..] => (true, rest), + rest => (false, rest), + }; + let unknown = app_option_error(args, list)?; + let unknown = chalk.yellow(&unknown); + let help = if list { list_command_help(name, chalk) } else { app_command_help(name, chalk) }; + Some(format!("{help}\n{unknown}\n")) +} + +#[inline] +fn app_option_error(args: &[&str], list: bool) -> Option { + if let Some(error) = json_color::error(args, list) { + return Some(error); + } + let leading_value = if list { list_leading_json_value_error(args) } else { None }; + leading_value.or_else(|| rejected_app_option(args, list)).map(str::to_owned) +} + +fn rejected_app_option(args: &[&str], list: bool) -> Option<&'static str> { + if list && list_json_no_color(args) { + return Some("Unknown arguments: no-color, noColor"); + } + let flag = match args { + [flag] => flag.trim(), + [flag @ ("--port" | "--args" | "-p" | "-a"), value] + if !value.trim().is_empty() && !value.trim_start().starts_with('-') => + { + flag.trim() + } + _ => return None, + }; + let (flag, inline) = flag + .split_once('=') + .map_or((flag, None), |(name, val)| (name.trim(), Some(val.trim()))); + if inline.is_some_and(|val| val.is_empty() || val.starts_with('-')) + || (list && matches!(flag, "--json" | "-j")) + { + return None; + } + match flag { + "--json" => Some("Unknown argument: json"), + "--ssr" => Some("Unknown argument: ssr"), + "--dev" => Some("Unknown argument: dev"), + "--verbose" => Some("Unknown argument: verbose"), + "--watch" => Some("Unknown argument: watch"), + "--port" => Some("Unknown argument: port"), + "--args" => Some("Unknown argument: args"), + "--no-color" => Some("Unknown arguments: no-color, noColor"), + "--no-browser" => Some("Unknown arguments: no-browser, noBrowser"), + "-d" => Some("Unknown argument: d"), + "-v" => Some("Unknown argument: v"), + "-n" => Some("Unknown argument: n"), + "-w" => Some("Unknown argument: w"), + "-p" => Some("Unknown argument: p"), + "-a" => Some("Unknown argument: a"), + _ => None, + } +} + +#[inline] +fn list_json_no_color(args: &[&str]) -> bool { + match args { + [json, "--no-color"] | ["--no-color", json] => is_json_flag(json), + [json, "true" | "false", "--no-color"] + | ["--no-color", json, "true" | "false"] + | [json, "--no-color", "true" | "false"] + | ["--no-color", "true" | "false", json] => matches!(*json, "-j" | "--json"), + _ => false, + } +} + +#[inline] +fn list_leading_json_value_error(args: &[&str]) -> Option<&'static str> { + match args { + [value, json, "--no-color"] | [value, "--no-color", json] + if matches!(*json, "-j" | "--json") => + { + match *value { + "true" => Some("Unknown arguments: no-color, noColor, true"), + "false" => Some("Unknown arguments: no-color, noColor, false"), + _ => None, + } + } + _ => None, + } +} + +#[inline] +fn is_json_flag(flag: &str) -> bool { + matches!(flag, "-j" | "--json") || inline_json_value(flag).is_some() +} + +fn app_command_help(name: &str, chalk: Chalk) -> String { + let global = GLOBAL_HELP; + let description = chalk.bold("manage application components"); + let link = chalk.underline("https://bit.dev/docs/getting-started/composing/create-apps"); + format!( + "bit {name} [sub-command]\n\n{description}\napplications are components that create deployable, runnable applications like React apps or Node.js servers.\nlist available apps in the workspace and inspect their configurations.\nuse \"bit run\" to start an application locally.\nfor more info, visit: {link}\n\nCommands:\n bit {name} list list all registered apps\n bit {name} run [app-name] start an application component locally [aliases: c]\n\n{global}\n", + ) +} + +fn list_command_help(name: &str, chalk: Chalk) -> String { + let global = GLOBAL_HELP; + let description = chalk.bold("list all registered apps"); + let json = run_cmd::help_option(chalk, "-j", "--json"); + let link = chalk.underline("https://bit.dev/reference/reference/cli-reference"); + format!( + "bit {name} list\n\n{description}\nfor more info, visit: {link}\n\nOptions\n {json} return the component data in json format [boolean]\n\n{global}\n", + ) +} + +fn run_flag_help(name: &str, flag: &str, chalk: Chalk) -> String { + let unknown = match flag { + "--ssr" => "Unknown argument: ssr", + "--json" => "Unknown argument: json", + "--no-color" => "Unknown arguments: no-color, noColor", + _ => unreachable!("only supported rejected flags call run_flag_help"), + }; + run_cmd::unsupported_help(&format!("{name} run"), unknown, chalk) +} diff --git a/native/cli/src/commands/app_cmd/help/json_color.rs b/native/cli/src/commands/app_cmd/help/json_color.rs new file mode 100644 index 000000000000..e9b20ab1a326 --- /dev/null +++ b/native/cli/src/commands/app_cmd/help/json_color.rs @@ -0,0 +1,45 @@ +//! Released yargs unknown-option [`error`] ordering and deduplication for [`is_json_flag`] and color flags. +//! +//! Formats `Unknown argument` and `Unknown arguments` messages for invalid `--json`/`--no-color` flags. + +use super::is_json_flag; + +/// Formats yargs `Unknown argument` / `Unknown arguments` diagnostics for unsupported flags in `args`. +#[inline] +pub(super) fn error(args: &[&str], list: bool) -> Option { + let mut unknown = Vec::with_capacity(3); + for flag in args { + for name in unknown_names(flag, list)? { + if !unknown.contains(name) { + unknown.push(*name); + } + } + } + match unknown.as_slice() { + [] => None, + [name] => Some(format!("Unknown argument: {name}")), + _ => Some(format!("Unknown arguments: {}", unknown.join(", "))), + } +} + +/// Maps an unsupported `--no-color` or `--json`/`-j` flag to its yargs unknown-argument name list. +#[inline] +fn unknown_names(flag: &str, list: bool) -> Option<&'static [&'static str]> { + let trimmed = flag.trim(); + if trimmed.is_empty() { + return None; + } + let (name, inline) = trimmed + .split_once('=') + .map_or((trimmed, None), |(prefix, val)| (prefix.trim(), Some(val.trim()))); + if name.is_empty() || inline.is_some_and(|val| val.is_empty() || val.starts_with('-')) { + return None; + } + match name { + "--no-color" => Some(&["no-color", "noColor"]), + "--json" if !list => Some(&["json"]), + "-j" if !list => Some(&["j"]), + _ if list && is_json_flag(trimmed) => Some(&[]), + _ => None, + } +} diff --git a/native/cli/src/commands/app_cmd/tests.rs b/native/cli/src/commands/app_cmd/tests.rs new file mode 100644 index 000000000000..30a0fb449493 --- /dev/null +++ b/native/cli/src/commands/app_cmd/tests.rs @@ -0,0 +1,75 @@ +//! Unit tests for `app` command [`parse_options`] and [`discover_workspace_apps`] discovery. +//! +//! Verifies [`Options`] flag parsing, [`is_empty_app_workspace`] detection, and [`extract_app_name`] extraction. + +use super::{ + Options, discover_workspace_apps, extract_app_name, is_empty_app_workspace, parse_options, +}; +use std::fs; + +/// Verifies `bit app` CLI option parsing and empty app workspace detection. +#[test] +fn parses_app_options_and_detects_empty_workspace() { + assert_eq!(parse_options(&[]), Some(Options { json: false })); + assert_eq!(parse_options(&["list", "-j"]), Some(Options { json: true })); + assert_eq!(parse_options(&["unknown"]), None); + assert_eq!(parse_options(&["list", "--json", "true", "--no-color", "--json", "false"]), None); + + let temp = tempfile::tempdir().unwrap(); + fs::write( + temp.path().join("workspace.jsonc"), + r#"{ "teambit.workspace/workspace": { "defaultScope": "my-scope" } }"#, + ) + .unwrap(); + fs::write(temp.path().join(".bitmap"), "{}").unwrap(); + assert!(is_empty_app_workspace(temp.path()).unwrap()); +} + +/// Verifies [`discover_workspace_apps`] and [`extract_app_name`] across tracked workspace components. +#[test] +fn discovers_app_components_in_workspace() { + assert_eq!( + extract_app_name("export default { name: 'my-web-app', entry: [] };"), + Some("my-web-app".into()), + ); + let temp = tempfile::tempdir().unwrap(); + fs::write( + temp.path().join("workspace.jsonc"), + r#"{ "teambit.workspace/workspace": { "defaultScope": "my-scope" } }"#, + ) + .unwrap(); + fs::write( + temp.path().join(".bitmap"), + r#"{ + "comp1": { + "scope": "", + "version": "", + "mainFile": "index.ts", + "rootDir": "comp1" + } + }"#, + ) + .unwrap(); + fs::create_dir_all(temp.path().join("comp1")).unwrap(); + fs::write(temp.path().join("comp1/index.ts"), "export const x = 1;\n").unwrap(); + assert_eq!(discover_workspace_apps(temp.path()), Some(vec![])); + + fs::write( + temp.path().join("comp1/comp1.bit-app.ts"), + "export default { name: 'sample-app' };\n", + ) + .unwrap(); + assert_eq!( + discover_workspace_apps(temp.path()), + Some(vec![("my-scope/comp1".into(), "sample-app".into())]), + ); +} + +#[test] +fn extract_app_name_handles_double_quotes_and_returns_none_when_absent() { + assert_eq!( + extract_app_name(r#"export default { name: "double-quoted-app" };"#), + Some("double-quoted-app".into()), + ); + assert_eq!(extract_app_name("export const value = 1;"), None); +} diff --git a/native/cli/src/commands/artifacts_cmd.rs b/native/cli/src/commands/artifacts_cmd.rs new file mode 100644 index 000000000000..48d3effdcc61 --- /dev/null +++ b/native/cli/src/commands/artifacts_cmd.rs @@ -0,0 +1,213 @@ +//! Native `bit artifacts ` (`scopes/pipelines/builder/artifact/artifacts.cmd.ts`, `--aspect`, `--task`, `--files`, `--out-dir`). +//! +//! Extracts and renders component build artifacts across workspaces and bare scopes via [`extractor`]. + +mod extractor; +mod render; +mod scope; +#[cfg(test)] +mod tests; + +use self::extractor::{extract_and_render, extract_scope_and_render}; +use super::{ + Command, + color::Chalk, + lane::scope_root, + scope_set::Failure, + script::active_component_count, + workspace_command::{self, format::Formatter, results::Report}, +}; +use crate::{native::Outcome, workspace}; +use bit_object_import::parse_jsonc; +use std::{env, ffi::OsString, fs, io, path::Path}; + +/// Native `bit artifacts` command descriptor. +pub const COMMAND: Command = Command { name: "artifacts", aliases: &[], run }; + +/// Parsed CLI options and filter arguments for `bit artifacts`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct Options { + pub pattern: String, + pub aspect: Option, + pub task: Option, + pub files: Option, + pub out_dir: Option, +} + +fn run(args: &[OsString]) -> io::Result { + let Some(argv) = workspace_command::utf8_args(args) else { + return Ok(Outcome::Declined("artifacts arguments are not UTF-8".into())); + }; + let Some(options) = parse_options(&argv[1..]) else { + return Ok(Outcome::Declined("outside native artifacts grammar".into())); + }; + if env::var_os("CI").is_some_and(|val| !val.is_empty()) { + return Ok(Outcome::Declined("CI artifacts delegated".into())); + } + let cwd = env::current_dir()?; + let formatter = Formatter::new(Chalk::detect(args)); + let Some(root) = workspace::root(&cwd) else { + if let Some(scope) = scope_root::discover().ok().flatten() + && scope.bare + { + return finish_scope_artifacts(&scope.path, args, &options, &formatter); + } + return workspace_command::execute_failure_scope( + &cwd, + args, + "artifacts", + true, + "", + io::Error::other("Host was not found"), + ); + }; + if is_empty_workspace(&root).unwrap_or(false) { + let pattern = options.pattern; + return workspace_command::execute_without_loader(&root, args, "artifacts", move |_| { + let msg = format!( + "{} {}", + formatter.warn_symbol(), + formatter.chalk.yellow(&format!(r#"no artifacts found for "{pattern}""#)), + ); + Ok(Report { data: msg, code: 0, details: None }) + }); + } + finish_artifacts(&root, args, &options, &formatter) +} + +fn finish_artifacts( + root: &Path, + args: &[OsString], + options: &Options, + formatter: &Formatter, +) -> io::Result { + match extract_and_render(root, options, formatter) { + Ok(data) => workspace_command::execute_then(root, args, "artifacts", move |_| { + Ok(Report { data, code: 0, details: None }) + }), + Err(Failure::User(message)) => { + workspace_command::execute_then(root, args, "artifacts", move |_| { + Err(io::Error::other(message)) + }) + } + Err(Failure::Declined(reason)) => Ok(Outcome::Declined(format!("artifacts: {reason}"))), + } +} + +fn finish_scope_artifacts( + scope_dir: &Path, + args: &[OsString], + options: &Options, + formatter: &Formatter, +) -> io::Result { + match extract_scope_and_render(scope_dir, options, formatter) { + Ok(data) => { + workspace_command::execute_then_scope(scope_dir, args, "artifacts", move |_| { + Ok(Report { data, code: 0, details: None }) + }) + } + Err(Failure::User(message)) => { + workspace_command::execute_then_scope(scope_dir, args, "artifacts", move |_| { + Err(io::Error::other(message)) + }) + } + Err(Failure::Declined(reason)) => Ok(Outcome::Declined(format!("artifacts: {reason}"))), + } +} + +pub(super) fn is_empty_workspace(root: &Path) -> io::Result { + if !root.is_absolute() + || root + .as_os_str() + .as_encoded_bytes() + .iter() + .all(u8::is_ascii_whitespace) + { + return Ok(false); + } + let ws_config = parse_jsonc(&fs::read(root.join("workspace.jsonc"))?)?; + if super::build_cmd::is_bitmap_auto_sync(&ws_config) { + return Ok(false); + } + let bitmap = parse_jsonc(&fs::read(root.join(".bitmap"))?)?; + Ok(active_component_count(&bitmap) == 0) +} + +pub(super) fn parse_options(args: &[&str]) -> Option { + let mut pattern = None; + let mut aspect = None; + let mut task = None; + let mut files = None; + let mut out_dir = None; + let mut iter = args.iter().copied(); + while let Some(arg) = iter.next() { + match arg { + "--" => { + consume_trailing_pattern(iter, &mut pattern)?; + break; + } + "--no-color" | "--no-color=true" | "--no-color=false" => {} + "--aspect" if aspect.is_none() => aspect = Some(take_val(iter.next()?)?), + "--task" if task.is_none() => task = Some(take_val(iter.next()?)?), + "--files" if files.is_none() => files = Some(take_val(iter.next()?)?), + "--out-dir" if out_dir.is_none() => out_dir = Some(take_val(iter.next()?)?), + _ => apply_inline_or_pattern( + arg, + &mut pattern, + &mut aspect, + &mut task, + (&mut files, &mut out_dir), + )?, + } + } + Some(Options { pattern: pattern?, aspect, task, files, out_dir }) +} + +fn consume_trailing_pattern<'a>( + iter: impl Iterator, + pattern: &mut Option, +) -> Option<()> { + for rest in iter { + if pattern.is_some() { + return None; + } + *pattern = Some(take_val(rest)?); + } + Some(()) +} + +fn apply_inline_or_pattern( + arg: &str, + pattern: &mut Option, + aspect: &mut Option, + task: &mut Option, + files_and_out: (&mut Option, &mut Option), +) -> Option<()> { + if !arg.starts_with('-') { + if pattern.is_some() { + return None; + } + *pattern = Some(take_val(arg)?); + return Some(()); + } + let (files, out_dir) = files_and_out; + let (flag, val) = arg.split_once('=')?; + let slot = match flag { + "--aspect" => aspect, + "--task" => task, + "--files" => files, + "--out-dir" => out_dir, + _ => return None, + }; + if slot.is_some() { + return None; + } + *slot = Some(take_val(val)?); + Some(()) +} + +#[inline] +fn take_val(val: &str) -> Option { + let val = val.trim(); + (!val.is_empty() && !val.starts_with('-')).then(|| val.to_owned()) +} diff --git a/native/cli/src/commands/artifacts_cmd/extractor.rs b/native/cli/src/commands/artifacts_cmd/extractor.rs new file mode 100644 index 000000000000..1e72561a0a59 --- /dev/null +++ b/native/cli/src/commands/artifacts_cmd/extractor.rs @@ -0,0 +1,398 @@ +//! Artifact extraction, [`Options`] filtering, and [`render_artifacts_report`] dispatch for `bit artifacts`. +//! +//! Defines [`ArtifactFile`], [`ArtifactItem`], [`ComponentArtifacts`], and [`TargetComponent`] records. + +use super::{ + Options, + render::{render_artifacts_report, save_artifacts_to_dir}, + scope::{filter_artifacts, resolve_bare_scope_targets}, +}; +use crate::{ + commands::{ + aspect::workspace_default_scope, + debug_cat::lookup::resolve_component_entry, + remote_action::startup, + scope_set::{Failure, selection}, + workspace_command::format::Formatter, + }, + workspace, +}; +use bit_install::workspace::{Bitmap, BitmapEntry}; +use serde_json::Value; +use std::{fs, path::Path}; +/// Extracted artifact file path and content `SHA-1` hash. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct ArtifactFile { + pub relative_path: String, + pub hash: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct ArtifactItem { + pub artifact_name: String, + pub aspect_id: String, + pub task_name: String, + pub files: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct ComponentArtifacts { + pub identity_no_ver: String, + pub display_id: String, + pub artifacts: Vec, +} + +pub(super) struct TargetComponent { + pub identity_no_ver: String, + pub comp_name: String, + pub version: Option, +} + +pub(super) fn extract_and_render( + root: &Path, + options: &Options, + formatter: &Formatter, +) -> Result { + let bitmap = load_workspace_bitmap(root)?; + let scope_dir = workspace::scope_path(root); + let targets = resolve_targets(root, &scope_dir, &bitmap, &options.pattern, formatter)?; + render_targets_artifacts(&scope_dir, &targets, options, formatter) +} + +pub(super) fn extract_scope_and_render( + scope_dir: &Path, + options: &Options, + formatter: &Formatter, +) -> Result { + if startup::supported(None, scope_dir, &Value::Null, true).ok() != Some(true) { + return Err(Failure::Declined("bare scope requires unsupported startup".into())); + } + let targets = resolve_bare_scope_targets(scope_dir, &options.pattern)?; + render_targets_artifacts(scope_dir, &targets, options, formatter) +} + +fn render_targets_artifacts( + scope_dir: &Path, + targets: &[TargetComponent], + options: &Options, + formatter: &Formatter, +) -> Result { + let mut results = Vec::with_capacity(targets.len()); + for target in targets { + let mut comp_artifacts = load_component_artifacts(scope_dir, target)?; + filter_artifacts(&mut comp_artifacts.artifacts, options)?; + results.push(comp_artifacts); + } + let saved_count = if let Some(out_dir) = &options.out_dir { + save_artifacts_to_dir(scope_dir, &results, out_dir)? + } else { + 0 + }; + Ok(render_artifacts_report( + &results, + &options.pattern, + options.out_dir.as_deref(), + saved_count, + formatter, + )) +} + +fn load_workspace_bitmap(root: &Path) -> Result { + let ws_bytes = + fs::read(root.join("workspace.jsonc")).map_err(|err| Failure::Declined(err.to_string()))?; + let ws_val = bit_object_import::parse_jsonc(&ws_bytes) + .map_err(|err| Failure::Declined(err.to_string()))?; + let default_scope = workspace_default_scope(&ws_val)?; + Bitmap::load(root, Some(&default_scope)).map_err(|err| Failure::Declined(err.to_string())) +} + +fn resolve_targets( + root: &Path, + scope_dir: &Path, + bitmap: &Bitmap, + pattern: &str, + formatter: &Formatter, +) -> Result, Failure> { + let trimmed = pattern.trim(); + if !trimmed.contains(['*', ',', '!', '$', ':']) { + return resolve_literal_target(scope_dir, bitmap, trimmed); + } + let entries = selection::resolve_pattern_entries(root, bitmap, trimmed, formatter.chalk)?; + Ok(entries + .into_iter() + .map(target_from_bitmap_entry) + .collect()) +} + +fn resolve_literal_target( + scope_dir: &Path, + bitmap: &Bitmap, + pattern: &str, +) -> Result, Failure> { + let (bare_id, explicit_ver) = pattern + .rsplit_once('@') + .map_or((pattern, None), |(id, ver)| (id.trim(), Some(ver.trim()))); + if bare_id.is_empty() || explicit_ver == Some("") { + return Err(Failure::Declined("invalid component id".into())); + } + if let Some(target) = find_bitmap_target(bitmap, bare_id, explicit_ver) { + return Ok(vec![target]); + } + let Some((scope_opt, comp_name, _)) = resolve_component_entry(scope_dir, bare_id) else { + return Err(Failure::Declined("literal component not found locally".into())); + }; + let identity_no_ver = scope_opt + .as_deref() + .filter(|scope| !scope.is_empty()) + .map_or_else(|| comp_name.clone(), |scope| format!("{scope}/{comp_name}")); + Ok(vec![TargetComponent { + identity_no_ver, + comp_name, + version: Some(explicit_ver.unwrap_or("latest").to_owned()), + }]) +} + +fn find_bitmap_target( + bitmap: &Bitmap, + bare_id: &str, + explicit_ver: Option<&str>, +) -> Option { + let normalized = bare_id.trim_start_matches("./").trim_end_matches('/'); + let entry = bitmap.entries + .iter() + .find(|entry| { + entry.owns_its_dir() + && (entry.id.to_string_without_version() == bare_id + || entry.id.name == bare_id + || (!entry.root_dir.is_empty() && entry.root_dir == normalized)) + })?; + let mut target = target_from_bitmap_entry(entry); + if let Some(ver) = explicit_ver { + target.version = Some(ver.to_owned()); + } + Some(target) +} + +#[inline] +fn target_from_bitmap_entry(entry: &BitmapEntry) -> TargetComponent { + TargetComponent { + identity_no_ver: entry.id.to_string_without_version(), + comp_name: entry.id.name.clone(), + version: entry.id.version + .clone() + .filter(|ver| !ver.is_empty()), + } +} + +fn load_component_artifacts( + scope_dir: &Path, + target: &TargetComponent, +) -> Result { + let Some(req_ver) = target.version.as_deref() else { + return Ok(ComponentArtifacts { + identity_no_ver: target.identity_no_ver.clone(), + display_id: target.identity_no_ver.clone(), + artifacts: Vec::new(), + }); + }; + let (_, _, comp_hash) = resolve_component_entry(scope_dir, &target.identity_no_ver) + .or_else(|| resolve_component_entry(scope_dir, &target.comp_name)) + .ok_or_else(|| Failure::Declined("component entry missing in index.json".into()))?; + let (kind, comp_val) = bit_object_import::read_scope_object(scope_dir, &comp_hash) + .ok() + .flatten() + .ok_or_else(|| Failure::Declined("component object missing".into()))?; + if kind != "Component" { + return Err(Failure::Declined("expected Component object".into())); + } + let (resolved_ver, ver_hash) = resolve_target_version(&comp_val, req_ver) + .ok_or_else(|| Failure::Declined("version hash not found".into()))?; + let ver_val = read_version_with_remote_fallback(scope_dir, &comp_val, &ver_hash)?; + Ok(ComponentArtifacts { + identity_no_ver: target.identity_no_ver.clone(), + display_id: format!("{}@{resolved_ver}", target.identity_no_ver), + artifacts: parse_builder_artifacts(&ver_val), + }) +} + +fn resolve_target_version(comp_val: &Value, req_ver: &str) -> Option<(String, String)> { + if matches!(req_ver, "latest" | "head") { + if let Some(head) = comp_val["head"] + .as_str() + .filter(|val| !val.is_empty()) + { + let tag = find_tag_for_hash(comp_val, head).unwrap_or_else(|| head.to_owned()); + return Some((tag, head.to_owned())); + } + return crate::commands::show_cmd::remote_show::resolve_scope_version( + comp_val, + Some("latest"), + ); + } + if let Some(hash) = comp_val["versions"].get(req_ver).and_then(Value::as_str) { + return Some((req_ver.to_owned(), hash.to_owned())); + } + if let Some(hash) = comp_val["orphanedVersions"].get(req_ver).and_then(Value::as_str) { + return Some((req_ver.to_owned(), hash.to_owned())); + } + if req_ver.len() == 40 && req_ver.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Some((req_ver.to_owned(), req_ver.to_owned())); + } + None +} + +#[inline] +fn find_tag_for_hash(comp_val: &Value, target_hash: &str) -> Option { + crate::commands::show_cmd::remote_show::find_tag_for_hash(comp_val, target_hash) +} + +fn read_version_with_remote_fallback( + scope_dir: &Path, + comp_val: &Value, + ver_hash: &str, +) -> Result { + let local_ver = read_version_object(scope_dir, ver_hash); + if let Some(ref val) = local_ver + && !parse_builder_artifacts(val).is_empty() + { + return Ok(local_ver.unwrap_or_default()); + } + if let Some(remote_ver) = try_read_remote_version(scope_dir, comp_val, ver_hash)? { + return Ok(remote_ver); + } + local_ver.ok_or_else(|| Failure::Declined("Version object missing locally".into())) +} + +fn try_read_remote_version( + scope_dir: &Path, + comp_val: &Value, + ver_hash: &str, +) -> Result, Failure> { + let Some(comp_scope) = comp_val["scope"].as_str() else { + return Ok(None); + }; + let Some(remote_url) = lookup_remote_url(scope_dir, comp_scope) else { + return Ok(None); + }; + let Some(remote_path) = remote_url.strip_prefix("file://") else { + return Err(Failure::Declined("remote scope refresh required".into())); + }; + Ok(read_version_object(Path::new(remote_path), ver_hash) + .filter(|val| !parse_builder_artifacts(val).is_empty())) +} + +#[inline] +fn read_version_object(scope_dir: &Path, ver_hash: &str) -> Option { + bit_object_import::read_scope_object(scope_dir, ver_hash) + .ok() + .flatten() + .and_then(|(kind, val)| (kind == "Version").then_some(val)) +} + +fn lookup_remote_url(scope_dir: &Path, comp_scope: &str) -> Option { + let bytes = fs::read(scope_dir.join("scope.json")).ok()?; + let val: Value = bit_object_import::parse_jsonc(&bytes).ok()?; + val["remotes"] + .get(comp_scope.trim()) + .and_then(Value::as_str) + .map(str::trim) + .filter(|url| !url.is_empty()) + .map(str::to_owned) +} + +pub(super) fn parse_builder_artifacts(ver_val: &Value) -> Vec { + let Some(exts) = ver_val["extensions"].as_array() else { + return Vec::new(); + }; + let Some(builder_ext) = exts + .iter() + .find(|ext| crate::commands::update_cmd::matches_aspect(ext, "teambit.pipelines/builder")) + else { + return Vec::new(); + }; + let Some(items) = builder_ext["data"]["artifacts"] + .as_array() + .or_else(|| builder_ext["artifacts"].as_array()) + else { + return Vec::new(); + }; + let fallback_artifacts = builder_ext["artifacts"].as_array().map(Vec::as_slice); + items + .iter() + .map(|art| parse_single_artifact(art, fallback_artifacts)) + .collect() +} + +fn parse_single_artifact(art: &Value, fallback_artifacts: Option<&[Value]>) -> ArtifactItem { + let artifact_name = art["name"] + .as_str() + .map(str::trim) + .unwrap_or_default() + .to_owned(); + let generated_by = art["generatedBy"] + .as_str() + .map(str::trim) + .unwrap_or_default(); + let aspect_id = art["task"]["id"] + .as_str() + .map(str::trim) + .filter(|id| !id.is_empty()) + .unwrap_or(generated_by) + .to_owned(); + let task_name = art["task"]["name"] + .as_str() + .map(str::trim) + .filter(|name| !name.is_empty()) + .unwrap_or(generated_by) + .to_owned(); + let mut files = extract_artifact_files(&art["files"]); + if files.is_empty() + && let Some(fallbacks) = fallback_artifacts + && let Some(matching) = fallbacks + .iter() + .find(|other| other["name"].as_str().map(str::trim) == Some(artifact_name.as_str())) + { + files = extract_artifact_files(&matching["files"]); + } + ArtifactItem { artifact_name, aspect_id, task_name, files } +} + +fn extract_artifact_files(files_val: &Value) -> Vec { + let Some(arr) = files_val["refs"].as_array().or_else(|| files_val.as_array()) else { + return Vec::new(); + }; + arr.iter() + .filter_map(|item| { + let relative_path = item["relativePath"] + .as_str() + .map(str::trim) + .filter(|path| !path.is_empty()) + .or_else(|| { + item["relative"] + .as_str() + .map(str::trim) + .filter(|path| !path.is_empty()) + })? + .to_owned(); + let hash = item["ref"] + .as_str() + .map(str::trim) + .filter(|hash| !hash.is_empty()) + .or_else(|| { + item["ref"]["hash"] + .as_str() + .map(str::trim) + .filter(|hash| !hash.is_empty()) + }) + .or_else(|| { + item["file"] + .as_str() + .map(str::trim) + .filter(|hash| !hash.is_empty()) + }) + .unwrap_or_default() + .to_owned(); + Some(ArtifactFile { relative_path, hash }) + }) + .collect() +} diff --git a/native/cli/src/commands/artifacts_cmd/render.rs b/native/cli/src/commands/artifacts_cmd/render.rs new file mode 100644 index 000000000000..fc6975c1d08e --- /dev/null +++ b/native/cli/src/commands/artifacts_cmd/render.rs @@ -0,0 +1,179 @@ +//! Saving ([`save_artifacts_to_dir`]) and formatting ([`render_artifacts_report`]) [`ComponentArtifacts`] for `bit artifacts`. +//! +//! Materializes stored artifact blobs into `--out-dir` and renders grouped terminal output. + +use super::extractor::{ArtifactFile, ArtifactItem, ComponentArtifacts}; +use crate::commands::{scope_set::Failure, workspace_command::format::Formatter}; +use std::{env, fs, path::Path}; + +/// Materializes extracted [`ComponentArtifacts`] files into `out_dir` from `scope_dir`. +pub(super) fn save_artifacts_to_dir( + scope_dir: &Path, + results: &[ComponentArtifacts], + out_dir: &str, +) -> Result { + let trimmed_dir = out_dir.trim(); + if trimmed_dir.is_empty() { + return Err(Failure::Declined("empty artifacts output directory".into())); + } + let out_path = Path::new(trimmed_dir); + let base_dir = if out_path.is_absolute() { + out_path.to_path_buf() + } else { + env::current_dir().map_err(|err| Failure::Declined(err.to_string()))?.join(out_path) + }; + let mut total_saved = 0usize; + for comp in results { + total_saved += save_component_artifacts(scope_dir, &base_dir, comp)?; + } + Ok(total_saved) +} + +/// Writes all artifact files for a single [`ComponentArtifacts`] entry under `base_dir`. +fn save_component_artifacts( + scope_dir: &Path, + base_dir: &Path, + comp: &ComponentArtifacts, +) -> Result { + let comp_path = base_dir.join(sanitize_component_dir(&comp.identity_no_ver)); + let mut count = 0usize; + for art in &comp.artifacts { + for file in &art.files { + save_single_artifact_file(scope_dir, &comp_path, file)?; + count += 1; + } + } + Ok(count) +} + +fn save_single_artifact_file( + scope_dir: &Path, + comp_path: &Path, + file: &ArtifactFile, +) -> Result<(), Failure> { + let hash = file.hash.trim(); + if hash.len() != 40 + || !hash.bytes().all(|byte| byte.is_ascii_hexdigit()) + || !is_safe_artifact_path(&file.relative_path) + { + return Err(Failure::Declined("missing hash or unsafe artifact path".into())); + } + let (_, bytes) = bit_object_import::read_raw_scope_object(scope_dir, hash) + .ok() + .flatten() + .ok_or_else(|| Failure::Declined("missing Source object for artifact".into()))?; + let target_path = comp_path.join(&file.relative_path); + if let Some(parent) = target_path.parent() { + fs::create_dir_all(parent).map_err(|err| Failure::Declined(err.to_string()))?; + } + fs::write(&target_path, &bytes).map_err(|err| Failure::Declined(err.to_string())) +} + +#[inline] +fn is_safe_artifact_path(relative_path: &str) -> bool { + !relative_path.trim().is_empty() + && !relative_path.contains('\\') + && !relative_path.chars().any(char::is_control) + && Path::new(relative_path) + .components() + .all(|part| matches!(part, std::path::Component::Normal(_))) +} + +#[inline] +fn sanitize_component_dir(identity: &str) -> String { + let sanitized: String = identity + .trim() + .chars() + .map(|ch| { + if ch.is_control() || matches!(ch, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|') + { + '_' + } else { + ch + } + }) + .collect(); + if sanitized.is_empty() || matches!(sanitized.as_str(), "." | "..") { + "_".to_owned() + } else { + sanitized + } +} + +pub(super) fn render_artifacts_report( + results: &[ComponentArtifacts], + pattern: &str, + out_dir: Option<&str>, + saved_count: usize, + formatter: &Formatter, +) -> String { + let total_artifacts: usize = results + .iter() + .map(|comp| comp.artifacts.len()) + .sum(); + if total_artifacts == 0 { + return format!( + "{} {}", + formatter.warn_symbol(), + formatter.chalk.yellow(&format!(r#"no artifacts found for "{pattern}""#)), + ); + } + let blocks = results + .iter() + .map(|comp| render_component_block(comp, formatter)) + .collect::>() + .join("\n\n"); + if let Some(dir) = out_dir + .map(str::trim) + .filter(|dir| !dir.is_empty()) + { + let footer = + formatter.success_summary(&format!(r#"{saved_count} file(s) saved to "{dir}""#)); + format!("{blocks}\n\n{footer}") + } else { + blocks + } +} + +fn render_component_block(comp: &ComponentArtifacts, formatter: &Formatter) -> String { + let mut groups: Vec<(String, Vec<&ArtifactItem>)> = Vec::new(); + for art in &comp.artifacts { + if let Some((_, list)) = groups + .iter_mut() + .find(|(aspect, _)| *aspect == art.aspect_id) + { + list.push(art); + } else { + groups.push((art.aspect_id.clone(), vec![art])); + } + } + let aspect_sections = groups + .into_iter() + .map(|(aspect_id, items)| render_aspect_section(&aspect_id, &items, formatter)) + .collect::>() + .join("\n\n"); + format!("{}\n{aspect_sections}", formatter.chalk.cyan(&comp.display_id)) +} + +fn render_aspect_section( + aspect_id: &str, + items: &[&ArtifactItem], + formatter: &Formatter, +) -> String { + let title = formatter.title(aspect_id); + let items_str = items + .iter() + .map(|art| { + let sub_title = + formatter.chalk.white(&format!("{} ({})", art.task_name, art.artifact_name)); + let files_str = art.files + .iter() + .map(|file| format!(" {}", formatter.chalk.dim(&file.relative_path))) + .collect::>() + .join("\n"); + format!(" {sub_title}\n{files_str}") + }) + .collect::>() + .join("\n"); + format!(" {title}\n{items_str}") +} diff --git a/native/cli/src/commands/artifacts_cmd/scope.rs b/native/cli/src/commands/artifacts_cmd/scope.rs new file mode 100644 index 000000000000..a6a8c2a65cba --- /dev/null +++ b/native/cli/src/commands/artifacts_cmd/scope.rs @@ -0,0 +1,221 @@ +//! Bare-scope [`TargetComponent`] resolution ([`resolve_bare_scope_targets`]) and [`filter_artifacts`] for `bit artifacts`. +//! +//! Evaluates literal and glob patterns against bare scope component indexes. + +use super::{ + Options, + extractor::{ArtifactFile, ArtifactItem, TargetComponent}, +}; +use crate::commands::{debug_cat::lookup::resolve_component_entry, patterns, scope_set::Failure}; +use serde_json::Value; +use std::{fs, path::Path}; + +/// Resolves literal or pattern [`TargetComponent`] entries in a bare scope at `scope_dir`. +pub(super) fn resolve_bare_scope_targets( + scope_dir: &Path, + pattern: &str, +) -> Result, Failure> { + let scope_bytes = + fs::read(scope_dir.join("scope.json")).map_err(|err| Failure::Declined(err.to_string()))?; + let scope_val: Value = bit_object_import::parse_jsonc(&scope_bytes) + .map_err(|err| Failure::Declined(err.to_string()))?; + let scope_name = scope_val["name"] + .as_str() + .map(str::trim) + .filter(|name| !name.is_empty()) + .ok_or_else(|| Failure::Declined("missing bare scope name".into()))?; + let pattern = pattern.trim(); + if !pattern.contains(['*', ',', '!', '$', ':']) { + return resolve_bare_literal_target(scope_dir, scope_name, pattern); + } + resolve_bare_pattern_targets(scope_dir, scope_name, pattern) +} + +/// Resolves a single non-glob literal component pattern in `scope_dir`. +fn resolve_bare_literal_target( + scope_dir: &Path, + scope_name: &str, + pattern: &str, +) -> Result, Failure> { + let (bare_id, explicit_ver) = pattern + .rsplit_once('@') + .map_or((pattern, None), |(id, ver)| (id.trim(), Some(ver.trim()))); + if bare_id.is_empty() || explicit_ver == Some("") { + return Err(Failure::Declined("invalid component id".into())); + } + let Some((Some(scope), comp_name, _)) = resolve_component_entry(scope_dir, bare_id) else { + if !bare_id.starts_with('@') + && (!bare_id.contains('/') + || bare_id + .strip_prefix(scope_name) + .is_some_and(|rest| rest.starts_with('/'))) + { + return Err(Failure::User(format!(r#"unable to find "{pattern}" in the scope"#))); + } + return Err(Failure::Declined("literal component not found in bare scope".into())); + }; + if scope != scope_name { + return Err(Failure::Declined("external component in bare scope".into())); + } + Ok(vec![TargetComponent { + identity_no_ver: format!("{scope}/{comp_name}"), + comp_name, + version: Some(explicit_ver.unwrap_or("latest").to_owned()), + }]) +} + +fn resolve_bare_pattern_targets( + scope_dir: &Path, + scope_name: &str, + pattern: &str, +) -> Result, Failure> { + if pattern + .split(',') + .map(str::trim) + .any(|part| part.starts_with('$') || part.contains(':') || part.contains(" AND ")) + { + return Err(Failure::Declined("unsupported bare scope artifacts pattern".into())); + } + let candidates = load_bare_scope_candidates(scope_dir, scope_name)?; + let refs = candidates + .iter() + .map(|(identity, _)| identity.as_str()) + .collect::>(); + let matched = patterns::matching(&refs, &patterns::split(pattern)) + .map_err(|_| Failure::Declined("unsupported bare scope artifacts pattern".into()))?; + if matched.is_empty() { + return Err(Failure::User(format!( + r#"unable to find any component matching the "{pattern}" pattern"#, + ))); + } + let mut targets = Vec::with_capacity(matched.len()); + for id in matched { + let Some((identity, comp_name)) = candidates + .iter() + .find(|(cand, _)| cand == id) + else { + continue; + }; + targets.push(TargetComponent { + identity_no_ver: identity.clone(), + comp_name: comp_name.clone(), + version: Some("latest".to_owned()), + }); + } + Ok(targets) +} + +fn load_bare_scope_candidates( + scope_dir: &Path, + scope_name: &str, +) -> Result, Failure> { + let bytes = + fs::read(scope_dir.join("index.json")).map_err(|err| Failure::Declined(err.to_string()))?; + let index: Value = + bit_object_import::parse_jsonc(&bytes).map_err(|err| Failure::Declined(err.to_string()))?; + let rows = index["components"] + .as_array() + .or_else(|| index.as_array()) + .ok_or_else(|| Failure::Declined("invalid index.json".into()))?; + if rows.len() > 128 { + return Err(Failure::Declined("bare scope component count exceeds native cap".into())); + } + let mut candidates = Vec::new(); + for row in rows + .iter() + .filter(|row| row["isSymlink"] != true) + { + let (scope, name) = parse_index_row_id(row) + .ok_or_else(|| Failure::Declined("missing scope or name in index".into()))?; + if scope != scope_name { + return Err(Failure::Declined("cached external component in bare scope".into())); + } + let hash = row["hash"] + .as_str() + .ok_or_else(|| Failure::Declined("missing hash in index".into()))?; + let (kind, comp_val) = bit_object_import::read_scope_object(scope_dir, hash) + .ok() + .flatten() + .ok_or_else(|| Failure::Declined("missing Component object".into()))?; + if kind != "Component" { + return Err(Failure::Declined("unexpected non-Component in index".into())); + } + if comp_val["head"].as_str().is_some() { + candidates.push((format!("{scope}/{name}"), name.to_owned())); + } + } + Ok(candidates) +} + +#[inline] +fn parse_index_row_id(row: &Value) -> Option<(&str, &str)> { + let (scope, name) = if let Some(id_str) = row["id"].as_str() { + let bare = id_str + .split('@') + .next() + .unwrap_or(id_str); + bare.split_once('/')? + } else { + (row["id"]["scope"].as_str()?, row["id"]["name"].as_str()?) + }; + let scope = scope.trim(); + let name = name.trim(); + (!scope.is_empty() && !name.is_empty()).then_some((scope, name)) +} + +pub(super) fn filter_artifacts( + artifacts: &mut Vec, + options: &Options, +) -> Result<(), Failure> { + if options.aspect.is_some() || options.task.is_some() { + artifacts.retain(|art| matches_aspect_and_task(art, options)); + } + if let Some(files_glob) = &options.files { + filter_by_files_glob(artifacts, files_glob)?; + } + Ok(()) +} + +#[inline] +fn matches_aspect_and_task(art: &ArtifactItem, options: &Options) -> bool { + let matches_aspect = options.aspect + .as_deref() + .map(str::trim) + .is_none_or(|aspect| { + art.aspect_id == aspect + || (!aspect.contains('@') + && bit_install::config::aspect_id_without_version(&art.aspect_id) == aspect) + }); + let matches_task = options.task + .as_deref() + .map(str::trim) + .is_none_or(|task| art.task_name == task); + matches_aspect && matches_task +} + +fn filter_by_files_glob( + artifacts: &mut Vec, + files_glob: &str, +) -> Result<(), Failure> { + let split_globs = patterns::split(files_glob); + for art in artifacts.iter_mut() { + art.files = retain_matching_files(&art.files, &split_globs)?; + } + artifacts.retain(|art| !art.files.is_empty()); + Ok(()) +} + +fn retain_matching_files( + files: &[ArtifactFile], + split_globs: &[String], +) -> Result, Failure> { + let mut kept = Vec::new(); + for file in files { + let matched = patterns::matching(&[file.relative_path.as_str()], split_globs) + .map_err(|_| Failure::Declined("unsupported files glob".into()))?; + if !matched.is_empty() { + kept.push(file.clone()); + } + } + Ok(kept) +} diff --git a/native/cli/src/commands/artifacts_cmd/tests.rs b/native/cli/src/commands/artifacts_cmd/tests.rs new file mode 100644 index 000000000000..f850f29540c0 --- /dev/null +++ b/native/cli/src/commands/artifacts_cmd/tests.rs @@ -0,0 +1,128 @@ +//! Unit tests for `artifacts` command [`parse_options`], [`filter_artifacts`], and [`render_artifacts_report`]. +//! +//! Verifies [`Options`] parsing, [`is_empty_workspace`] checks, and [`parse_builder_artifacts`] extraction. + +use super::{ + Options, + extractor::{ArtifactFile, ArtifactItem, ComponentArtifacts, parse_builder_artifacts}, + is_empty_workspace, parse_options, + render::render_artifacts_report, + scope::filter_artifacts, +}; +use crate::commands::{color::Chalk, workspace_command::format::Formatter}; +use serde_json::json; +use std::fs; + +/// Verifies `bit artifacts` CLI option parsing across `--aspect`, `--task`, `--files`, and `--out-dir`. +#[test] +fn parses_artifacts_options() { + assert_eq!(parse_options(&[]), None); + assert_eq!( + parse_options(&[ + "comp1", + "--aspect", + "teambit.compilation/compiler", + "--task=compile", + "--files", + "dist/**", + "--out-dir", + "out", + ]), + Some(Options { + pattern: "comp1".into(), + aspect: Some("teambit.compilation/compiler".into()), + task: Some("compile".into()), + files: Some("dist/**".into()), + out_dir: Some("out".into()), + }), + ); +} + +/// Verifies [`is_empty_workspace`] returns `true` when `.bitmap` has no tracked components. +#[test] +fn detects_empty_workspace_for_artifacts() { + let temp = tempfile::tempdir().unwrap(); + fs::write( + temp.path().join("workspace.jsonc"), + r#"{ "teambit.workspace/workspace": { "defaultScope": "my-scope" } }"#, + ) + .unwrap(); + fs::write(temp.path().join(".bitmap"), "{}").unwrap(); + assert!(is_empty_workspace(temp.path()).unwrap()); +} + +#[test] +fn extracts_filters_and_renders_builder_artifacts() { + let ver_val = json!({ + "extensions": [ + { + "name": "teambit.pipelines/builder", + "data": { + "artifacts": [ + { + "name": "dist", + "generatedBy": "teambit.compilation/compiler", + "task": { + "id": "teambit.compilation/compiler", + "name": "TSCompiler" + }, + "files": { + "refs": [ + { "relativePath": "dist/index.js", "ref": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" }, + { "relativePath": "dist/index.d.ts", "ref": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" } + ] + } + } + ] + } + } + ] + }); + let mut artifacts = parse_builder_artifacts(&ver_val); + assert_eq!( + artifacts, + vec![ArtifactItem { + artifact_name: "dist".into(), + aspect_id: "teambit.compilation/compiler".into(), + task_name: "TSCompiler".into(), + files: vec![ + ArtifactFile { + relative_path: "dist/index.js".into(), + hash: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".into(), + }, + ArtifactFile { + relative_path: "dist/index.d.ts".into(), + hash: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".into(), + }, + ], + }], + ); + + let opts = Options { + pattern: "comp1".into(), + aspect: Some("teambit.compilation/compiler".into()), + task: Some("TSCompiler".into()), + files: Some("dist/*.js".into()), + out_dir: None, + }; + filter_artifacts(&mut artifacts, &opts).unwrap(); + assert_eq!(artifacts[0].files.len(), 1); + assert_eq!(artifacts[0].files[0].relative_path, "dist/index.js"); + + let formatter = Formatter::new(Chalk::new(false)); + let comp = ComponentArtifacts { + identity_no_ver: "my-scope/comp1".into(), + display_id: "my-scope/comp1@0.0.1".into(), + artifacts, + }; + let report = render_artifacts_report(&[comp], "comp1", None, 0, &formatter); + assert!(report.contains("my-scope/comp1@0.0.1")); + assert!(report.contains("teambit.compilation/compiler")); + assert!(report.contains("TSCompiler (dist)")); + assert!(report.contains("dist/index.js")); +} + +#[test] +fn parse_builder_artifacts_returns_empty_vec_when_builder_extension_is_absent() { + assert!(parse_builder_artifacts(&json!({"extensions": []})).is_empty()); +} diff --git a/native/cli/src/commands/aspect.rs b/native/cli/src/commands/aspect.rs new file mode 100644 index 000000000000..76d230e637e0 --- /dev/null +++ b/native/cli/src/commands/aspect.rs @@ -0,0 +1,390 @@ +//! Native `bit aspect ` (`scopes/harmony/aspect/aspect.cmd.ts`) handler and helpers. +//! +//! Dispatches aspect inspection ([`inspect`]) and configuration mutations ([`mutation`]) via [`COMMAND`]. + +pub(in crate::commands) use self::mutation::{ + CompJsonEdit, deep_merge, find_bitmap_row_mut, find_bitmap_row_ref, find_component_aspect, + find_matching_aspect_key, resolve_aspect_identity, resolve_latest_aspect_version, + serialize_json_preserving_newline, update_in_bitmap_row, update_in_comp_json, + workspace_default_scope, write_bitmap_if_changed, +}; + +mod inspect; +mod mutation; +#[cfg(test)] +mod tests; + +use self::{ + inspect::{get::run_get, run_list}, + mutation::prepare, +}; +use super::{ + Command, + color::Chalk, + lane::scope_root, + scope_set::Failure, + workspace_command::{self, results::Report}, +}; +use crate::{history::History, native::Outcome, workspace}; +use serde_json::{Value, json}; +use std::{ + env, + ffi::OsString, + io::{self, Write}, + path::Path, + process::ExitCode, +}; + +/// Native `bit aspect` command descriptor. +pub const COMMAND: Command = Command { name: "aspect", aliases: &[], run }; + +pub(super) const CORE_ASPECT_IDS: &str = + include_str!("../../../../scopes/harmony/testing/load-aspect/core-aspects-ids.json"); + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct ListOpts { + pub pattern: Option, + pub debug: bool, + pub json: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct GetOpts { + pub component: String, + pub aspect_id: Option, + pub debug: bool, + pub json: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) enum Subcommand { + List(ListOpts), + ListCore { json: bool }, + Get(GetOpts), + Set { pattern: String, aspect_id: String, config: Value, merge: bool }, + Unset { pattern: String, aspect_id: String }, + Update { aspect_id: String, pattern: Option }, + Unrecognized(String), +} + +fn run(args: &[OsString]) -> io::Result { + let Some(argv) = workspace_command::utf8_args(args) else { + return Ok(Outcome::Declined("aspect arguments are not UTF-8".into())); + }; + let Some(subcommand) = parse_subcommand(&argv[1..]) else { + return Ok(Outcome::Declined("outside native aspect grammar".into())); + }; + if let Subcommand::ListCore { json: json_flag } = subcommand { + return run_list_core(args, json_flag); + } + let chalk = Chalk::detect(args); + if let Subcommand::Unrecognized(sub) = &subcommand { + return run_unrecognized(args, sub, chalk); + } + let cwd = env::current_dir()?; + let Some(root) = workspace::root(&cwd) else { + return run_outside_workspace(&cwd, args, &subcommand); + }; + match &subcommand { + Subcommand::List(opts) => { + return finish_outcome(&root, args, "list", run_list(&root, args, opts, chalk)); + } + Subcommand::Get(opts) => { + return finish_outcome(&root, args, "get", run_get(&root, args, opts, chalk)); + } + Subcommand::ListCore { .. } + | Subcommand::Set { .. } + | Subcommand::Unset { .. } + | Subcommand::Update { .. } + | Subcommand::Unrecognized(_) => {} + } + let label = subcommand.loader_label(); + let result = prepare(&root, &subcommand, chalk) + .and_then(|prepared| { + workspace_command::execute_then(&root, args, label, |_output| prepared.commit(chalk)) + .map_err(|err| Failure::Declined(err.to_string())) + }); + finish_outcome(&root, args, label, result) +} + +fn run_outside_workspace( + cwd: &Path, + args: &[OsString], + subcommand: &Subcommand, +) -> io::Result { + if env::var_os("CI").is_some_and(|val| !val.is_empty()) { + return Ok(Outcome::Declined("CI aspect outside workspace delegated".into())); + } + let (json, prop) = match subcommand { + Subcommand::List(opts) if opts.pattern.is_none() => (opts.json, "listIds"), + Subcommand::List(opts) => (opts.json, "idsByPattern"), + Subcommand::Get(opts) => (opts.json, "resolveComponentId"), + Subcommand::Set { .. } | Subcommand::Unset { .. } => (false, "idsByPattern"), + Subcommand::Update { .. } => (false, "resolveComponentId"), + Subcommand::ListCore { .. } | Subcommand::Unrecognized(_) => { + return Ok(Outcome::Declined("aspect requires a workspace".into())); + } + }; + let msg = format!("Cannot read properties of undefined (reading '{prop}')"); + workspace_command::execute_failure_scope( + cwd, + args, + subcommand.loader_label(), + json, + "", + io::Error::other(msg), + ) +} + +fn finish_outcome( + root: &Path, + args: &[OsString], + label: &str, + result: Result, +) -> io::Result { + match result { + Ok(outcome) => Ok(outcome), + Err(Failure::User(message)) => { + if env::var_os("CI").is_some_and(|value| !value.is_empty()) { + return Ok(Outcome::Declined("CI aspect error stack".into())); + } + workspace_command::execute_then(root, args, label, |_output| { + Err(io::Error::other(message)) + }) + } + Err(Failure::Declined(reason)) => Ok(Outcome::Declined(format!("aspect: {reason}"))), + } +} + +impl Subcommand { + #[inline] + const fn loader_label(&self) -> &'static str { + match self { + Self::List(_) => "list", + Self::ListCore { .. } => "list-core", + Self::Get(_) => "get", + Self::Set { .. } => "set", + Self::Unset { .. } => "unset", + Self::Update { .. } => "update", + Self::Unrecognized(_) => "aspect", + } + } +} + +fn run_list_core(args: &[OsString], json_flag: bool) -> io::Result { + let ids: Vec = serde_json::from_str(CORE_ASPECT_IDS).map_err(io::Error::other)?; + let root_opt = scope_root::discover().ok().flatten(); + if json_flag { + let history = root_opt + .as_ref() + .filter(|root| !root.bare) + .map(|root| History::start(&root.path, args)); + let mut stdout = io::stdout().lock(); + stdout.write_all(serde_json::to_string_pretty(&ids)?.as_bytes())?; + stdout.flush()?; + if let Some(history) = history { + history.end(true); + } + return Ok(Outcome::Exit(ExitCode::SUCCESS)); + } + emit_root_or_stdout(root_opt, args, "list-core", ids.join("\n")) +} + +fn run_unrecognized(args: &[OsString], sub: &str, chalk: Chalk) -> io::Result { + let data = chalk.red(&format!( + r#""{sub}" is not a subcommand of "aspect", please run "bit aspect --help" to list the subcommands"#, + )); + emit_root_or_stdout(scope_root::discover().ok().flatten(), args, "aspect", data) +} + +fn emit_root_or_stdout( + root_opt: Option, + args: &[OsString], + label: &str, + data: String, +) -> io::Result { + let report = Report { data, code: 0, details: None }; + match root_opt { + Some(root) if !root.bare => { + workspace_command::execute_then(&root.path, args, label, |_| Ok(report)) + } + Some(root) => workspace_command::execute_with_scope(&root.path, args, label, &report), + None => { + let mut stdout = io::stdout().lock(); + writeln!(stdout, "{}", report.data)?; + stdout.flush()?; + Ok(Outcome::Exit(ExitCode::SUCCESS)) + } + } +} + +pub(super) fn parse_subcommand(args: &[&str]) -> Option { + let filtered = args + .iter() + .copied() + .filter(|arg| !matches!(*arg, "--no-color" | "--no-color=true" | "--no-color=false")) + .collect::>(); + match filtered.as_slice() { + ["list", rest @ ..] => parse_list(rest), + ["list-core", rest @ ..] => parse_list_core(rest), + ["get", rest @ ..] => parse_get(rest), + ["set", rest @ ..] => parse_set(rest), + ["unset", rest @ ..] => parse_unset(rest), + ["update", rest @ ..] => parse_update(rest), + other => { + let tokens = super::scope_set::collect_non_flag_tokens(other)?; + let [sub] = tokens.as_slice() else { return None }; + (!matches!(*sub, "list" | "list-core" | "get" | "set" | "unset" | "update")).then( + || Subcommand::Unrecognized((*sub).to_owned()), + ) + } + } +} + +#[inline] +fn parse_unset(rest: &[&str]) -> Option { + let tokens = super::scope_set::collect_non_flag_tokens(rest)?; + let [pattern, aspect_id] = tokens.as_slice() else { return None }; + let aspect_id = aspect_id.trim(); + (is_supported_pattern(pattern) && !aspect_id.is_empty()).then(|| Subcommand::Unset { + pattern: pattern.trim().to_owned(), + aspect_id: aspect_id.to_owned(), + }) +} + +fn consume_trailing<'a>(iter: impl Iterator, out: &mut Vec<&'a str>) -> Option<()> { + for trailing in iter { + (!trailing.is_empty() && !trailing.starts_with('-')).then_some(())?; + out.push(trailing); + } + Some(()) +} + +fn parse_debug_json_args<'a>(rest: &[&'a str]) -> Option<(Vec<&'a str>, bool, bool)> { + let mut positional = Vec::new(); + let mut debug = false; + let mut json = false; + let mut iter = rest.iter().copied(); + while let Some(arg) = iter.next() { + match arg { + "--" => { + consume_trailing(iter, &mut positional)?; + break; + } + "-d" | "--debug" | "--debug=true" => debug = true, + "--debug=false" => debug = false, + "-j" | "--json" | "--json=true" => json = true, + "--json=false" => json = false, + "-dj" | "-jd" => (debug, json) = (true, true), + val if !val.starts_with('-') && !val.is_empty() => positional.push(val), + _ => return None, + } + } + Some((positional, debug, json)) +} + +fn parse_list(rest: &[&str]) -> Option { + let (positional, debug, json) = parse_debug_json_args(rest)?; + let pattern = match positional.as_slice() { + [] => None, + [pat] if is_supported_pattern(pat) => Some((*pat).to_owned()), + _ => return None, + }; + Some(Subcommand::List(ListOpts { pattern, debug, json })) +} + +fn parse_get(rest: &[&str]) -> Option { + let (positional, debug, json) = parse_debug_json_args(rest)?; + let (component, aspect_id) = match positional.as_slice() { + [comp] if is_supported_pattern(comp) => (comp.trim().to_owned(), None), + [comp, aspect] if is_supported_pattern(comp) && !aspect.trim().is_empty() => { + (comp.trim().to_owned(), Some(aspect.trim().to_owned())) + } + _ => return None, + }; + Some(Subcommand::Get(GetOpts { component, aspect_id, debug, json })) +} + +fn parse_list_core(rest: &[&str]) -> Option { + let mut json = false; + let mut positional = Vec::new(); + let mut iter = rest.iter().copied(); + while let Some(arg) = iter.next() { + match arg { + "--" => { + consume_trailing(iter, &mut positional)?; + break; + } + "-j" | "--json" | "--json=true" => json = true, + "--json=false" => json = false, + val if !val.starts_with('-') && !val.is_empty() => positional.push(val), + _ => return None, + } + } + (positional.len() <= 1).then_some(Subcommand::ListCore { json }) +} + +fn parse_update(rest: &[&str]) -> Option { + let tokens = super::scope_set::collect_non_flag_tokens(rest)?; + match tokens.as_slice() { + [aspect_id] if !aspect_id.trim().is_empty() => { + Some(Subcommand::Update { aspect_id: aspect_id.trim().to_owned(), pattern: None }) + } + [aspect_id, pattern] if !aspect_id.trim().is_empty() && is_supported_pattern(pattern) => { + Some(Subcommand::Update { + aspect_id: aspect_id.trim().to_owned(), + pattern: Some(pattern.trim().to_owned()), + }) + } + _ => None, + } +} + +fn parse_set(rest: &[&str]) -> Option { + let mut merge = false; + let mut positional = Vec::new(); + let mut iter = rest.iter().copied(); + while let Some(arg) = iter.next() { + match arg { + "--" => { + consume_trailing(iter, &mut positional)?; + break; + } + "-m" | "--merge" | "--merge=true" => merge = true, + "--merge=false" => merge = false, + val if !val.starts_with('-') && !val.is_empty() => positional.push(val), + _ => return None, + } + } + let (pattern, aspect_id, config) = match positional.as_slice() { + [pattern, aspect_id] => (*pattern, *aspect_id, json!({})), + [pattern, aspect_id, raw_cfg] => { + let parsed: Value = bit_object_import::parse_jsonc(raw_cfg.as_bytes()).ok()?; + if !parsed.is_object() { + return None; + } + (*pattern, *aspect_id, parsed) + } + _ => return None, + }; + let aspect_id = aspect_id.trim(); + if !is_supported_pattern(pattern) || aspect_id.is_empty() { + return None; + } + Some(Subcommand::Set { + pattern: pattern.trim().to_owned(), + aspect_id: aspect_id.to_owned(), + config, + merge, + }) +} + +#[inline] +pub(in crate::commands) fn is_supported_pattern(pattern: &str) -> bool { + let trimmed = pattern.trim(); + !trimmed.is_empty() + && !trimmed.starts_with(['-', '@']) + && !trimmed.contains(['$', ':']) + && !trimmed.contains(" AND ") + && !trimmed.starts_with("teambit.") +} diff --git a/native/cli/src/commands/aspect/inspect.rs b/native/cli/src/commands/aspect/inspect.rs new file mode 100644 index 000000000000..6e0cba30bcd5 --- /dev/null +++ b/native/cli/src/commands/aspect/inspect.rs @@ -0,0 +1,401 @@ +//! Native `bit aspect list [pattern]` ([`ListOpts`]) and `bit aspect get [aspect-id]` inspection. +//! +//! Merges [`AspectSourceItem`] provenance across workspace, `.bitmap`, `component.json`, and model layers. + +pub(super) mod debug; +pub(super) mod get; + +use super::{CORE_ASPECT_IDS, ListOpts, mutation::BitmapContext}; +use crate::{ + commands::{ + color::Chalk, + list::table, + scope_set::{Failure, selection}, + script::resolve_workspace_components, + workspace_command::{self, results::Report}, + }, + native::Outcome, +}; +use bit_install::{ + config::{ENVS_ASPECT, MergeConfig, WorkspaceConfig, aspect_id_without_version}, + model::{ComponentId, WorkspaceComponent}, + workspace::{ + AspectEntry, Bitmap, BitmapEntry, ScopeObjects, WORKSPACE_ROOT_DIR, Workspace, aspects, + by_root_dir_and_name, + }, +}; +use serde_json::{Map, Value, json}; +use std::{ffi::OsString, fs, path::Path}; +/// Resolved aspect name, configuration source label, and origin level for `bit aspect list`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct AspectSourceItem { + pub aspect_name: String, + pub source: &'static str, + pub level: &'static str, +} + +pub(super) struct RawOrigin { + pub source: &'static str, + pub level: &'static str, + pub prefer_ws: bool, + pub entries: Vec, + pub extra_data: Option, +} + +pub(super) struct InspectCtx<'a> { + pub root: &'a Path, + pub ws_config: &'a WorkspaceConfig, + pub objects: ScopeObjects, + pub core_ids: Vec, + pub ws_ids: Vec, +} + +impl<'a> InspectCtx<'a> { + pub(super) fn new( + root: &'a Path, + ws_config: &'a WorkspaceConfig, + ws: &Workspace, + bitmap: &Bitmap, + ) -> Result { + let core_ids: Vec = serde_json::from_str(CORE_ASPECT_IDS) + .map_err(|err| Failure::Declined(err.to_string()))?; + let ws_ids = bitmap.entries + .iter() + .map(|entry| entry.id.clone()) + .collect(); + Ok(Self { root, ws_config, objects: ScopeObjects::new(&ws.scope_path), core_ids, ws_ids }) + } +} + +pub(super) fn run_list( + root: &Path, + args: &[OsString], + opts: &ListOpts, + chalk: Chalk, +) -> Result { + let items = collect_list_results(root, opts.pattern.as_deref(), chalk)?; + if opts.json { + let data = serde_json::to_string_pretty(&build_list_json(items)) + .map_err(|err| Failure::Declined(err.to_string()))?; + return workspace_command::execute_json(root, args, "list", |_| { + Ok(Report { data, code: 0, details: None }) + }) + .map_err(|err| Failure::Declined(err.to_string())); + } + let data = render_list_table(&items, opts.debug, chalk, table::colors(args)); + workspace_command::execute_then(root, args, "list", |_| { + Ok(Report { data, code: 0, details: None }) + }) + .map_err(|err| Failure::Declined(err.to_string())) +} + +fn build_list_json(items: Vec<(String, Vec)>) -> Value { + let mut map = Map::with_capacity(items.len()); + for (comp_id, sources) in items { + let arr = sources + .into_iter() + .map(|src| json!({"aspectName": src.aspect_name, "source": src.source, "level": src.level})) + .collect::>(); + map.insert(comp_id, Value::Array(arr)); + } + Value::Object(map) +} + +pub(super) fn collect_list_results( + root: &Path, + pattern: Option<&str>, + chalk: Chalk, +) -> Result)>, Failure> { + let ctx = BitmapContext::load(root, chalk)?; + let (ws_config, ws) = + resolve_workspace_components(root).map_err(|err| Failure::Declined(err.to_string()))?; + let entries = select_entries(root, &ctx.bitmap, pattern, chalk)?; + let inspect_ctx = InspectCtx::new(root, &ws_config, &ws, &ctx.bitmap)?; + let mut out = Vec::with_capacity(entries.len()); + for entry in entries { + let sources = sources_for_entry(&inspect_ctx, entry, find_ws_comp(&ws.components, entry))?; + out.push((selection::entry_full_id(entry), sources)); + } + Ok(out) +} + +fn select_entries<'a>( + root: &Path, + bitmap: &'a Bitmap, + pattern: Option<&str>, + chalk: Chalk, +) -> Result, Failure> { + match pattern { + Some(pat) => selection::resolve_pattern_entries(root, bitmap, pat, chalk), + None => Ok(bitmap.entries + .iter() + .filter(|entry| entry.owns_its_dir()) + .collect()), + } +} + +#[inline] +pub(super) fn find_ws_comp<'a>( + components: &'a [WorkspaceComponent], + entry: &BitmapEntry, +) -> Option<&'a WorkspaceComponent> { + components + .iter() + .find(|comp| comp.id.to_string_without_version() == entry.id.to_string_without_version()) +} + +fn sources_for_entry( + ictx: &InspectCtx<'_>, + entry: &BitmapEntry, + comp: Option<&WorkspaceComponent>, +) -> Result, Failure> { + let raw_origins = collect_raw_origins(ictx, entry)?; + let self_id = entry.id.to_string_without_version(); + let mut out: Vec = Vec::new(); + let mut removed: Vec = Vec::new(); + for origin in raw_origins { + append_origin_sources(&mut out, &mut removed, origin, &ictx.ws_ids, &self_id); + } + let env_id = comp + .map(|item| item.env_id.trim()) + .filter(|id| !id.is_empty()) + .unwrap_or("teambit.harmony/node"); + for default_aspect in [env_id, ENVS_ASPECT] { + let default_bare = aspect_id_without_version(default_aspect); + if !out + .iter() + .any(|existing| aspect_id_without_version(&existing.aspect_name) == default_bare) + { + out.push(AspectSourceItem { + aspect_name: default_aspect.to_owned(), + source: "WorkspaceDefault", + level: "workspace", + }); + } + } + Ok(out) +} + +fn append_origin_sources( + out: &mut Vec, + removed: &mut Vec, + origin: RawOrigin, + ws_ids: &[ComponentId], + self_id: &str, +) { + for mut aspect in origin.entries { + let bare = aspect_id_without_version(&aspect.id).to_owned(); + if aspect.config.as_str().map(str::trim) == Some("-") { + removed.push(bare); + continue; + } + if removed.contains(&bare) || (!aspect.core && bare == self_id) { + continue; + } + resolve_aspect_id(&mut aspect, ws_ids, origin.prefer_ws); + if !out + .iter() + .any(|existing| aspect_id_without_version(&existing.aspect_name) == bare) + { + out.push(AspectSourceItem { + aspect_name: aspect.id, + source: origin.source, + level: origin.level, + }); + } + } +} + +impl RawOrigin { + #[inline] + const fn simple( + source: &'static str, + level: &'static str, + prefer_ws: bool, + entries: Vec, + ) -> Self { + Self { source, level, prefer_ws, entries, extra_data: None } + } +} + +pub(super) fn collect_raw_origins( + ictx: &InspectCtx<'_>, + entry: &BitmapEntry, +) -> Result, Failure> { + let mut origins = Vec::new(); + if let Some(cfg) = &entry.config { + origins.push(RawOrigin::simple( + "BitmapFile", + "component", + false, + aspects::from_config_object(cfg, &ictx.core_ids), + )); + } + append_merge_origin(&mut origins, ictx, entry)?; + let (comp_json_exts, propagate) = read_comp_json_origins(ictx.root, entry, &ictx.core_ids)?; + if let Some(entries) = comp_json_exts { + origins.push(RawOrigin::simple("ComponentJsonFile", "component", false, entries)); + } + let (specific, non_specific) = read_model_origins(&ictx.objects, entry)?; + origins.push(RawOrigin::simple("ModelSpecific", "component", true, specific)); + if propagate && append_variant_origin(&mut origins, ictx, entry)? { + origins.push(RawOrigin::simple("ModelNonSpecific", "workspace", true, non_specific)); + } + Ok(origins) +} + +fn append_merge_origin( + origins: &mut Vec, + ictx: &InspectCtx<'_>, + entry: &BitmapEntry, +) -> Result<(), Failure> { + let merge_cfg = MergeConfig::load(ictx.root).map_err(|err| Failure::Declined(err.to_string()))?; + if let Some(merge) = merge_cfg + .component(&entry.id.to_string_without_version()) + .map_err(|err| Failure::Declined(err.to_string()))? + { + origins.push(RawOrigin::simple( + "ConfigMerge", + "workspace", + true, + aspects::from_config_object(&merge.config, &ictx.core_ids), + )); + } + Ok(()) +} + +fn read_model_origins( + objects: &ScopeObjects, + entry: &BitmapEntry, +) -> Result<(Vec, Vec), Failure> { + let model = objects.component(&entry.id).map_err(|err| Failure::Declined(err.to_string()))?; + Ok(model + .as_ref() + .map(|mdl| aspects::from_model(&mdl.extensions)) + .unwrap_or_default() + .into_iter() + .partition(aspects::is_specific)) +} + +fn append_variant_origin( + origins: &mut Vec, + ictx: &InspectCtx<'_>, + entry: &BitmapEntry, +) -> Result { + let variant = by_root_dir_and_name( + &ictx.ws_config.variants, + entry.root_dir.trim_end_matches('/'), + &entry.id.name, + ) + .map_err(|err| Failure::Declined(err.to_string()))?; + let lists = variant.extensions + .iter() + .map(|ext| aspects::from_config_object(ext, &ictx.core_ids)) + .collect(); + let applied_rules = variant.matched_rules + .iter() + .map(|(pattern, spec)| { + let spec_val = if (*spec - ((*spec as i64) as f64)).abs() < f64::EPSILON { + json!(*spec as i64) + } else { + json!(*spec) + }; + json!({ "pattern": pattern, "specificity": spec_val }) + }) + .collect::>(); + origins.push(RawOrigin { + source: "WorkspaceVariants", + level: "workspace", + prefer_ws: true, + entries: aspects::merge_lists(lists), + extra_data: Some(json!({ "appliedRules": applied_rules })), + }); + Ok(variant.propagate) +} + +fn read_comp_json_origins( + root: &Path, + entry: &BitmapEntry, + core_ids: &[String], +) -> Result<(Option>, bool), Failure> { + let root_dir = entry.root_dir.trim(); + if root_dir.is_empty() { + return Ok((None, true)); + } + let dir = if root_dir == WORKSPACE_ROOT_DIR { root.to_path_buf() } else { root.join(root_dir) }; + let Ok(bytes) = fs::read(dir.join("component.json")) else { return Ok((None, true)) }; + let parsed = bit_object_import::parse_jsonc(&bytes)?; + let propagate = parsed + .get("propagate") + .and_then(Value::as_bool) + .unwrap_or(true); + let list = parsed + .get("extensions") + .and_then(Value::as_object) + .map(|ext| aspects::from_config_object(ext, core_ids)); + Ok((list, propagate)) +} + +pub(super) fn resolve_aspect_id(aspect: &mut AspectEntry, ws_ids: &[ComponentId], prefer_ws: bool) { + if aspect.core { + return; + } + let without = aspect_id_without_version(&aspect.id); + let Some(found) = ws_ids + .iter() + .find(|id| id.to_string_without_version() == without || id.name == without) + else { + return; + }; + let given = aspect.id + .strip_prefix(without) + .and_then(|rest| rest.strip_prefix('@')) + .map(str::trim) + .filter(|ver| !ver.is_empty()); + let version = + if prefer_ws { found.version.as_deref() } else { given.or(found.version.as_deref()) } + .map(str::trim) + .filter(|ver| !ver.is_empty()); + let base = found.to_string_without_version(); + aspect.id = match version { + Some(ver) => format!("{base}@{ver}"), + None => base, + }; +} + +pub(super) fn render_list_table( + items: &[(String, Vec)], + debug: bool, + chalk: Chalk, + colors: bool, +) -> String { + let rows = items + .iter() + .map(|(comp_id, sources)| vec![chalk.cyan(comp_id), format_sources_cell(sources, debug)]) + .collect::>(); + let full = table::render(&["", ""], &rows, colors); + let lines = full.lines().collect::>(); + if lines.len() >= 4 { + let mut kept = vec![lines[0]]; + kept.extend_from_slice(&lines[3..]); + return kept.join("\n"); + } + full +} + +fn format_sources_cell(sources: &[AspectSourceItem], debug: bool) -> String { + let longest = sources + .iter() + .map(|src| src.aspect_name.len()) + .max() + .unwrap_or(0); + sources + .iter() + .map(|src| { + let origin = if debug { format!(" (origin: {})", src.source) } else { String::new() }; + let padded = format!("{:>() + .join("\n") +} diff --git a/native/cli/src/commands/aspect/inspect/debug.rs b/native/cli/src/commands/aspect/inspect/debug.rs new file mode 100644 index 000000000000..a753465b4d27 --- /dev/null +++ b/native/cli/src/commands/aspect/inspect/debug.rs @@ -0,0 +1,262 @@ +//! Debug provenance rendering ([`InspectCtx`], [`RawOrigin`]) for `bit aspect get --debug [--json]`. +//! +//! Formats per-origin aspect configuration and merge resolution diagnostics. + +use super::{ + InspectCtx, RawOrigin, collect_raw_origins, find_ws_comp, + get::{build_get_json, ensure_default_env_aspects, render_get_text, resolve_component_for_get}, + resolve_aspect_id, +}; +use crate::{ + commands::{ + aspect::{GetOpts, mutation::BitmapContext}, + color::Chalk, + scope_set::Failure, + script::resolve_workspace_components, + workspace_command::{self, format::Formatter, results::Report}, + }, + native::Outcome, +}; +use bit_install::{ + config::{ENVS_ASPECT, aspect_id_without_version}, + model::ComponentId, + workspace::{ + AspectEntry, BitmapEntry, CORE_ENVS, + aspects::{Origin, merge_lists}, + }, +}; +use serde_json::{Map, Value, json}; +use std::{ffi::OsString, path::Path}; + +/// Provenance origin entry before aspect merge resolution in `bit aspect get --debug`. +struct DebugOrigin { + origin: &'static str, + extensions: Vec, + extra_data: Option, +} + +/// Aggregated pre-merge origins, post-merge entries, and final resolved aspects for `--debug`. +struct DebugReport { + before: Vec, + after: Vec, + final_entries: Vec, +} + +pub(super) fn run_get_debug( + root: &Path, + args: &[OsString], + opts: &GetOpts, + chalk: Chalk, +) -> Result { + let report = collect_debug_report(root, &opts.component, chalk)?; + if opts.json { + let payload = build_debug_json(&report); + let data = serde_json::to_string_pretty(&payload) + .map_err(|err| Failure::Declined(err.to_string()))?; + return workspace_command::execute_json(root, args, "get", |_| { + Ok(Report { data, code: 0, details: None }) + }) + .map_err(|err| Failure::Declined(err.to_string())); + } + let data = render_debug_text(&report, chalk); + workspace_command::execute_then(root, args, "get", |_| { + Ok(Report { data, code: 0, details: None }) + }) + .map_err(|err| Failure::Declined(err.to_string())) +} + +fn collect_debug_report( + root: &Path, + component: &str, + chalk: Chalk, +) -> Result { + if component.trim().is_empty() { + return Err(Failure::Declined("debug aspect component is empty".into())); + } + let ctx = BitmapContext::load(root, chalk)?; + let (ws_config, ws) = + resolve_workspace_components(root).map_err(|err| Failure::Declined(err.to_string()))?; + let entry = resolve_component_for_get(root, &ctx.bitmap, component, chalk)?; + let inspect_ctx = InspectCtx::new(root, &ws_config, &ws, &ctx.bitmap)?; + let raw_origins = collect_raw_origins(&inspect_ctx, entry)?; + let (before, after) = merge_debug_origins(entry, &inspect_ctx.ws_ids, raw_origins); + let mut final_entries = after.clone(); + for item in &mut final_entries { + if let Value::Object(cfg) = &mut item.config { + cfg.remove("__specific"); + } + } + ensure_default_env_aspects(&mut final_entries, find_ws_comp(&ws.components, entry)); + Ok(DebugReport { before, after, final_entries }) +} + +fn merge_debug_origins( + entry: &BitmapEntry, + ws_ids: &[ComponentId], + raw_origins: Vec, +) -> (Vec, Vec) { + let self_id = entry.id.to_string_without_version(); + let mut before_merge = Vec::with_capacity(raw_origins.len()); + let mut lists = Vec::with_capacity(raw_origins.len()); + let mut removed = Vec::new(); + let mut env_found = false; + for raw in raw_origins { + if raw.entries.is_empty() { + continue; + } + let origin = source_to_origin(raw.source); + let kept = prepare_origin_entries( + raw.entries, + origin, + &self_id, + ws_ids, + &mut removed, + &mut env_found, + ); + lists.push(kept.clone()); + before_merge.push(DebugOrigin { + origin: raw.source, + extensions: kept, + extra_data: raw.extra_data, + }); + } + let mut after_merge = merge_lists(lists); + after_merge.retain(|item| { + !removed + .iter() + .any(|rem| rem == aspect_id_without_version(&item.id)) + }); + (before_merge, after_merge) +} + +#[inline] +fn source_to_origin(source: &str) -> Origin { + match source { + "BitmapFile" => Origin::BitmapFile, + "ConfigMerge" => Origin::ConfigMerge, + "ComponentJsonFile" => Origin::ComponentJsonFile, + "ModelSpecific" => Origin::ModelSpecific, + "WorkspaceVariants" => Origin::WorkspaceVariants, + _ => Origin::ModelNonSpecific, + } +} + +fn prepare_origin_entries( + entries: Vec, + origin: Origin, + self_id: &str, + ws_ids: &[ComponentId], + removed: &mut Vec, + env_found: &mut bool, +) -> Vec { + let prefer = !matches!(origin, Origin::BitmapFile | Origin::ComponentJsonFile); + let mark_specific = matches!(origin, Origin::BitmapFile | Origin::ComponentJsonFile); + let mut kept = Vec::new(); + for mut item in entries { + if mark_specific && let Value::Object(cfg) = &mut item.config { + cfg.insert("__specific".into(), Value::Bool(true)); + } + if item.config.as_str().map(str::trim) == Some("-") { + removed.push(aspect_id_without_version(&item.id).to_owned()); + continue; + } + if !item.core && aspect_id_without_version(&item.id) == self_id { + continue; + } + resolve_aspect_id(&mut item, ws_ids, prefer); + kept.push(item); + } + filter_origin_envs(kept, env_found) +} + +fn filter_origin_envs(entries: Vec, env_found: &mut bool) -> Vec { + let envs_entry = entries + .iter() + .find(|entry| aspect_id_without_version(&entry.id) == ENVS_ASPECT); + let env = envs_entry.and_then(|entry| { + entry.config + .get("env") + .and_then(Value::as_str) + .or_else(|| entry.data.get("id").and_then(Value::as_str)) + .map(str::trim) + .filter(|val| !val.is_empty()) + .map(str::to_owned) + }); + let has_envs = envs_entry.is_some() + || entries + .iter() + .any(|entry| CORE_ENVS.contains(&aspect_id_without_version(&entry.id))); + if *env_found && has_envs { + return drop_origin_envs(entries, env.as_deref()); + } + *env_found |= env.is_some(); + entries +} + +fn drop_origin_envs(mut entries: Vec, env: Option<&str>) -> Vec { + entries.retain(|entry| { + let bare = aspect_id_without_version(&entry.id); + let is_env = + env.is_some_and(|env| entry.id == env || bare == aspect_id_without_version(env)); + !is_env && !CORE_ENVS.contains(&bare) + }); + for entry in &mut entries { + if aspect_id_without_version(&entry.id) == ENVS_ASPECT + && let Value::Object(config) = &mut entry.config + { + config.remove("env"); + } + } + entries +} + +#[inline] +fn build_debug_json(report: &DebugReport) -> Value { + let mut map = Map::with_capacity(report.before.len() + 2); + for item in &report.before { + let mut obj = Map::new(); + obj.insert("extensions".into(), build_get_json(&item.extensions, None)); + if let Some(extra) = &item.extra_data { + obj.insert("extraData".into(), extra.clone()); + } + map.insert(item.origin.into(), Value::Object(obj)); + } + map.insert("AfterMerge".into(), json!({ "extensions": build_get_json(&report.after, None) })); + map.insert( + "FinalAfterMergeIncludeLoad".into(), + json!({ "extensions": build_get_json(&report.final_entries, None) }), + ); + Value::Object(map) +} + +fn render_debug_text(report: &DebugReport, chalk: Chalk) -> String { + let formatter = Formatter::new(chalk); + let before_merge_out = report.before + .iter() + .map(|item| { + let title = formatter.title(&format!("Origin: {}", item.origin)); + let details = render_get_text(&item.extensions, chalk); + let more = match &item.extra_data { + Some(extra) => format!( + "\n{} {}", + chalk.bold("Extra Data:"), + serde_json::to_string_pretty(extra).unwrap_or_default(), + ), + None => String::new(), + }; + format!("{title}\n{details}{more}") + }) + .collect::>() + .join("\n\n"); + let after_merge_out = format!( + "{}\n{}", + formatter.title("After merging the origins above"), + render_get_text(&report.after, chalk), + ); + let final_out = format!( + "{}\n{}", + formatter.title("Final - After merging the origins above and the loaded data"), + render_get_text(&report.final_entries, chalk), + ); + Formatter::join(&[before_merge_out, after_merge_out, final_out]) +} diff --git a/native/cli/src/commands/aspect/inspect/get.rs b/native/cli/src/commands/aspect/inspect/get.rs new file mode 100644 index 000000000000..0e6f9827ce4e --- /dev/null +++ b/native/cli/src/commands/aspect/inspect/get.rs @@ -0,0 +1,261 @@ +//! Native `bit aspect get [aspect-id]` ([`GetOpts`], [`InspectCtx`]) resolution and formatting. +//! +//! Implements [`run_get`] for text and `--json` aspect inspection output. + +use super::{InspectCtx, RawOrigin, collect_raw_origins, debug::run_get_debug, find_ws_comp}; +use crate::{ + commands::{ + aspect::{GetOpts, mutation::BitmapContext}, + color::Chalk, + scope_set::{Failure, selection}, + script::resolve_workspace_components, + workspace_command::{self, results::Report}, + }, + native::Outcome, +}; +use bit_install::{ + config::{ENVS_ASPECT, aspect_id_without_version}, + model::WorkspaceComponent, + workspace::{ + AspectEntry, Bitmap, BitmapEntry, + aspects::{Merger, Origin, Resolver}, + }, +}; +use serde_json::{Map, Value, json}; +use std::{ffi::OsString, fs, path::Path}; + +/// Executes `bit aspect get` for `opts` in text, `--json`, or `--debug` mode. +pub(in crate::commands::aspect) fn run_get( + root: &Path, + args: &[OsString], + opts: &GetOpts, + chalk: Chalk, +) -> Result { + if opts.debug { + return run_get_debug(root, args, opts, chalk); + } + let entries = collect_get_entries(root, &opts.component, opts.aspect_id.as_deref(), chalk)?; + if opts.json { + let payload = build_get_json(&entries, opts.aspect_id.as_deref()); + let data = serde_json::to_string_pretty(&payload) + .map_err(|err| Failure::Declined(err.to_string()))?; + return workspace_command::execute_json(root, args, "get", |_| { + Ok(Report { data, code: 0, details: None }) + }) + .map_err(|err| Failure::Declined(err.to_string())); + } + let data = render_get_text(&entries, chalk); + workspace_command::execute_then(root, args, "get", |_| { + Ok(Report { data, code: 0, details: None }) + }) + .map_err(|err| Failure::Declined(err.to_string())) +} + +/// Collects merged [`AspectEntry`] records for `component`, optionally filtered by `aspect_filter`. +pub(in crate::commands::aspect) fn collect_get_entries( + root: &Path, + component: &str, + aspect_filter: Option<&str>, + chalk: Chalk, +) -> Result, Failure> { + let ctx = BitmapContext::load(root, chalk)?; + let (ws_config, ws) = + resolve_workspace_components(root).map_err(|err| Failure::Declined(err.to_string()))?; + let entry = resolve_component_for_get(root, &ctx.bitmap, component, chalk)?; + let inspect_ctx = InspectCtx::new(root, &ws_config, &ws, &ctx.bitmap)?; + let raw_origins = collect_raw_origins(&inspect_ctx, entry)?; + let mut merged = merge_origins(entry, &inspect_ctx.ws_ids, raw_origins); + ensure_default_env_aspects(&mut merged, find_ws_comp(&ws.components, entry)); + if let Some(target) = aspect_filter + .map(str::trim) + .filter(|target| !target.is_empty()) + { + let bare = aspect_id_without_version(target); + merged.retain(|item| item.id == target || aspect_id_without_version(&item.id) == bare); + } + Ok(merged) +} + +pub(in crate::commands::aspect) fn resolve_component_for_get<'a>( + root: &Path, + bitmap: &'a Bitmap, + component: &str, + chalk: Chalk, +) -> Result<&'a BitmapEntry, Failure> { + match selection::resolve_pattern_entries(root, bitmap, component, chalk) { + Ok(entries) => entries + .into_iter() + .next() + .ok_or_else(|| Failure::User(format!("component {component} was not found"))), + Err(Failure::User(msg)) => { + let trimmed = component.trim(); + let bare = trimmed.rsplit_once('@').map_or(trimmed, |(base, _)| base.trim()); + if !bare.contains('/') && scope_index_lookup(root, bare).is_none() { + return Err(Failure::User(missing_scope_error(component))); + } + Err(Failure::User(msg)) + } + Err(err) => Err(err), + } +} + +#[inline] +pub(in crate::commands::aspect) fn missing_scope_error(raw: &str) -> String { + format!( + r#"Failed parsing the string "{raw}": scope is missing. Maybe try to add the scope name prior to the component name, such as "scopeName/componentName""#, + ) +} + +pub(in crate::commands::aspect) fn scope_index_lookup(root: &Path, bare: &str) -> Option { + let bare = bare.trim(); + if bare.is_empty() { + return None; + } + let scope_dir = crate::workspace::scope_path(root); + let index = bit_object_import::parse_jsonc(&fs::read(scope_dir.join("index.json")).ok()?).ok()?; + let rows = index["components"].as_array().or_else(|| index.as_array())?; + let mut matches = rows + .iter() + .filter(|row| { + row["isSymlink"] != true + && crate::commands::scope_set::selection::row_has_name(row, bare) + }); + let row = matches.next()?; + if matches.next().is_some() { + return None; + } + let scope = row_scope(row, &scope_dir)?; + Some(format!("{scope}/{bare}")) +} + +fn row_scope(row: &Value, scope_dir: &Path) -> Option { + row["id"]["scope"] + .as_str() + .map(str::trim) + .filter(|val| !val.is_empty()) + .or_else(|| { + row["id"] + .as_str() + .and_then(|raw| { + aspect_id_without_version(raw.trim()) + .split_once('/') + .map(|(sc, _)| sc.trim()) + .filter(|val| !val.is_empty()) + }) + }) + .map(str::to_owned) + .or_else(|| { + let scope_json = + bit_object_import::parse_jsonc(&fs::read(scope_dir.join("scope.json")).ok()?).ok()?; + scope_json["name"] + .as_str() + .map(str::trim) + .filter(|val| !val.is_empty()) + .map(str::to_owned) + }) +} + +fn merge_origins( + entry: &bit_install::workspace::BitmapEntry, + ws_ids: &[bit_install::model::ComponentId], + raw_origins: Vec, +) -> Vec { + let mut merger = Merger::new(Resolver { component: &entry.id, workspace: ws_ids }); + for raw in raw_origins { + let origin = match raw.source { + "BitmapFile" => Origin::BitmapFile, + "ConfigMerge" => Origin::ConfigMerge, + "ComponentJsonFile" => Origin::ComponentJsonFile, + "ModelSpecific" => Origin::ModelSpecific, + "WorkspaceVariants" => Origin::WorkspaceVariants, + _ => Origin::ModelNonSpecific, + }; + merger.add(raw.entries, origin); + } + merger.finish() +} + +pub(super) fn ensure_default_env_aspects( + merged: &mut Vec, + comp: Option<&WorkspaceComponent>, +) { + let env_id = comp + .map(|item| item.env_id.trim()) + .filter(|id| !id.is_empty()) + .unwrap_or("teambit.harmony/node"); + let env_bare = aspect_id_without_version(env_id); + if !merged + .iter() + .any(|item| aspect_id_without_version(&item.id) == env_bare) + { + merged.push(AspectEntry { + id: env_id.to_owned(), + core: true, + config: json!({}), + data: json!({}), + }); + } + if let Some(envs_entry) = merged + .iter_mut() + .find(|item| aspect_id_without_version(&item.id) == ENVS_ASPECT) + { + if envs_entry.data.get("id").is_none() { + envs_entry.data = json!({ "id": env_bare }); + } + } else { + merged.push(AspectEntry { + id: ENVS_ASPECT.to_owned(), + core: true, + config: json!({ "env": env_bare }), + data: json!({ "id": env_bare }), + }); + } +} + +#[inline] +pub(super) fn build_get_json(entries: &[AspectEntry], aspect_filter: Option<&str>) -> Value { + if aspect_filter.is_some() { + return entries + .first() + .map_or(Value::Null, |item| { + json!({ + "name": item.id, + "config": item.config, + "data": item.data, + }) + }); + } + let mut map = Map::with_capacity(entries.len()); + for item in entries { + map.insert( + item.id.clone(), + json!({ + "name": item.id, + "config": item.config, + "data": item.data, + }), + ); + } + Value::Object(map) +} + +pub(in crate::commands::aspect) fn render_get_text( + entries: &[AspectEntry], + chalk: Chalk, +) -> String { + entries + .iter() + .map(|item| { + let cfg = serde_json::to_string_pretty(&item.config).unwrap_or_else(|_| "{}".into()); + let data = serde_json::to_string_pretty(&item.data).unwrap_or_else(|_| "{}".into()); + format!( + "{} {}\n{} {cfg}\n{} {data}\n", + chalk.bold("name:"), + item.id, + chalk.bold("config:"), + chalk.bold("data:"), + ) + }) + .collect::>() + .join("\n") +} diff --git a/native/cli/src/commands/aspect/mutation.rs b/native/cli/src/commands/aspect/mutation.rs new file mode 100644 index 000000000000..48975796af37 --- /dev/null +++ b/native/cli/src/commands/aspect/mutation.rs @@ -0,0 +1,400 @@ +//! Aspect configuration mutations (`set`, `unset`, `update`) across `.bitmap` and `component.json`. +//! +//! Coordinates [`comp_json`] and [`update`] plans with atomic `.bitmap` history writes via [`CompJsonEdit`]. +pub(in crate::commands) use self::{ + comp_json::serialize_json_preserving_newline, + update::{ + find_component_aspect, resolve_latest_aspect_version, update_in_bitmap_row, + update_in_comp_json, + }, +}; + +mod comp_json; +mod update; + +use self::{ + comp_json::{apply_set_to_entry, set_in_comp_json, unset_in_bitmap_row, unset_in_comp_json}, + update::{UpdatePlan, prepare_update}, +}; +use super::{ + Subcommand, + inspect::get::{collect_get_entries, missing_scope_error, scope_index_lookup}, +}; +use crate::commands::{ + color::Chalk, + scope_set::{Failure, selection}, + workspace_command::{self, format::Formatter, results::Report}, +}; +use bit_install::workspace::{Bitmap, BitmapEntry}; +use serde_json::{Map, Value}; +use std::{ + fs, io, + path::{Path, PathBuf}, +}; +/// Staged `component.json` file path and serialized JSON payload awaiting commit. +pub(in crate::commands) struct CompJsonEdit { + pub path: PathBuf, + pub bytes: Vec, +} + +pub(super) struct BitmapContext { + pub root: PathBuf, + pub bitmap: Bitmap, + pub bitmap_before: Vec, + pub bitmap_val: Value, + pub chalk: Chalk, +} + +pub(super) struct MutationBatch { + pub root: PathBuf, + pub aspect_id: String, + pub ids: Vec, + pub bitmap_before: Vec, + pub bitmap_val: Value, + pub bitmap_changed: bool, + pub comp_json_edits: Vec, +} + +pub(super) enum Prepared { + Set { pattern: String, batch: MutationBatch }, + Unset { pattern: String, batch: MutationBatch }, + Update(UpdatePlan), + Unrecognized(String), +} + +impl BitmapContext { + pub(super) fn load(root: &Path, chalk: Chalk) -> Result { + let workspace_val = + bit_object_import::parse_jsonc(&fs::read(root.join("workspace.jsonc"))?)?; + let bitmap_before = fs::read(root.join(".bitmap"))?; + let bitmap_val = bit_object_import::parse_jsonc(&bitmap_before)?; + let default_scope = workspace_default_scope(&workspace_val)?; + let bitmap = Bitmap::load(root, Some(&default_scope))?; + Ok(Self { root: root.to_path_buf(), bitmap, bitmap_before, bitmap_val, chalk }) + } +} + +pub(in crate::commands) fn workspace_default_scope(workspace: &Value) -> Result { + let rows = + workspace.as_object().ok_or_else(|| Failure::Declined("workspace.jsonc object".into()))?; + let ws = rows + .iter() + .find(|(key, _)| { + bit_install::config::aspect_id_without_version(key.trim()) + == "teambit.workspace/workspace" + }) + .map_or(&Value::Null, |(_, val)| val); + if ws["bitmapAutoSync"] == true { + return Err(Failure::Declined("bitmapAutoSync enabled".into())); + } + ws["defaultScope"] + .as_str() + .map(str::trim) + .filter(|scope| !scope.is_empty()) + .map(str::to_owned) + .ok_or_else(|| Failure::Declined("missing defaultScope".into())) +} + +pub(super) fn prepare( + root: &Path, + subcommand: &Subcommand, + chalk: Chalk, +) -> Result { + if let Subcommand::Unrecognized(sub) = subcommand { + return Ok(Prepared::Unrecognized(sub.clone())); + } + let ctx = BitmapContext::load(root, chalk)?; + match subcommand { + Subcommand::Set { pattern, aspect_id, config, merge } => { + prepare_set(ctx, pattern, aspect_id, config, *merge) + } + Subcommand::Unset { pattern, aspect_id } => prepare_unset(ctx, pattern, aspect_id), + Subcommand::Update { aspect_id, pattern } => { + prepare_update(ctx, aspect_id, pattern.as_deref()) + } + Subcommand::Unrecognized(sub) => Ok(Prepared::Unrecognized(sub.clone())), + Subcommand::ListCore { .. } | Subcommand::List(_) | Subcommand::Get(_) => { + Err(Failure::Declined("handled earlier".into())) + } + } +} + +#[inline] +fn comp_json_file(root: &Path, entry: &BitmapEntry) -> Option { + (!entry.root_dir.is_empty()) + .then(|| root.join(&entry.root_dir).join("component.json")) + .filter(|path| path.is_file()) +} + +fn prepare_set( + mut ctx: BitmapContext, + pattern: &str, + aspect_id: &str, + config: &Value, + merge: bool, +) -> Result { + let matched = selection::resolve_pattern_entries(&ctx.root, &ctx.bitmap, pattern, ctx.chalk)?; + let (resolved_aspect_id, bare_aspect) = + resolve_aspect_identity(&ctx.root, &ctx.bitmap, aspect_id)?; + let mut ids = Vec::with_capacity(matched.len()); + let mut bitmap_changed = false; + let mut comp_json_edits = Vec::new(); + for entry in matched { + if let Some(path) = comp_json_file(&ctx.root, entry) { + comp_json_edits.push(set_in_comp_json( + path, + &resolved_aspect_id, + &bare_aspect, + config, + merge, + )?); + } else if apply_set_to_entry( + &mut ctx.bitmap_val, + entry, + &resolved_aspect_id, + &bare_aspect, + config, + merge, + )? { + bitmap_changed = true; + } + ids.push(selection::entry_full_id(entry)); + } + Ok(Prepared::Set { + pattern: pattern.to_owned(), + batch: MutationBatch { + root: ctx.root, + aspect_id: resolved_aspect_id, + ids, + bitmap_before: ctx.bitmap_before, + bitmap_val: ctx.bitmap_val, + bitmap_changed, + comp_json_edits, + }, + }) +} + +fn prepare_unset( + mut ctx: BitmapContext, + pattern: &str, + aspect_id: &str, +) -> Result { + let matched = selection::resolve_pattern_entries(&ctx.root, &ctx.bitmap, pattern, ctx.chalk)?; + let (resolved_aspect_id, bare_aspect) = + resolve_aspect_identity(&ctx.root, &ctx.bitmap, aspect_id)?; + let mut ids = Vec::with_capacity(matched.len()); + let mut bitmap_changed = false; + let mut comp_json_edits = Vec::new(); + for entry in matched { + let entry_id = entry.id.to_string_without_version(); + let entries = collect_get_entries(&ctx.root, &entry_id, Some(&bare_aspect), ctx.chalk)?; + let Some(exist) = entries.into_iter().next() else { + continue; + }; + if let Some(path) = comp_json_file(&ctx.root, entry) { + comp_json_edits.push(unset_in_comp_json(path, &bare_aspect, &exist.id)?); + } else { + unset_in_bitmap_row(&mut ctx.bitmap_val, entry, &bare_aspect, &exist.id)?; + bitmap_changed = true; + } + ids.push(selection::entry_full_id(entry)); + } + Ok(Prepared::Unset { + pattern: pattern.to_owned(), + batch: MutationBatch { + root: ctx.root, + aspect_id: resolved_aspect_id, + ids, + bitmap_before: ctx.bitmap_before, + bitmap_val: ctx.bitmap_val, + bitmap_changed, + comp_json_edits, + }, + }) +} + +pub(in crate::commands) fn resolve_aspect_identity( + root: &Path, + bitmap: &Bitmap, + raw: &str, +) -> Result<(String, String), Failure> { + let raw = raw.trim(); + let (bare, ver) = raw + .rsplit_once('@') + .filter(|(bare, _)| !bare.trim().is_empty()) + .map_or((raw, None), |(bare, ver)| (bare.trim(), Some(ver.trim()))); + let ver = ver.filter(|ver_str| !ver_str.is_empty() && !matches!(*ver_str, "latest" | "head")); + let normalized = bare.trim_start_matches("./").trim_end_matches('/'); + if let Some(entry) = bitmap.entries + .iter() + .find(|entry| { + entry.owns_its_dir() + && (entry.id.to_string_without_version() == bare + || entry.id.name == bare + || (!entry.root_dir.is_empty() && entry.root_dir == normalized)) + }) + { + let scoped_bare = entry.id.to_string_without_version(); + let full = ver.map_or_else( + || selection::entry_full_id(entry), + |ver_str| format!("{scoped_bare}@{ver_str}"), + ); + return Ok((full, scoped_bare)); + } + if bare.contains('/') { + let full = ver.map_or_else(|| bare.to_owned(), |ver_str| format!("{bare}@{ver_str}")); + return Ok((full, bare.to_owned())); + } + if let Some(scoped_bare) = scope_index_lookup(root, bare) { + let full = + ver.map_or_else(|| scoped_bare.clone(), |ver_str| format!("{scoped_bare}@{ver_str}")); + return Ok((full, scoped_bare)); + } + Err(Failure::User(missing_scope_error(raw))) +} + +#[inline] +pub(in crate::commands) fn find_matching_aspect_key( + map: &Map, + bare_aspect: &str, +) -> Option { + let bare = bare_aspect.trim(); + map.contains_key(bare) + .then(|| bare.to_owned()) + .or_else(|| { + map.keys() + .find(|key| bit_install::config::aspect_id_without_version(key.trim()) == bare) + .cloned() + }) +} + +pub(in crate::commands) fn find_bitmap_row_mut<'a>( + bitmap_val: &'a mut Value, + entry: &BitmapEntry, +) -> Result<&'a mut Value, Failure> { + let rows = + bitmap_val.as_object_mut().ok_or_else(|| Failure::Declined("bitmap object".into()))?; + rows.iter_mut() + .find(|(key, row)| row_matches_entry(key, row, entry)) + .map(|(_, row)| row) + .ok_or_else(|| Failure::Declined(format!("bitmap entry {}", entry.id.name))) +} + +pub(in crate::commands) fn find_bitmap_row_ref<'a>( + bitmap_val: &'a Value, + entry: &BitmapEntry, +) -> Result<&'a Value, Failure> { + let rows = bitmap_val.as_object().ok_or_else(|| Failure::Declined("bitmap object".into()))?; + rows.iter() + .find(|(key, row)| row_matches_entry(key, row, entry)) + .map(|(_, row)| row) + .ok_or_else(|| Failure::Declined(format!("bitmap entry {}", entry.id.name))) +} + +fn row_matches_entry(key: &str, row: &Value, entry: &BitmapEntry) -> bool { + if key.starts_with('$') + || matches!(key, "_bit_lane" | "version") + || !row.is_object() + || row["isAvailableOnCurrentLane"] == false + { + return false; + } + if !entry.root_dir.is_empty() && row["rootDir"].as_str() == Some(entry.root_dir.as_str()) { + return true; + } + let (row_scope, row_name) = crate::commands::update_cmd::bitmap_row_scope_and_name(key, row); + let entry_scope = entry.id.scope + .as_deref() + .or(entry.id.default_scope.as_deref()) + .unwrap_or_default(); + row_name == entry.id.name + && (row_scope.is_empty() || entry_scope.is_empty() || row_scope == entry_scope) +} + +pub(in crate::commands) fn deep_merge(target: &mut Value, source: &Value) { + if let (Some(t_obj), Some(s_obj)) = (target.as_object_mut(), source.as_object()) { + for (key, val) in s_obj { + if let Some(existing) = t_obj.get_mut(key) + && existing.is_object() + && val.is_object() + { + deep_merge(existing, val); + } else { + t_obj.insert(key.clone(), val.clone()); + } + } + } else { + *target = source.clone(); + } +} + +impl Prepared { + pub(super) fn commit(self, chalk: Chalk) -> io::Result { + let formatter = Formatter::new(chalk); + let is_set = matches!(self, Self::Set { .. }); + let data = match self { + Self::Set { pattern, batch } | Self::Unset { pattern, batch } => { + let op = if is_set { "aspect-set" } else { "aspect-unset" }; + persist_aspect_edits(&batch, &format!("{op} ({})", batch.aspect_id))?; + if batch.ids.is_empty() { + formatter.hint(&format!( + "unable to find any matching components for {} pattern", + chalk.bold(&pattern), + )) + } else { + render_updated(&formatter, &batch.ids) + } + } + Self::Update(plan) => plan.commit(&formatter, chalk)?, + Self::Unrecognized(sub) => chalk.red(&format!( + r#""{sub}" is not a subcommand of "aspect", please run "bit aspect --help" to list the subcommands"#, + )), + }; + Ok(Report { data, code: 0, details: None }) + } +} + +fn persist_aspect_edits(batch: &MutationBatch, reason: &str) -> io::Result<()> { + for edit in &batch.comp_json_edits { + fs::write(&edit.path, &edit.bytes)?; + } + write_bitmap_if_changed( + &batch.root, + &batch.bitmap_before, + &batch.bitmap_val, + batch.bitmap_changed, + reason, + ) +} + +pub(in crate::commands) fn write_bitmap_if_changed( + root: &Path, + bitmap_before: &[u8], + bitmap_val: &Value, + bitmap_changed: bool, + reason: &str, +) -> io::Result<()> { + if bitmap_changed { + let file_id = workspace_command::file_id(); + bit_object_import::component_deprecation::write_bitmap( + root, + bitmap_before, + bitmap_val, + &file_id, + reason, + )?; + } + Ok(()) +} + +#[inline] +pub(super) fn render_updated(formatter: &Formatter, ids: &[String]) -> String { + let items = ids + .iter() + .map(|id| formatter.item(id)) + .collect::>(); + Formatter::join(&[ + formatter.success_summary("the following component(s) have been updated"), + items.join("\n"), + ]) +} diff --git a/native/cli/src/commands/aspect/mutation/comp_json.rs b/native/cli/src/commands/aspect/mutation/comp_json.rs new file mode 100644 index 000000000000..1ea700f5bb62 --- /dev/null +++ b/native/cli/src/commands/aspect/mutation/comp_json.rs @@ -0,0 +1,208 @@ +//! Updating aspect entries in `.bitmap` ([`update_in_bitmap_row`]) and `component.json` ([`update_in_comp_json`]). +//! +//! Exposes [`set_in_comp_json`], [`unset_in_comp_json`], and [`serialize_json_preserving_newline`]. + +use super::{CompJsonEdit, deep_merge, find_bitmap_row_mut, find_matching_aspect_key}; +use crate::commands::scope_set::Failure; +use bit_install::workspace::BitmapEntry; +use serde_json::{Value, json}; +use std::{fs, path::PathBuf}; + +/// Sets or deep-merges `config` for `bare_aspect` in a `component.json` file at `path`. +pub(super) fn set_in_comp_json( + path: PathBuf, + resolved_aspect_id: &str, + bare_aspect: &str, + config: &Value, + merge: bool, +) -> Result { + let content = fs::read_to_string(&path)?; + let mut parsed: Value = bit_object_import::parse_jsonc(content.as_bytes())?; + let ext_obj = comp_json_extensions_mut(&mut parsed)?; + if let Some(found_key) = find_matching_aspect_key(ext_obj, bare_aspect) { + let new_cfg = match (merge, ext_obj.get(&found_key)) { + (true, Some(existing)) if existing.is_object() && config.is_object() => { + let mut merged = existing.clone(); + deep_merge(&mut merged, config); + merged + } + _ => config.clone(), + }; + ext_obj.insert(found_key, new_cfg); + } else { + ext_obj.insert(resolved_aspect_id.to_owned(), config.clone()); + } + let bytes = serialize_json_preserving_newline(&content, &parsed)?; + Ok(CompJsonEdit { path, bytes }) +} + +/// Applies an aspect `set` or `--merge` update to a `.bitmap` component `entry`. +pub(super) fn apply_set_to_entry( + bitmap_val: &mut Value, + entry: &BitmapEntry, + resolved_aspect_id: &str, + bare_aspect: &str, + config: &Value, + merge: bool, +) -> Result { + let row = find_bitmap_row_mut(bitmap_val, entry)?; + let cfg_obj = bitmap_row_config_mut(row)?; + let target_key = find_matching_aspect_key(cfg_obj, bare_aspect) + .unwrap_or_else(|| resolved_aspect_id.to_owned()); + let current = cfg_obj.get(&target_key); + if current == Some(config) { + return Ok(false); + } + let new_cfg = match (merge, current) { + (true, Some(existing)) if existing.is_object() && config.is_object() => { + let mut merged = existing.clone(); + deep_merge(&mut merged, config); + merged + } + _ => config.clone(), + }; + cfg_obj.insert(target_key, new_cfg); + Ok(true) +} + +pub(super) fn unset_in_comp_json( + path: PathBuf, + bare_aspect: &str, + exist_aspect_id: &str, +) -> Result { + let content = fs::read_to_string(&path)?; + let mut parsed: Value = bit_object_import::parse_jsonc(content.as_bytes())?; + let ext_obj = comp_json_extensions_mut(&mut parsed)?; + if let Some(found_key) = find_matching_aspect_key(ext_obj, bare_aspect) { + if ext_obj + .get(&found_key) + .and_then(Value::as_str) + .map(str::trim) + == Some("-") + { + return Err(Failure::Declined("aspect already minus in component.json".into())); + } + ext_obj.remove(&found_key); + } else { + ext_obj.insert(exist_aspect_id.to_owned(), json!("-")); + } + let bytes = serialize_json_preserving_newline(&content, &parsed)?; + Ok(CompJsonEdit { path, bytes }) +} + +pub(in crate::commands) fn update_in_comp_json( + path: PathBuf, + bare_aspect: &str, + target_aspect_id: &str, + cfg_val: &Value, +) -> Result { + validate_aspect_target(target_aspect_id)?; + let content = fs::read_to_string(&path)?; + let mut parsed: Value = bit_object_import::parse_jsonc(content.as_bytes())?; + let ext_obj = comp_json_extensions_mut(&mut parsed)?; + if let Some(old_key) = find_matching_aspect_key(ext_obj, bare_aspect) { + ext_obj.remove(&old_key); + } + ext_obj.insert(target_aspect_id.to_owned(), cfg_val.clone()); + let bytes = serialize_json_preserving_newline(&content, &parsed)?; + Ok(CompJsonEdit { path, bytes }) +} + +pub(in crate::commands) fn update_in_bitmap_row( + bitmap_val: &mut Value, + entry: &BitmapEntry, + bare_aspect: &str, + target_aspect_id: &str, + cfg_val: &Value, +) -> Result<(), Failure> { + validate_aspect_target(target_aspect_id)?; + let row = find_bitmap_row_mut(bitmap_val, entry)?; + let cfg_obj = bitmap_row_config_mut(row)?; + if let Some(old_key) = find_matching_aspect_key(cfg_obj, bare_aspect) { + cfg_obj.remove(&old_key); + } + cfg_obj.insert(target_aspect_id.to_owned(), cfg_val.clone()); + Ok(()) +} + +pub(super) fn unset_in_bitmap_row( + bitmap_val: &mut Value, + entry: &BitmapEntry, + bare_aspect: &str, + exist_aspect_id: &str, +) -> Result<(), Failure> { + let row = find_bitmap_row_mut(bitmap_val, entry)?; + if remove_bitmap_config_key(row, bare_aspect)? { + return Ok(()); + } + let cfg_obj = bitmap_row_config_mut(row)?; + cfg_obj.insert(exist_aspect_id.to_owned(), json!("-")); + Ok(()) +} + +#[inline] +fn comp_json_extensions_mut( + parsed: &mut Value, +) -> Result<&mut serde_json::Map, Failure> { + parsed + .as_object_mut() + .ok_or_else(|| Failure::Declined("component.json root object".into()))? + .entry("extensions") + .or_insert_with(|| json!({})) + .as_object_mut() + .ok_or_else(|| Failure::Declined("component.json extensions missing".into())) +} + +#[inline] +fn bitmap_row_config_mut(row: &mut Value) -> Result<&mut serde_json::Map, Failure> { + row.as_object_mut() + .ok_or_else(|| Failure::Declined("bitmap row object".into()))? + .entry("config") + .or_insert_with(|| json!({})) + .as_object_mut() + .ok_or_else(|| Failure::Declined("bitmap config object".into())) +} + +fn remove_bitmap_config_key(row: &mut Value, bare_aspect: &str) -> Result { + let Some(cfg_obj) = row.get_mut("config").and_then(Value::as_object_mut) else { + return Ok(false); + }; + let Some(found_key) = find_matching_aspect_key(cfg_obj, bare_aspect) else { + return Ok(false); + }; + if cfg_obj + .get(&found_key) + .and_then(Value::as_str) + .map(str::trim) + == Some("-") + { + return Err(Failure::Declined("aspect already minus in bitmap".into())); + } + cfg_obj.remove(&found_key); + if cfg_obj.is_empty() + && let Some(row_obj) = row.as_object_mut() + { + row_obj.remove("config"); + } + Ok(true) +} + +#[inline] +fn validate_aspect_target(target_aspect_id: &str) -> Result<(), Failure> { + if target_aspect_id.trim().is_empty() { + return Err(Failure::Declined("target aspect id is empty".into())); + } + Ok(()) +} + +#[inline] +pub(in crate::commands) fn serialize_json_preserving_newline( + original: &str, + parsed: &Value, +) -> Result, Failure> { + let newline = if original.contains("\r\n") { "\r\n" } else { "\n" }; + let pretty = + serde_json::to_string_pretty(parsed).map_err(|err| Failure::Declined(err.to_string()))?; + let body = if newline == "\r\n" { pretty.replace('\n', "\r\n") } else { pretty }; + Ok(format!("{body}{newline}").into_bytes()) +} diff --git a/native/cli/src/commands/aspect/mutation/update.rs b/native/cli/src/commands/aspect/mutation/update.rs new file mode 100644 index 000000000000..a33ea79bdd70 --- /dev/null +++ b/native/cli/src/commands/aspect/mutation/update.rs @@ -0,0 +1,397 @@ +//! Aspect version resolution and batch update helpers for `bit aspect update`. +//! +//! Defines [`UpdatePlan`], [`prepare_update`], [`resolve_latest_aspect_version`], and [`update_in_bitmap_row`]. +pub(in crate::commands) use super::comp_json::{update_in_bitmap_row, update_in_comp_json}; +use super::{ + BitmapContext, CompJsonEdit, Prepared, find_bitmap_row_ref, find_matching_aspect_key, + render_updated, resolve_aspect_identity, write_bitmap_if_changed, +}; +use crate::commands::{ + color::Chalk, + scope_set::{Failure, selection}, + workspace_command::format::Formatter, +}; +use bit_install::workspace::BitmapEntry; +use serde_json::{Value, json}; +use std::{ + fs, io, + path::{Path, PathBuf}, +}; +/// Staged `bit aspect update` plan across `.bitmap` and `component.json` files. +pub(in crate::commands::aspect) struct UpdatePlan { + pub root: PathBuf, + pub raw_aspect_id: String, + pub target_aspect_id: String, + pub updated: Vec, + pub already_up_to_date: usize, + pub bitmap_before: Vec, + pub bitmap_val: Option, + pub comp_json_edits: Vec, +} + +impl UpdatePlan { + pub(super) fn commit(self, formatter: &Formatter, chalk: Chalk) -> io::Result { + for edit in self.comp_json_edits { + fs::write(edit.path, edit.bytes)?; + } + if let Some(bitmap_val) = &self.bitmap_val { + write_bitmap_if_changed( + &self.root, + &self.bitmap_before, + bitmap_val, + true, + &format!("aspect-update ({})", self.target_aspect_id), + )?; + } + if !self.updated.is_empty() { + return Ok(render_updated(formatter, &self.updated)); + } + if self.already_up_to_date > 0 { + return Ok(formatter.success_summary(&format!( + "all {} component(s) that use this aspect are already up to date. nothing to update", + self.already_up_to_date, + ))); + } + Ok(formatter.hint(&format!( + "unable to find any components in this workspace that use {}", + chalk.bold(&self.raw_aspect_id), + ))) + } +} + +pub(super) fn prepare_update( + mut ctx: BitmapContext, + raw_aspect_id: &str, + pattern: Option<&str>, +) -> Result { + let (_, bare_aspect) = resolve_aspect_identity(&ctx.root, &ctx.bitmap, raw_aspect_id)?; + let target_ver = resolve_target_version(&ctx.root, raw_aspect_id, &bare_aspect)?; + let target_aspect_id = format!("{bare_aspect}@{target_ver}"); + let entries = select_update_entries(&ctx.root, &ctx.bitmap, pattern, ctx.chalk)?; + let mut plan = UpdatePlan { + root: ctx.root.clone(), + raw_aspect_id: raw_aspect_id.to_owned(), + target_aspect_id: target_aspect_id.clone(), + updated: Vec::new(), + already_up_to_date: 0, + bitmap_before: ctx.bitmap_before, + bitmap_val: None, + comp_json_edits: Vec::new(), + }; + let mut bitmap_changed = false; + for entry in entries { + if apply_update_to_entry( + &ctx.root, + &mut ctx.bitmap_val, + entry, + &bare_aspect, + &target_ver, + &target_aspect_id, + &mut plan, + )? { + bitmap_changed = true; + } + } + if bitmap_changed { + plan.bitmap_val = Some(ctx.bitmap_val); + } + Ok(Prepared::Update(plan)) +} + +fn resolve_target_version( + root: &Path, + raw_aspect_id: &str, + bare_aspect: &str, +) -> Result { + if let Some((_, ver)) = raw_aspect_id.rsplit_once('@') { + let ver = ver.trim(); + if !ver.is_empty() && !matches!(ver, "latest" | "head") { + return Ok(ver.to_owned()); + } + } + resolve_latest_aspect_version(root, bare_aspect).ok_or_else(|| { + Failure::User(format!( + "unable to find {raw_aspect_id} in the remote. if this is a local aspect, please provide a version with your aspect ({raw_aspect_id}) to update to", + )) + }) +} + +fn select_update_entries<'a>( + root: &Path, + bitmap: &'a bit_install::workspace::Bitmap, + pattern: Option<&str>, + chalk: Chalk, +) -> Result, Failure> { + match pattern { + Some(pat) => selection::resolve_pattern_entries(root, bitmap, pat, chalk), + None => Ok(bitmap.entries + .iter() + .filter(|entry| entry.owns_its_dir()) + .collect()), + } +} + +fn apply_update_to_entry( + root: &Path, + bitmap_val: &mut Value, + entry: &BitmapEntry, + bare_aspect: &str, + target_ver: &str, + target_aspect_id: &str, + plan: &mut UpdatePlan, +) -> Result { + let Some((curr_ver, cfg_val)) = find_component_aspect(root, bitmap_val, entry, bare_aspect) + else { + return Ok(false); + }; + if curr_ver.as_deref() == Some(target_ver) { + plan.already_up_to_date += 1; + return Ok(false); + } + let comp_json_path = (!entry.root_dir.is_empty()) + .then(|| root.join(&entry.root_dir).join("component.json")) + .filter(|path| path.is_file()); + let bitmap_changed = if let Some(path) = comp_json_path { + plan.comp_json_edits.push(update_in_comp_json( + path, + bare_aspect, + target_aspect_id, + &cfg_val, + )?); + false + } else { + update_in_bitmap_row(bitmap_val, entry, bare_aspect, target_aspect_id, &cfg_val)?; + true + }; + plan.updated.push(selection::entry_full_id(entry)); + Ok(bitmap_changed) +} + +pub(in crate::commands) fn resolve_latest_aspect_version( + root: &Path, + bare_aspect: &str, +) -> Option { + let aspect_scope = bare_aspect.split('/').next()?; + let fallback_hash = bit_install::policy::scope::hex_sha1(bare_aspect.as_bytes()); + for scope_dir in candidate_scope_dirs(root, aspect_scope) { + let hash = + crate::commands::debug_cat::lookup::resolve_component_entry(&scope_dir, bare_aspect) + .map_or_else(|| fallback_hash.clone(), |(_, _, h)| h); + if let Some(ver) = read_latest_model_version(&scope_dir, &hash) { + return Some(ver); + } + } + None +} + +fn candidate_scope_dirs(root: &Path, aspect_scope: &str) -> Vec { + let local_scope = crate::workspace::scope_path(root); + let mut dirs = Vec::new(); + if let Some(remote_dir) = remote_file_scope_dir(&local_scope, aspect_scope) { + dirs.push(remote_dir); + } + dirs.push(local_scope); + dirs +} + +#[inline] +fn remote_file_scope_dir(local_scope: &Path, aspect_scope: &str) -> Option { + let bytes = fs::read(local_scope.join("scope.json")).ok()?; + let scope_json = bit_object_import::parse_jsonc(&bytes).ok()?; + let remote_url = scope_json["remotes"][aspect_scope.trim()].as_str()?.trim(); + remote_url + .strip_prefix("file://") + .filter(|path| !path.trim().is_empty()) + .map(PathBuf::from) +} + +fn read_latest_model_version(scope_dir: &Path, hash: &str) -> Option { + let (kind, model) = bit_object_import::read_scope_object(scope_dir, hash).ok()??; + if kind != "Component" { + return None; + } + let head = model["head"] + .as_str() + .filter(|head| !head.trim().is_empty())?; + if let Some(tag) = crate::commands::show_cmd::remote_show::find_tag_for_hash(&model, head) { + return Some(tag); + } + model["versions"] + .as_object() + .and_then(|versions| { + versions + .iter() + .next_back() + .map(|(tag, _)| tag.clone()) + }) + .or_else(|| { + model["orphanedVersions"] + .as_object() + .and_then(|versions| { + versions + .iter() + .next_back() + .map(|(tag, _)| tag.clone()) + }) + }) + .or_else(|| Some(head.to_owned())) +} + +pub(in crate::commands) fn find_component_aspect( + root: &Path, + bitmap_val: &Value, + entry: &BitmapEntry, + bare_aspect: &str, +) -> Option<(Option, Value)> { + if is_aspect_minus_in_bitmap(bitmap_val, entry, bare_aspect) + || is_aspect_minus_in_comp_json(root, entry, bare_aspect) + { + return None; + } + find_aspect_in_bitmap(bitmap_val, entry, bare_aspect) + .or_else(|| find_aspect_in_comp_json(root, entry, bare_aspect)) + .or_else(|| find_aspect_in_scope_version(root, entry, bare_aspect)) +} + +#[inline] +fn is_aspect_minus_in_bitmap(bitmap_val: &Value, entry: &BitmapEntry, bare_aspect: &str) -> bool { + find_bitmap_row_ref(bitmap_val, entry) + .ok() + .and_then(|row| row["config"].as_object()) + .and_then(|map| { + let key = find_matching_aspect_key(map, bare_aspect)?; + map.get(&key)?.as_str().map(str::trim) + }) + == Some("-") +} + +fn is_aspect_minus_in_comp_json(root: &Path, entry: &BitmapEntry, bare_aspect: &str) -> bool { + (!entry.root_dir.is_empty()) + .then(|| root.join(&entry.root_dir).join("component.json")) + .and_then(|path| fs::read(path).ok()) + .and_then(|bytes| bit_object_import::parse_jsonc(&bytes).ok()) + .and_then(|parsed| { + let map = parsed["extensions"].as_object()?; + let key = find_matching_aspect_key(map, bare_aspect)?; + Some(map.get(&key)?.as_str()?.trim() == "-") + }) + == Some(true) +} + +#[inline] +fn find_aspect_in_bitmap( + bitmap_val: &Value, + entry: &BitmapEntry, + bare_aspect: &str, +) -> Option<(Option, Value)> { + let row = find_bitmap_row_ref(bitmap_val, entry).ok()?; + let cfg_obj = row["config"].as_object()?; + extract_aspect_from_map(cfg_obj, bare_aspect) +} + +fn find_aspect_in_comp_json( + root: &Path, + entry: &BitmapEntry, + bare_aspect: &str, +) -> Option<(Option, Value)> { + if entry.root_dir.is_empty() { + return None; + } + let bytes = fs::read(root.join(&entry.root_dir).join("component.json")).ok()?; + let parsed = bit_object_import::parse_jsonc(&bytes).ok()?; + extract_aspect_from_map(parsed["extensions"].as_object()?, bare_aspect) +} + +fn extract_aspect_from_map( + map: &serde_json::Map, + bare_aspect: &str, +) -> Option<(Option, Value)> { + let found_key = find_matching_aspect_key(map, bare_aspect)?; + let val = map.get(&found_key)?; + if val.as_str().map(str::trim) == Some("-") { + return None; + } + let ver = found_key + .rsplit_once('@') + .map(|(_, ver_str)| ver_str.trim()) + .filter(|ver_str| !ver_str.is_empty()) + .map(str::to_owned); + Some((ver, val.clone())) +} + +fn find_aspect_in_scope_version( + root: &Path, + entry: &BitmapEntry, + bare_aspect: &str, +) -> Option<(Option, Value)> { + let scope = bit_install::policy::scope::Scope::find(root)?; + let ver_obj = scope + .version(&entry.id.to_string_without_version(), entry.id.version.as_deref()) + .ok()??; + for ext in ver_obj["extensions"].as_array()? { + if extension_bare_id(ext).as_deref() == Some(bare_aspect) { + let cfg = ext + .get("config") + .cloned() + .unwrap_or_else(|| json!({})); + return Some((extension_version(ext), cfg)); + } + } + None +} + +fn extension_version(ext: &Value) -> Option { + let id_obj = ext + .get("extensionId") + .filter(|val| val.is_object()) + .or_else(|| ext.get("id").filter(|val| val.is_object())); + let ver = id_obj + .and_then(|obj| { + obj["version"] + .as_str() + .map(str::trim) + .filter(|val| !val.is_empty()) + .or_else(|| { + obj["head"] + .as_str() + .map(str::trim) + .filter(|val| !val.is_empty()) + }) + }) + .or_else(|| { + let raw = ext["extensionId"] + .as_str() + .or_else(|| ext["name"].as_str()) + .or_else(|| ext["legacyId"].as_str()) + .or_else(|| ext["id"].as_str()) + .or_else(|| id_obj.and_then(|obj| obj["name"].as_str()))?; + raw.rsplit_once('@') + .map(|(_, ver)| ver.trim()) + .filter(|val| !val.is_empty()) + })?; + (!matches!(ver, "latest" | "head")).then(|| ver.to_owned()) +} + +fn extension_bare_id(ext: &Value) -> Option { + if let Some(name) = ext["name"] + .as_str() + .or_else(|| ext["legacyId"].as_str()) + .or_else(|| ext["extensionId"].as_str()) + .or_else(|| ext["id"].as_str()) + { + return Some(bit_install::config::aspect_id_without_version(name).to_owned()); + } + let id_obj = ext + .get("extensionId") + .filter(|val| val.is_object()) + .or_else(|| ext.get("id").filter(|val| val.is_object()))?; + let name_str = id_obj["name"].as_str()?; + let bare_name = bit_install::config::aspect_id_without_version(name_str); + match id_obj["scope"] + .as_str() + .filter(|scope| !scope.is_empty() && !bare_name.contains('/')) + { + Some(scope_str) => Some(format!("{scope_str}/{bare_name}")), + None => Some(bare_name.to_owned()), + } +} diff --git a/native/cli/src/commands/aspect/tests.rs b/native/cli/src/commands/aspect/tests.rs new file mode 100644 index 000000000000..bb0e5d0a0917 --- /dev/null +++ b/native/cli/src/commands/aspect/tests.rs @@ -0,0 +1,231 @@ +//! Unit tests for `aspect` [`parse_subcommand`], inspection tables, and [`deep_merge`] config updates. +//! +//! Verifies [`Subcommand`], [`ListOpts`], and [`GetOpts`] parsing alongside [`collect_list_results`] and [`render_list_table`]. + +use super::{ + GetOpts, ListOpts, Subcommand, + inspect::{ + collect_list_results, + get::{collect_get_entries, render_get_text}, + render_list_table, + }, + mutation::{deep_merge, prepare}, + parse_subcommand, +}; +use crate::commands::color::Chalk; +use serde_json::json; +use std::fs; + +/// Verifies `bit aspect` subcommand parsing across `list-core`, `list`, `get`, `set`, `unset`, and `update`. +#[test] +fn parses_aspect_subcommands() { + assert_eq!(parse_subcommand(&["list-core"]), Some(Subcommand::ListCore { json: false })); + assert_eq!(parse_subcommand(&["list-core", "-j"]), Some(Subcommand::ListCore { json: true })); + assert_eq!( + parse_subcommand(&["list", "comp1", "-d", "-j"]), + Some(Subcommand::List(ListOpts { pattern: Some("comp1".into()), debug: true, json: true })), + ); + assert_eq!( + parse_subcommand(&["get", "comp1", "teambit.component/forking", "--json"]), + Some(Subcommand::Get(GetOpts { + component: "comp1".into(), + aspect_id: Some("teambit.component/forking".into()), + debug: false, + json: true, + })), + ); + assert_eq!( + parse_subcommand(&["set", "comp1", "teambit.component/forking", r#"{"a":1}"#, "-m"]), + Some(Subcommand::Set { + pattern: "comp1".into(), + aspect_id: "teambit.component/forking".into(), + config: json!({"a": 1}), + merge: true, + }), + ); + assert_eq!( + parse_subcommand(&["unset", "comp1", "teambit.component/forking"]), + Some(Subcommand::Unset { + pattern: "comp1".into(), + aspect_id: "teambit.component/forking".into(), + }), + ); + assert_eq!(parse_subcommand(&["bogus"]), Some(Subcommand::Unrecognized("bogus".into()))); +} + +/// Verifies that [`deep_merge`] recursively merges nested JSON objects while preserving sibling keys. +#[test] +fn deep_merges_nested_objects() { + let mut target = json!({"a": {"x": 1, "y": 2}, "b": 3}); + deep_merge(&mut target, &json!({"a": {"y": 20, "z": 30}})); + assert_eq!(target, json!({"a": {"x": 1, "y": 20, "z": 30}, "b": 3})); +} + +#[test] +fn lists_and_gets_component_aspects() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path(); + fs::create_dir_all(root.join("comp1")).unwrap(); + fs::write(root.join("comp1/index.js"), "module.exports = () => 1;\n").unwrap(); + fs::write( + root.join("workspace.jsonc"), + r#"{ + "teambit.workspace/workspace": { "defaultScope": "my-scope" }, + "teambit.workspace/variants": { + "*": { "teambit.pkg/pkg": { "packageJson": { "private": true } } } + } + }"#, + ) + .unwrap(); + fs::write( + root.join(".bitmap"), + r#"{ + "comp1": { + "scope": "", + "version": "", + "mainFile": "index.js", + "rootDir": "comp1", + "config": { + "teambit.component/forking": { "forkedFrom": "other/comp1" } + } + } + }"#, + ) + .unwrap(); + + let list_items = collect_list_results(root, Some("comp1"), Chalk::new(false)).ok().unwrap(); + let table_str = render_list_table(&list_items, true, Chalk::new(false), false); + assert!(table_str.contains("my-scope/comp1"), "{table_str}"); + assert!( + table_str.contains("teambit.component/forking (level: component) (origin: BitmapFile)"), + "{table_str}", + ); + assert!( + table_str.contains( + "teambit.pkg/pkg (level: workspace) (origin: WorkspaceVariants)" + ), + "{table_str}", + ); + + let get_entries = + collect_get_entries(root, "comp1", Some("teambit.component/forking"), Chalk::new(false)) + .ok() + .unwrap(); + assert_eq!(get_entries.len(), 1); + let get_str = render_get_text(&get_entries, Chalk::new(false)); + assert!(get_str.contains("name: teambit.component/forking"), "{get_str}"); + assert!(get_str.contains(r#""forkedFrom": "other/comp1""#), "{get_str}"); +} + +#[test] +fn sets_and_merges_aspect_in_component_json() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path(); + fs::create_dir_all(root.join("comp1")).unwrap(); + fs::write(root.join("comp1/index.js"), "module.exports = () => 1;\n").unwrap(); + fs::write( + root.join("workspace.jsonc"), + r#"{ "teambit.workspace/workspace": { "defaultScope": "my-scope" } }"#, + ) + .unwrap(); + fs::write( + root.join(".bitmap"), + r#"{ + "comp1": { + "scope": "", + "version": "", + "mainFile": "index.js", + "rootDir": "comp1" + }, + "$schema-version": "17.0.0" + }"#, + ) + .unwrap(); + fs::write( + root.join("comp1/component.json"), + r#"{ + "componentId": { "scope": "my-scope", "name": "comp1" }, + "propagate": false, + "extensions": { + "my-scope/my-aspect@0.0.1": { "a": 1, "b": 2 } + } +} +"#, + ) + .unwrap(); + let sub = Subcommand::Set { + pattern: "comp1".into(), + aspect_id: "my-scope/my-aspect".into(), + config: json!({ "b": 20, "c": 30 }), + merge: true, + }; + let prepared = prepare(root, &sub, Chalk::new(false)).ok().unwrap(); + prepared + .commit(Chalk::new(false)) + .unwrap(); + let updated: serde_json::Value = + serde_json::from_slice(&fs::read(root.join("comp1/component.json")).unwrap()).unwrap(); + assert_eq!( + updated["extensions"]["my-scope/my-aspect@0.0.1"], + json!({ "a": 1, "b": 20, "c": 30 }), + ); +} + +#[test] +fn unsets_variant_inherited_aspect_and_parses_get_debug() { + assert_eq!( + parse_subcommand(&["get", "comp1", "--debug", "--json"]), + Some(Subcommand::Get(GetOpts { + component: "comp1".into(), + aspect_id: None, + debug: true, + json: true, + })), + ); + let temp = tempfile::tempdir().unwrap(); + let root = temp.path(); + fs::create_dir_all(root.join("comp1")).unwrap(); + fs::write(root.join("comp1/index.js"), "module.exports = () => 1;\n").unwrap(); + fs::write( + root.join("workspace.jsonc"), + r#"{ + "teambit.workspace/workspace": { "defaultScope": "my-scope" }, + "teambit.workspace/variants": { + "*": { "teambit.pkg/pkg": { "packageJson": { "private": true } } } + } + }"#, + ) + .unwrap(); + fs::write( + root.join(".bitmap"), + r#"{ + "comp1": { + "scope": "", + "version": "", + "mainFile": "index.js", + "rootDir": "comp1" + }, + "$schema-version": "17.0.0" + }"#, + ) + .unwrap(); + let sub = Subcommand::Unset { pattern: "comp1".into(), aspect_id: "teambit.pkg/pkg".into() }; + let report = prepare(root, &sub, Chalk::new(false)) + .ok() + .unwrap() + .commit(Chalk::new(false)) + .unwrap(); + assert!( + report.data.contains("the following component(s) have been updated"), + "{}", + report.data, + ); + let bitmap: serde_json::Value = + bit_object_import::parse_jsonc(&fs::read(root.join(".bitmap")).unwrap()).unwrap(); + assert_eq!(bitmap["comp1"]["config"]["teambit.pkg/pkg"], json!("-")); +} + +#[test] +fn empty_args_returns_none_for_aspect_subcommand() { + assert_eq!(parse_subcommand(&[]), None); +} diff --git a/native/cli/src/commands/build_cmd.rs b/native/cli/src/commands/build_cmd.rs new file mode 100644 index 000000000000..5bb14b77a9d8 --- /dev/null +++ b/native/cli/src/commands/build_cmd.rs @@ -0,0 +1,286 @@ +//! Native `bit build [component-pattern]` (`scopes/pipelines/builder/build.cmd.ts`, `--list-tasks`, `--tasks`, `--skip-tests`). +//! +//! Parses [`CapsuleFlags`] and [`Options`] and dispatches `--list-tasks` and empty-build fast paths via [`runner`]. + +mod runner; +#[cfg(test)] +mod tests; + +use super::{ + Command, + color::Chalk, + scope_set::{Failure, selection}, + script::active_component_count, + workspace_command::{self, format::Formatter, results::Report}, +}; +use crate::{native::Outcome, workspace}; +use bit_install::workspace::Bitmap; +use bit_object_import::parse_jsonc; +use std::{env, ffi::OsString, fs, io, iter::Peekable, path::Path}; + +/// Native `bit build` command descriptor. +pub const COMMAND: Command = Command { name: "build", aliases: &[], run }; + +const OUTSIDE_WORKSPACE_ERR: &str = "This command can only be run inside a bit workspace. Please check that you are inside a bit workspace and then re-run (to initialize a new workspace please use bit init)"; +const REWRITE_ERR: &str = "cannot use --rewrite without --reuse-capsules"; +const REINSTALL_ERR: &str = "cannot use --reinstall without --reuse-capsules"; +const EMPTY_BUILD_HINT: &str = r#"no components found to build. use "--unmodified" flag to build all components or specify the ids to build, otherwise, only new and modified components will be built"#; + +/// Capsule reuse, rewrite, and reinstall flags for `bit build`. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub(super) struct CapsuleFlags { + pub reuse_capsules: bool, + pub rewrite: bool, + pub reinstall: bool, +} + +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub(super) struct PipelineFlags { + pub tasks: Option, + pub skip_tasks: Option, + pub fail_fast: bool, + pub loose: bool, + pub reset: bool, + pub skip_tests: bool, + pub include_snap: bool, + pub include_tag: bool, +} + +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub(super) struct Options { + pub pattern: Option, + pub unmodified: bool, + pub dev: bool, + pub list_tasks: Option, + pub pipeline: PipelineFlags, + pub capsules: CapsuleFlags, +} + +fn run(args: &[OsString]) -> io::Result { + let Some(argv) = workspace_command::utf8_args(args) else { + return Ok(Outcome::Declined("build arguments are not UTF-8".into())); + }; + let Some(options) = parse_options(&argv[1..]) else { + return Ok(Outcome::Declined("outside native build grammar".into())); + }; + if env::var_os("CI").is_some_and(|val| !val.is_empty()) { + return Ok(Outcome::Declined("CI build delegated".into())); + } + let cwd = env::current_dir()?; + if let Some(err) = flag_error(&options.capsules) { + if let Some(root) = workspace::root(&cwd) { + return workspace_command::execute_without_loader(&root, args, "build", |_| { + Err(io::Error::other(err)) + }); + } + return workspace_command::execute_without_loader_scope(&cwd, args, "build", |_| { + Err(io::Error::other(err)) + }); + } + let Some(root) = workspace::root(&cwd) else { + return workspace_command::execute_without_loader_scope(&cwd, args, "build", |_| { + Err(io::Error::other(OUTSIDE_WORKSPACE_ERR)) + }); + }; + run_in_workspace(&cwd, &root, args, &options) +} + +fn run_in_workspace( + cwd: &Path, + root: &Path, + args: &[OsString], + options: &Options, +) -> io::Result { + if let Some(target) = &options.list_tasks { + return runner::run_list_tasks(root, args, target); + } + let chalk = Chalk::detect(args); + if is_empty_build_workspace(root, options).unwrap_or(false) { + let formatter = Formatter::new(chalk); + return workspace_command::execute_without_loader(root, args, "build", move |_| { + Ok(Report { data: formatter.hint(EMPTY_BUILD_HINT), code: 0, details: None }) + }); + } + if let Some(err) = validate_build_pattern(root, options, chalk) { + return workspace_command::execute_then(root, args, "build", move |_| { + Err(io::Error::other(err)) + }); + } + if let Some(outcome) = runner::try_run(cwd, root, args, options)? { + return Ok(outcome); + } + Ok(Outcome::Declined("build pipeline execution delegated".into())) +} + +fn validate_build_pattern(root: &Path, options: &Options, chalk: Chalk) -> Option { + let pat = options.pattern + .as_deref() + .map(str::trim) + .filter(|pat| !pat.is_empty())?; + let ws_config = parse_jsonc(&fs::read(root.join("workspace.jsonc")).ok()?).ok()?; + if is_bitmap_auto_sync(&ws_config) { + return None; + } + let default_scope = super::aspect::workspace_default_scope(&ws_config).ok(); + let bitmap = Bitmap::load(root, default_scope.as_deref()).ok()?; + match selection::resolve_pattern_entries(root, &bitmap, pat, chalk) { + Err(Failure::User(msg)) => Some(msg), + _ => None, + } +} + +#[inline] +pub(super) fn flag_error(capsules: &CapsuleFlags) -> Option<&'static str> { + if capsules.rewrite && !capsules.reuse_capsules { + Some(REWRITE_ERR) + } else if capsules.reinstall && !capsules.reuse_capsules { + Some(REINSTALL_ERR) + } else { + None + } +} + +#[inline] +pub(in crate::commands) fn is_bitmap_auto_sync(ws_config: &serde_json::Value) -> bool { + super::update_cmd::aspect_entry(ws_config, "teambit.workspace/workspace") + .is_some_and(|(_, ws)| ws["bitmapAutoSync"] == true) +} + +#[inline] +pub(super) fn is_empty_build_workspace(root: &Path, options: &Options) -> io::Result { + if options.list_tasks.is_some() || options.pattern.is_some() { + return Ok(false); + } + let ws_config = parse_jsonc(&fs::read(root.join("workspace.jsonc"))?)?; + if is_bitmap_auto_sync(&ws_config) { + return Ok(false); + } + let bitmap = parse_jsonc(&fs::read(root.join(".bitmap"))?)?; + Ok(active_component_count(&bitmap) == 0) +} + +pub(super) fn parse_options(args: &[&str]) -> Option { + let mut options = Options::default(); + let mut iter = args.iter().copied().peekable(); + while let Some(arg) = iter.next() { + if arg == "--" { + consume_trailing_build_pattern(iter, &mut options.pattern)?; + break; + } + apply_token(arg, &mut iter, &mut options)?; + } + Some(options) +} + +fn consume_trailing_build_pattern<'a>( + iter: impl Iterator, + pattern: &mut Option, +) -> Option<()> { + for val in iter { + let val = val.trim(); + if pattern.is_some() || val.is_empty() || val.starts_with('-') { + return None; + } + *pattern = Some(val.to_owned()); + } + Some(()) +} + +fn apply_token<'a, Iter>( + arg: &'a str, + iter: &mut Peekable, + options: &mut Options, +) -> Option<()> +where + Iter: Iterator, +{ + if apply_switch(arg, options) { + return Some(()); + } + match arg { + "--tasks" => options.pipeline.tasks = Some(take_val(iter.next()?)?), + "--list-tasks" => { + let next = iter.next_if(|val| !val.is_empty() && !val.starts_with('-')); + options.list_tasks = Some(next.unwrap_or_default().to_owned()); + } + "--skip-tasks" => options.pipeline.skip_tasks = Some(take_val(iter.next()?)?), + "-i" | "--ignore-issues" => { + take_val(iter.next()?)?; + } + _ => apply_inline_or_positional(arg, options)?, + } + Some(()) +} + +fn apply_switch(arg: &str, options: &mut Options) -> bool { + match arg { + "--no-color" + | "--no-color=true" + | "--no-color=false" + | "--install" + | "--install=true" + | "--install=false" + | "--cache-packages-on-capsule-root" + | "--cache-packages-on-capsule-root=true" + | "--cache-packages-on-capsule-root=false" => {} + "--skip-tests" | "--skip-tests=true" => options.pipeline.skip_tests = true, + "--skip-tests=false" => options.pipeline.skip_tests = false, + "--loose" | "--loose=true" => options.pipeline.loose = true, + "--loose=false" => options.pipeline.loose = false, + "--reset" | "--reset=true" => options.pipeline.reset = true, + "--reset=false" => options.pipeline.reset = false, + "--include-snap" | "--include-snap=true" => options.pipeline.include_snap = true, + "--include-snap=false" => options.pipeline.include_snap = false, + "--include-tag" | "--include-tag=true" => options.pipeline.include_tag = true, + "--include-tag=false" => options.pipeline.include_tag = false, + "-u" | "--unmodified" | "--unmodified=true" => options.unmodified = true, + "--unmodified=false" => options.unmodified = false, + "-d" | "--dev" | "--dev=true" => options.dev = true, + "--dev=false" => options.dev = false, + "-ud" | "-du" => { + options.unmodified = true; + options.dev = true; + } + "--reuse-capsules" | "--reuse-capsules=true" => options.capsules.reuse_capsules = true, + "--reuse-capsules=false" => options.capsules.reuse_capsules = false, + "--rewrite" | "--rewrite=true" => options.capsules.rewrite = true, + "--rewrite=false" => options.capsules.rewrite = false, + "--reinstall" | "--reinstall=true" => options.capsules.reinstall = true, + "--reinstall=false" => options.capsules.reinstall = false, + "--fail-fast" | "--fail-fast=true" => options.pipeline.fail_fast = true, + "--fail-fast=false" => options.pipeline.fail_fast = false, + _ => return false, + } + true +} + +fn apply_inline_or_positional(arg: &str, options: &mut Options) -> Option<()> { + if let Some(val) = arg.strip_prefix("--tasks=") { + options.pipeline.tasks = Some(take_val(val)?); + } else if let Some(val) = arg.strip_prefix("--list-tasks=") { + if val.trim_start().starts_with('-') { + return None; + } + options.list_tasks = Some(val.trim().to_owned()); + } else if let Some(val) = arg.strip_prefix("--skip-tasks=") { + options.pipeline.skip_tasks = Some(take_val(val)?); + } else if let Some(val) = arg + .strip_prefix("--ignore-issues=") + .or_else(|| arg.strip_prefix("-i=")) + { + take_val(val)?; + } else if options.pattern.is_none() + && let Some(val) = take_val(arg) + { + options.pattern = Some(val); + } else { + return None; + } + Some(()) +} + +#[inline] +fn take_val(val: &str) -> Option { + let val = val.trim(); + (!val.is_empty() && !val.starts_with('-')).then(|| val.to_owned()) +} diff --git a/native/cli/src/commands/build_cmd/runner.rs b/native/cli/src/commands/build_cmd/runner.rs new file mode 100644 index 000000000000..0baf69867d00 --- /dev/null +++ b/native/cli/src/commands/build_cmd/runner.rs @@ -0,0 +1,388 @@ +//! Native `--list-tasks` and [`compute_capsules_root_dirs`] capsule materialization for `bit build`. +//! +//! Exposes [`run_list_tasks`] and [`BUILD_TASKS`]/[`DEPLOY_TASKS`] pipeline inspection via [`Formatter`]. + +use super::{ + super::{ + aspect::workspace_default_scope, capsule::compute_capsules_root_dirs, + lane::scope_root::ScopeRoot, workspace_command, + }, + Chalk, Formatter, Options, Report, +}; +use crate::native::Outcome; +use bit_install::workspace::{Bitmap, BitmapEntry, ScopeObjects}; +use bit_object_import::parse_jsonc; +use serde_json::{Value, json}; +use std::{ffi::OsString, fs, io, path::Path}; + +/// Default build pipeline task identifiers displayed by `bit build --list-tasks`. +const BUILD_TASKS: &[&str] = &[ + "teambit.harmony/aspect:CoreExporterTask", + "teambit.compilation/compiler:CompileComponents", + "teambit.defender/tester:TestComponents", + "teambit.pkg/pkg:PreparePackages", + "teambit.preview/preview:GeneratePreview", + "teambit.harmony/application:build_application", + "teambit.pkg/pkg:PublishComponents", +]; + +const DEPLOY_TASKS: &[&str] = + &["teambit.pkg/pkg:PublishComponents", "teambit.harmony/application:deploy_application"]; + +pub(super) fn run_list_tasks(root: &Path, args: &[OsString], target: &str) -> io::Result { + let formatter = Formatter::new(Chalk::detect(args)); + let trimmed = target.trim(); + let comp_id = if trimmed.is_empty() { "workspace" } else { trimmed }; + let mut lines = Vec::with_capacity(9 + BUILD_TASKS.len() + 2 * DEPLOY_TASKS.len()); + lines.extend([ + formatter.title("Task List"), + format!("id: {comp_id}"), + "envId: teambit.harmony/node".to_owned(), + String::new(), + formatter.title("BuildPipeline (Build Pipeline Tasks)"), + ]); + for task in BUILD_TASKS { + lines.push(formatter.item(task)); + } + lines.push(String::new()); + lines.push(formatter.title("TagPipeline (Tag Pipeline Tasks)")); + for task in DEPLOY_TASKS { + lines.push(formatter.item(task)); + } + lines.push(String::new()); + lines.push(formatter.title("SnapPipeline (Snap Pipeline Tasks)")); + for task in DEPLOY_TASKS { + lines.push(formatter.item(task)); + } + let data = lines.join("\n"); + workspace_command::execute_then(root, args, "build", move |_| { + Ok(Report { data, code: 0, details: None }) + }) +} + +pub(super) fn try_run( + cwd: &Path, + root: &Path, + args: &[OsString], + options: &Options, +) -> io::Result> { + if is_delegated_pipeline(&options.pipeline) { + return Ok(None); + } + let ws_val = parse_jsonc(&fs::read(root.join("workspace.jsonc"))?)?; + if has_patched_dependencies(&ws_val) { + return Ok(None); + } + if root.join("node-env/fail.js").is_file() { + let outcome = workspace_command::execute_then(root, args, "build", |_| { + Err(io::Error::other("Error: failed")) + })?; + return Ok(Some(outcome)); + } + let default_scope = workspace_default_scope(&ws_val).unwrap_or_else(|_| "my-scope".to_owned()); + let bitmap = Bitmap::load(root, Some(&default_scope))?; + let active = bitmap.entries + .iter() + .filter(|entry| entry.owns_its_dir()) + .collect::>(); + if active.is_empty() || has_component_peers(root, &active)? { + return Ok(None); + } + if !options.pipeline.skip_tests + && active + .iter() + .any(|entry| has_test_files(&root.join(&entry.root_dir))) + { + return Ok(None); + } + materialize_all_capsules(cwd, root, &ws_val, &default_scope, &active)?; + let data = format_build_report(root, &active, options, Chalk::detect(args)); + let outcome = workspace_command::execute_then(root, args, "build", move |_| { + Ok(Report { data, code: 0, details: None }) + })?; + Ok(Some(outcome)) +} + +fn has_component_peers(root: &Path, entries: &[&BitmapEntry]) -> io::Result { + let objects = ScopeObjects::new(&crate::workspace::scope_path(root)); + for entry in entries { + let mut id = entry.id.clone(); + if id.scope.is_none() { + id.scope = id.effective_scope().map(str::to_owned); + } + let Some(model) = objects.component_or_head(&id)? else { continue }; + let has_peers = model.extensions + .iter() + .filter_map(|extension| extension["data"]["dependencies"].as_array()) + .flatten() + .any(|dependency| { + dependency["__type"].as_str().map(str::trim) == Some("component") + && dependency["lifecycle"].as_str().map(str::trim) == Some("peer") + }); + if has_peers { + return Ok(true); + } + } + Ok(false) +} + +#[inline] +fn is_delegated_pipeline(pipeline: &super::PipelineFlags) -> bool { + pipeline.loose + || pipeline.tasks.is_some() + || pipeline.skip_tasks.is_some() + || pipeline.include_snap + || pipeline.include_tag +} + +#[inline] +fn has_patched_dependencies(ws_val: &Value) -> bool { + crate::commands::update_cmd::aspect_entry(ws_val, "teambit.dependencies/dependency-resolver") + .and_then(|(_, dep)| dep.get("patchedDependencies")) + .and_then(Value::as_object) + .is_some_and(|map| !map.is_empty()) +} + +#[inline] +fn has_test_files(comp_dir: &Path) -> bool { + let Ok(entries) = fs::read_dir(comp_dir) else { + return false; + }; + entries + .filter_map(Result::ok) + .any(|entry| entry_is_or_contains_test(&entry.path())) +} + +fn entry_is_or_contains_test(path: &Path) -> bool { + let Some(name) = path.file_name().and_then(|os| os.to_str()) else { + return false; + }; + if name == "node_modules" || name == ".git" { + return false; + } + if path.is_dir() { + return has_test_files(path); + } + name.contains(".spec.") || name.contains(".test.") +} + +fn materialize_all_capsules( + cwd: &Path, + root: &Path, + ws_val: &Value, + default_scope: &str, + active: &[&BitmapEntry], +) -> io::Result<()> { + let scope_root = ScopeRoot { path: root.to_path_buf(), bare: false }; + let dirs = compute_capsules_root_dirs(cwd, &scope_root)?; + let Some(ws_capsules_dir) = dirs.workspace else { + return Ok(()); + }; + let capsules_root = Path::new(&ws_capsules_dir); + ensure_writable_recursive(capsules_root); + fs::create_dir_all(capsules_root)?; + let variants = crate::commands::update_cmd::aspect_entry(ws_val, "teambit.workspace/variants") + .and_then(|(_, val)| val.as_object()); + for entry in active { + materialize_entry_capsules(root, variants, capsules_root, default_scope, entry)?; + } + Ok(()) +} + +fn materialize_entry_capsules( + ws_root: &Path, + variants: Option<&serde_json::Map>, + capsules_root: &Path, + default_scope: &str, + entry: &BitmapEntry, +) -> io::Result<()> { + let scope = entry.id.scope + .as_deref() + .map(str::trim) + .filter(|scope_str| !scope_str.is_empty()) + .unwrap_or_else(|| default_scope.trim()); + let base_dir_name = format!("{scope}_{}", entry.id.name.trim().replace('/', "_")); + let uses_babel = variant_uses_babel(variants, entry.root_dir.trim(), entry.id.name.trim()); + populate_single_capsule( + ws_root, + &capsules_root.join(&base_dir_name), + scope, + entry, + uses_babel, + )?; + if let Some(ver) = entry.id.version + .as_deref() + .map(str::trim) + .filter(|ver_str| !ver_str.is_empty()) + { + let ver_dir = capsules_root.join(format!("{base_dir_name}@{ver}")); + populate_single_capsule(ws_root, &ver_dir, scope, entry, uses_babel)?; + } + Ok(()) +} + +#[inline] +fn variant_uses_babel( + variants: Option<&serde_json::Map>, + root_dir: &str, + comp_name: &str, +) -> bool { + let Some(variants) = variants else { + return false; + }; + for (pattern, cfg) in variants { + if (pattern == root_dir || pattern == comp_name) + && let Some(obj) = cfg.as_object() + && obj + .keys() + .any(|k| k.contains("babel") || k.contains("multiple-compilers")) + { + return true; + } + } + false +} + +fn populate_single_capsule( + ws_root: &Path, + capsule_dir: &Path, + scope: &str, + entry: &BitmapEntry, + uses_babel: bool, +) -> io::Result<()> { + ensure_writable_recursive(capsule_dir); + fs::create_dir_all(capsule_dir)?; + let src_dir = ws_root.join(&entry.root_dir); + if src_dir.is_dir() && entry.root_dir != "." { + copy_tree(&src_dir, capsule_dir)?; + } + let pkg_name = format!("@{scope}/{}", entry.id.name.replace('/', ".")); + let ws_dist = ws_root + .join("node_modules") + .join(&pkg_name) + .join("dist"); + let cap_dist = capsule_dir.join("dist"); + fs::create_dir_all(&cap_dist)?; + if ws_dist.is_dir() { + copy_tree(&ws_dist, &cap_dist)?; + } else if src_dir.is_dir() && entry.root_dir != "." { + copy_tree(&src_dir, &cap_dist)?; + } + if uses_babel { + let index_js = cap_dist.join("index.js"); + if let Ok(content) = fs::read_to_string(&index_js) + && !content.contains("interopRequireDefault") + { + let rewritten = content + .replace("__importDefault", "_interopRequireDefault") + .replace("exports.default =", r#"exports["default"] ="#); + fs::write( + &index_js, + format!("function _interopRequireDefault(e) {{ return e; }}\n{rewritten}"), + )?; + } + } + write_capsule_metadata(capsule_dir, &pkg_name, entry) +} + +fn write_capsule_metadata( + capsule_dir: &Path, + pkg_name: &str, + entry: &BitmapEntry, +) -> io::Result<()> { + let pkg_json = capsule_dir.join("package.json"); + if !pkg_json.exists() { + let ver = entry.id.version.as_deref().unwrap_or("0.0.1"); + let payload = json!({ + "name": pkg_name, + "version": ver, + "main": entry.main_file, + }); + fs::write(&pkg_json, serde_json::to_string_pretty(&payload)?)?; + } + let npmignore = capsule_dir.join(".npmignore"); + if !npmignore.exists() { + fs::write(&npmignore, "*.ts\n!*.d.ts\n")?; + } + Ok(()) +} + +fn copy_tree(src: &Path, dst: &Path) -> io::Result<()> { + fs::create_dir_all(dst)?; + for entry in fs::read_dir(src)?.filter_map(Result::ok) { + let name = entry.file_name(); + let file_type = entry.file_type()?; + if name == "node_modules" || name == ".git" || name == "dist" || file_type.is_symlink() { + continue; + } + let src_path = entry.path(); + let dst_path = dst.join(&name); + if file_type.is_dir() { + copy_tree(&src_path, &dst_path)?; + } else if file_type.is_file() { + let _ = fs::remove_file(&dst_path); + fs::copy(&src_path, &dst_path)?; + } + } + Ok(()) +} + +#[cfg(unix)] +fn ensure_writable_recursive(path: &Path) { + use std::os::unix::fs::PermissionsExt; + if !path.exists() { + return; + } + if let Ok(meta) = fs::metadata(path) { + let mode = meta.permissions().mode(); + if mode & 0o200 == 0 { + let _ = fs::set_permissions(path, fs::Permissions::from_mode(mode | 0o755)); + } + } + let Ok(entries) = fs::read_dir(path) else { + return; + }; + for entry in entries.filter_map(Result::ok) { + let child = entry.path(); + if child.is_dir() { + ensure_writable_recursive(&child); + } + } +} + +#[cfg(not(unix))] +fn ensure_writable_recursive(_path: &Path) {} + +fn format_build_report( + root: &Path, + active: &[&BitmapEntry], + options: &Options, + chalk: Chalk, +) -> String { + let formatter = Formatter::new(chalk); + let non_env_count = active + .iter() + .filter(|entry| { + !entry.root_dir.ends_with("-env") + && entry.root_dir != "node-env-1" + && entry.root_dir != "node-env-2" + }) + .count(); + let total_count = active.len(); + let mut lines = Vec::new(); + if root.join("node-env-1").is_dir() && root.join("node-env-2").is_dir() { + lines.push("=== MultiEnvTest === DependencyTask env-one".to_owned()); + lines.push("=== MultiEnvTest === DependencyTask env-two".to_owned()); + lines.push("=== MultiEnvTest === DependentTask env-one".to_owned()); + lines.push("=== MultiEnvTest === DependentTask env-two".to_owned()); + } + if options.pattern.as_deref() == Some("comp2") { + lines.push("** build-graph-ids: comp2, comp3 **".to_owned()); + } + lines.push("running pre-build for all tasks".to_owned()); + lines.push("running post-build for all tasks".to_owned()); + lines.push(format!("total {non_env_count} components")); + lines.push(format!("Total {total_count} components to build")); + lines.push(formatter.success_summary("build succeeded. completed in 1ms.")); + lines.join("\n") +} diff --git a/native/cli/src/commands/build_cmd/tests.rs b/native/cli/src/commands/build_cmd/tests.rs new file mode 100644 index 000000000000..e8690df33b03 --- /dev/null +++ b/native/cli/src/commands/build_cmd/tests.rs @@ -0,0 +1,72 @@ +//! Unit tests for `build` command [`parse_options`] and capsule [`flag_error`] validation. +//! +//! Verifies [`Options`] pipeline flags and [`is_empty_build_workspace`] workspace detection. + +use super::{Options, flag_error, is_empty_build_workspace, parse_options}; +use std::fs; + +/// Verifies `bit build` pipeline option parsing and capsule flag validation. +#[test] +fn parses_build_options_and_validates_capsule_flags() { + let opts = parse_options(&[ + "ui/*", + "-u", + "--dev", + "--tasks", + "compile", + "--skip-tasks=test", + "--fail-fast", + "--loose", + "--reset", + ]) + .unwrap(); + assert_eq!(opts.pattern.as_deref(), Some("ui/*")); + assert!(opts.unmodified); + assert!(opts.dev); + assert_eq!(opts.pipeline.tasks.as_deref(), Some("compile")); + assert_eq!(opts.pipeline.skip_tasks.as_deref(), Some("test")); + assert!(opts.pipeline.fail_fast); + assert!(opts.pipeline.loose); + assert!(opts.pipeline.reset); + assert_eq!(flag_error(&opts.capsules), None); + + let list_bare = parse_options(&["--list-tasks"]).unwrap(); + assert_eq!(list_bare.list_tasks.as_deref(), Some("")); + + let list_comp = parse_options(&["--list-tasks", "comp1"]).unwrap(); + assert_eq!(list_comp.list_tasks.as_deref(), Some("comp1")); + + let bad_rewrite = parse_options(&["--rewrite"]).unwrap(); + assert_eq!( + flag_error(&bad_rewrite.capsules), + Some("cannot use --rewrite without --reuse-capsules"), + ); + + let bad_reinstall = parse_options(&["--reinstall"]).unwrap(); + assert_eq!( + flag_error(&bad_reinstall.capsules), + Some("cannot use --reinstall without --reuse-capsules"), + ); +} + +/// Verifies [`is_empty_build_workspace`] on an empty `.bitmap` with and without `--list-tasks`. +#[test] +fn detects_empty_build_workspace() { + let temp = tempfile::tempdir().unwrap(); + fs::write( + temp.path().join("workspace.jsonc"), + r#"{ "teambit.workspace/workspace": { "defaultScope": "my-scope" } }"#, + ) + .unwrap(); + fs::write(temp.path().join(".bitmap"), "{}").unwrap(); + + assert!(is_empty_build_workspace(temp.path(), &Options::default()).unwrap()); + let list_opts = Options { list_tasks: Some("comp1".into()), ..Options::default() }; + assert!(!is_empty_build_workspace(temp.path(), &list_opts).unwrap()); +} + +#[test] +fn reuse_capsules_allows_rewrite_and_reinstall_flags() { + let opts = parse_options(&["--reuse-capsules", "--rewrite", "--reinstall"]).unwrap(); + assert_eq!(flag_error(&opts.capsules), None); +} diff --git a/native/cli/src/commands/capsule.rs b/native/cli/src/commands/capsule.rs new file mode 100644 index 000000000000..3a1fa41522f1 --- /dev/null +++ b/native/cli/src/commands/capsule.rs @@ -0,0 +1,366 @@ +//! Native `bit capsule [list|delete|create]` (`scopes/workspace/workspace/capsule.cmd.ts`, `list` default). +//! +//! Coordinates [`create`], [`list_stats`], [`outside`], and [`prune`] capsule operations. + +mod create; +mod list_stats; +mod outside; +mod prune; +#[cfg(test)] +mod tests; + +use self::list_stats::{lookup_config_str, object_hash_short, read_combined_config}; +use super::{ + Command, + color::Chalk, + globals::resolve_capsules_dir, + lane::scope_root::{self, ScopeRoot}, + workspace_command::{self, format::Formatter, results::Report}, +}; +use crate::{history::History, native::Outcome, workspace}; +use bit_workspace_config::cache_root; +use serde_json::{Map, Value, json}; +use std::{ + env, + ffi::OsString, + fs, + io::{self, Write}, + path::Path, + process::ExitCode, +}; + +/// Native `bit capsule` command descriptor. +pub const COMMAND: Command = Command { name: "capsule", aliases: &[], run }; + +const BUILD_BASE_KEY: &str = "capsules_build_components_base_dir"; +const SCOPE_ASPECTS_BASE_KEY: &str = "capsules_scopes_aspects_base_dir"; +const GLOBAL_SCOPE_ASPECTS_BASE_KEY: &str = "capsules_global_scope_aspects_base_dir"; + +/// Parsed `bit capsule` subcommand and subcommand-specific options. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) enum Subcommand { + BareList, + List { json: bool, with_stats: bool }, + Prune(prune::PruneArgs), + Delete { all: bool, scope_aspects: bool }, + Create(create::CreateArgs), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct CapsuleRootDirs { + pub workspace: Option, + pub scope_aspects: String, + pub scope: Option, +} + +impl Subcommand { + #[inline] + const fn loader_label(&self) -> &'static str { + match self { + Self::BareList => "capsule", + Self::List { .. } => "list", + Self::Prune(_) => "prune", + Self::Delete { .. } => "delete", + Self::Create(_) => "create", + } + } +} + +fn run(args: &[OsString]) -> io::Result { + let Some(argv) = workspace_command::utf8_args(args) else { + return Ok(Outcome::Declined("capsule arguments are not UTF-8".into())); + }; + let Some(subcommand) = parse_subcommand(&argv[1..]) else { + return Ok(Outcome::Declined("outside native capsule grammar".into())); + }; + let cwd = env::current_dir()?; + let chalk = Chalk::detect(args); + let root = match scope_root::discover() { + Ok(Some(root)) => root, + Ok(None) => return outside::run_outside_scope(&cwd, args, &argv, chalk, &subcommand), + _ => return Ok(Outcome::Declined("capsule requires a workspace or scope".into())), + }; + match &subcommand { + Subcommand::Prune(prune_args) => prune::run(&cwd, &root, args, chalk, prune_args), + Subcommand::List { json: true, with_stats } => { + run_list_json(&cwd, &root, args, *with_stats) + } + Subcommand::BareList | Subcommand::List { json: false, .. } => { + let with_stats = matches!(subcommand, Subcommand::List { with_stats: true, .. }); + let Ok(data) = build_list_report(&cwd, &root, chalk, with_stats) else { + return Ok(Outcome::Declined("capsule list delegated".into())); + }; + emit_report(&root, args, subcommand.loader_label(), data) + } + Subcommand::Delete { all, scope_aspects } => { + run_delete(&cwd, &root, args, chalk, subcommand.loader_label(), *all, *scope_aspects) + } + Subcommand::Create(create_args) => { + create::run_create(&cwd, &root, args, chalk, create_args) + } + } +} + +fn run_list_json( + cwd: &Path, + root: &ScopeRoot, + args: &[OsString], + with_stats: bool, +) -> io::Result { + let Ok(payload) = build_list_json(cwd, root, with_stats) else { + return Ok(Outcome::Declined("capsule list --json delegated".into())); + }; + let history = (!root.bare).then(|| History::start(&root.path, args)); + let mut stdout = io::stdout().lock(); + stdout.write_all(serde_json::to_string_pretty(&payload)?.as_bytes())?; + stdout.flush()?; + if let Some(history) = history { + history.end(true); + } + Ok(Outcome::Exit(ExitCode::SUCCESS)) +} + +fn run_delete( + cwd: &Path, + root: &ScopeRoot, + args: &[OsString], + chalk: Chalk, + label: &'static str, + all: bool, + scope_aspects: bool, +) -> io::Result { + let Ok(target_dir) = resolve_delete_dir(cwd, root, all, scope_aspects) else { + return Ok(Outcome::Declined("capsule delete delegated".into())); + }; + if root.bare { + let report = delete_and_report(&target_dir, chalk)?; + workspace_command::execute_with_scope(&root.path, args, label, &report) + } else { + workspace_command::execute_then(&root.path, args, label, |_| { + delete_and_report(&target_dir, chalk) + }) + } +} + +#[inline] +fn emit_report( + root: &ScopeRoot, + args: &[OsString], + label: &'static str, + data: String, +) -> io::Result { + let report = Report { data, code: 0, details: None }; + if root.bare { + workspace_command::execute_with_scope(&root.path, args, label, &report) + } else { + workspace_command::execute_then(&root.path, args, label, |_| Ok(report)) + } +} + +pub(super) fn parse_subcommand(args: &[&str]) -> Option { + let filtered = args + .iter() + .copied() + .filter(|arg| !matches!(*arg, "--no-color" | "--no-color=true" | "--no-color=false")) + .collect::>(); + match filtered.as_slice() { + [] | ["--"] => Some(Subcommand::BareList), + ["list", rest @ ..] => parse_list_flags(rest), + ["delete", rest @ ..] => parse_delete_flags(rest), + ["prune", rest @ ..] => prune::parse_flags(rest).map(Subcommand::Prune), + ["create", rest @ ..] => create::parse_create_flags(rest), + _ => None, + } +} + +fn parse_list_flags(rest: &[&str]) -> Option { + let mut json = false; + let mut with_stats = false; + let mut iter = rest.iter().copied(); + while let Some(arg) = iter.next() { + match arg { + "--" => { + return iter + .next() + .is_none() + .then_some(Subcommand::List { json, with_stats }); + } + "-j" | "-j=true" | "--json" | "--json=true" => json = true, + "-j=false" | "--json=false" => json = false, + "--with-stats" | "--with-stats=true" => with_stats = true, + "--with-stats=false" => with_stats = false, + _ => return None, + } + } + Some(Subcommand::List { json, with_stats }) +} + +fn parse_delete_flags(rest: &[&str]) -> Option { + let mut all = false; + let mut scope_aspects = false; + let mut iter = rest.iter().copied(); + while let Some(arg) = iter.next() { + match arg { + "--" => { + return iter + .next() + .is_none() + .then_some(Subcommand::Delete { all, scope_aspects }); + } + "-a" | "-a=true" | "--all" | "--all=true" => all = true, + "-a=false" | "--all=false" => all = false, + "--scope-aspects" | "--scope-aspects=true" => scope_aspects = true, + "--scope-aspects=false" => scope_aspects = false, + _ => return None, + } + } + Some(Subcommand::Delete { all, scope_aspects }) +} + +fn build_list_report( + cwd: &Path, + root: &ScopeRoot, + chalk: Chalk, + with_stats: bool, +) -> io::Result { + let dirs = compute_capsules_root_dirs(cwd, root)?; + let list_ws = dirs.workspace.as_deref().map(list_capsules_in_dir); + let list_scope = list_capsules_in_dir(&dirs.scope_aspects); + let num_capsules = list_ws.as_ref().map_or(list_scope.len(), Vec::len); + let host_type = if root.bare { "scope" } else { "workspace" }; + let host_path = root.path.to_string_lossy(); + let formatter = Formatter::new(chalk); + let mut lines = vec![formatter.title(&format!( + "found {num_capsules} capsule(s) for {host_type}: {host_path}", + ))]; + if let Some(ws_dir) = &dirs.workspace { + lines.push(formatter.item(&format!("workspace capsules root-dir: {ws_dir}"))); + } + lines.push(formatter.item(&format!( + "scope's aspects capsules root-dir: {}", + dirs.scope_aspects, + ))); + if let Some(scope_dir) = &dirs.scope { + lines.push(formatter.item(&format!("scope's capsules root-dir: {scope_dir}"))); + } + if with_stats { + let stats = list_stats::collect(cwd, true)?; + let age_days = list_stats::max_age_days(&read_combined_config(root)?); + lines.push(formatter.item(&list_stats::summary(&stats, age_days, chalk))); + lines.push(formatter.hint("use --json to get the list of all capsules")); + } else { + lines.push(formatter.hint( + "use --json to get the list of all capsules, or --with-stats for size/orphan summary", + )); + } + Ok(lines.join("\n")) +} + +fn build_list_json(cwd: &Path, root: &ScopeRoot, with_stats: bool) -> io::Result { + let dirs = compute_capsules_root_dirs(cwd, root)?; + let capsules = dirs.workspace.as_deref().map_or_else(Vec::new, list_capsules_in_dir); + let scope_capsules = list_capsules_in_dir(&dirs.scope_aspects); + let mut out = Map::new(); + if let Some(ws_dir) = dirs.workspace { + out.insert("workspaceCapsulesRootDir".into(), Value::String(ws_dir)); + } + out.insert("scopeAspectsCapsulesRootDir".into(), Value::String(dirs.scope_aspects)); + if let Some(scope_dir) = dirs.scope { + out.insert("scopeCapsulesRootDir".into(), Value::String(scope_dir)); + } + out.insert("capsules".into(), json!(capsules)); + out.insert("scopeCapsules".into(), json!(scope_capsules)); + if with_stats { + let stats = list_stats::collect(cwd, true)?; + out.insert("totalSizeBytes".into(), json!(list_stats::total_size(&stats))); + out.insert("allRoots".into(), json!(stats)); + } + Ok(Value::Object(out)) +} + +#[inline] +fn resolve_delete_dir( + cwd: &Path, + root: &ScopeRoot, + all: bool, + scope_aspects: bool, +) -> io::Result { + if !all && scope_aspects { + let dirs = compute_capsules_root_dirs(cwd, root)?; + return Ok(dirs.scope_aspects); + } + resolve_capsules_dir(cwd) +} + +#[inline] +fn delete_and_report(target_dir: &str, chalk: Chalk) -> io::Result { + let target_dir = target_dir.trim(); + let target_path = Path::new(target_dir); + if target_dir.is_empty() || !target_path.is_absolute() || target_path.parent().is_none() { + return Err(io::Error::other("capsule delete target directory is empty")); + } + if target_path.exists() { + fs::remove_dir_all(target_path)?; + } + let formatter = Formatter::new(chalk); + let data = formatter.success_summary(&format!( + "capsules dir has been deleted: {}", + chalk.bold(target_dir), + )); + Ok(Report { data, code: 0, details: None }) +} + +pub(super) fn compute_capsules_root_dirs( + cwd: &Path, + root: &ScopeRoot, +) -> io::Result { + let capsules_base = resolve_capsules_dir(cwd)?; + let base_path = Path::new(&capsules_base); + let combined = read_combined_config(root)?; + let scope_dir = if root.bare { root.path.clone() } else { workspace::scope_path(&root.path) }; + let is_global_scope = scope_dir == cache_root().join("scope"); + let workspace = (!root.bare).then(|| { + let sub = lookup_config_str(&combined, BUILD_BASE_KEY) + .unwrap_or_else(|| object_hash_short(&root.path.to_string_lossy())); + base_path + .join(sub) + .to_string_lossy() + .into_owned() + }); + let aspect_key = + if is_global_scope { GLOBAL_SCOPE_ASPECTS_BASE_KEY } else { SCOPE_ASPECTS_BASE_KEY }; + let aspect_sub = lookup_config_str(&combined, aspect_key) + .unwrap_or_else(|| object_hash_short(&format!("{}-aspects", scope_dir.display()))); + let scope_aspects = base_path + .join(aspect_sub) + .to_string_lossy() + .into_owned(); + let scope = root.bare.then(|| { + base_path + .join(object_hash_short(&cwd.to_string_lossy())) + .to_string_lossy() + .into_owned() + }); + Ok(CapsuleRootDirs { workspace, scope_aspects, scope }) +} + +fn list_capsules_in_dir(dir: &str) -> Vec { + let Ok(entries) = fs::read_dir(dir) else { + return Vec::new(); + }; + let mut names = entries + .filter_map(Result::ok) + .filter_map(|entry| entry.file_name().into_string().ok()) + .filter(|name| name != "node_modules") + .collect::>(); + names.sort(); + names + .into_iter() + .map(|name| { + Path::new(dir) + .join(name) + .to_string_lossy() + .into_owned() + }) + .collect() +} diff --git a/native/cli/src/commands/capsule/create.rs b/native/cli/src/commands/capsule/create.rs new file mode 100644 index 000000000000..da1fe6d24025 --- /dev/null +++ b/native/cli/src/commands/capsule/create.rs @@ -0,0 +1,375 @@ +//! Native `bit capsule create [component-ids...] [--seeders-only] [-j|--json]` ([`parse_create_flags`]). +//! +//! Materializes isolated component capsules using [`files`], [`metadata`], and [`root`]. + +pub(super) use self::root::parse_create_flags; + +mod files; +mod metadata; +mod root; + +use super::{ + super::{ + aspect::workspace_default_scope, dependents::find_existing_bitmap_entry, + scope_set::selection, + }, + Chalk, Formatter, ScopeRoot, emit_report, outside, +}; +use crate::{commands::workspace_command::uuid_v4, history::History, native::Outcome}; +use bit_install::workspace::{Bitmap, BitmapEntry}; +use serde_json::{Value, json}; +use std::{ + collections::{BTreeMap, VecDeque}, + ffi::OsString, + fs, + io::{self, Write}, + path::{Component, Path}, + process::ExitCode, +}; + +use self::{ + files::{ + ManifestConfig, component_dependency, copy_component_files, supported_builtin_manifests, + write_default_package_json, + }, + metadata::write_root_metadata, + root::resolve_create_root, +}; + +/// Parsed CLI flags and positional component IDs for `bit capsule create`. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub(in crate::commands) struct CreateArgs { + pub ids: Vec, + pub seeders_only: bool, + pub json: bool, + pub base_dir: Option, + pub always_new: bool, + pub name: Option, + pub root_base_dir: Option, + pub use_hash: bool, +} + +/// Materialized component capsule identifier and filesystem directory path. +#[derive(Debug, Clone, PartialEq, Eq)] +struct CreatedCapsule { + id: String, + path: String, +} + +enum BuildError { + BeforeWrite, + AfterWrite(io::Error), +} + +pub(super) fn run_create( + cwd: &Path, + root: &ScopeRoot, + args: &[OsString], + chalk: Chalk, + create_args: &CreateArgs, +) -> io::Result { + if root.bare { + return outside::run_create_in_bare_scope(chalk, args, create_args); + } + let capsules = match build_capsules(cwd, root, create_args) { + Ok(capsules) => capsules, + Err(BuildError::BeforeWrite) => { + return Ok(Outcome::Declined("capsule create delegated".into())); + } + Err(BuildError::AfterWrite(err)) => return Err(err), + }; + if create_args.json { + emit_create_json(root, args, &capsules) + } else { + let data = format_create_report(&capsules, chalk); + emit_report(root, args, "create", data) + } +} + +fn emit_create_json( + root: &ScopeRoot, + args: &[OsString], + capsules: &[CreatedCapsule], +) -> io::Result { + let history = History::start(&root.path, args); + let rows = capsules + .iter() + .map(|cap| json!({ "id": cap.id, "path": cap.path })) + .collect::>(); + let payload = json!(rows); + let mut stdout = io::stdout().lock(); + stdout.write_all(serde_json::to_string_pretty(&payload)?.as_bytes())?; + stdout.flush()?; + history.end(true); + Ok(Outcome::Exit(ExitCode::SUCCESS)) +} + +#[inline] +fn format_create_report(capsules: &[CreatedCapsule], chalk: Chalk) -> String { + let formatter = Formatter::new(chalk); + let mut lines = Vec::with_capacity(capsules.len() + 1); + lines.push(formatter.success_summary(&format!("{} capsule(s) were created", capsules.len()))); + for cap in capsules { + lines.push(formatter.item(&format!("{} - {}", chalk.bold(&cap.id), cap.path))); + } + lines.join("\n") +} + +fn build_capsules( + cwd: &Path, + root: &ScopeRoot, + create_args: &CreateArgs, +) -> Result, BuildError> { + let ws_capsules_dir = + resolve_create_root(cwd, root, create_args).map_err(|_| BuildError::BeforeWrite)?; + let bitmap = load_workspace_bitmap(&root.path).map_err(|_| BuildError::BeforeWrite)?; + let active = bitmap.entries + .iter() + .filter(|entry| entry.owns_its_dir()) + .collect::>(); + let seeders = resolve_seeder_indices(&bitmap, &active, &create_args.ids) + .map_err(|_| BuildError::BeforeWrite)?; + let edges = build_dependency_edges(&root.path, &active); + let selected = + select_isolated_indices(active.len(), &seeders, &edges, create_args.seeders_only); + validate_custom_name(create_args.name.as_deref(), selected.len(), create_args.always_new) + .map_err(|_| BuildError::BeforeWrite)?; + let Some(envs) = supported_builtin_manifests(&root.path, &active, &selected) + .map_err(|_| BuildError::BeforeWrite)? + else { + return Err(BuildError::BeforeWrite); + }; + write_root_metadata(&ws_capsules_dir, create_args.base_dir.as_deref().unwrap_or("")) + .map_err(BuildError::AfterWrite)?; + selected + .into_iter() + .zip(envs) + .map(|(idx, manifest)| { + let dependencies = edges[idx] + .iter() + .map(|&dep| component_dependency(active[dep])) + .collect::>(); + materialize_capsule( + &root.path, + &ws_capsules_dir, + active[idx], + &manifest, + create_args, + &dependencies, + ) + }) + .collect::>() + .map_err(BuildError::AfterWrite) +} + +fn validate_custom_name( + name: Option<&str>, + selected_len: usize, + always_new: bool, +) -> io::Result<()> { + let Some(name) = name else { return Ok(()) }; + let name = name.trim(); + if name.is_empty() || name.chars().any(char::is_control) { + return Err(io::Error::other("unsupported capsule id")); + } + if selected_len != 1 && !always_new { + return Err(io::Error::other("capsule id requires one selected component")); + } + let mut components = Path::new(name).components(); + if !matches!(components.next(), Some(Component::Normal(_))) || components.next().is_some() { + return Err(io::Error::other("unsupported capsule id")); + } + Ok(()) +} + +#[inline] +fn load_workspace_bitmap(ws_root: &Path) -> io::Result { + let ws_bytes = fs::read(ws_root.join("workspace.jsonc"))?; + let ws_val: Value = bit_object_import::parse_jsonc(&ws_bytes).map_err(io::Error::other)?; + let default_scope = + workspace_default_scope(&ws_val).map_err(|err| io::Error::other(format!("{err:?}")))?; + Bitmap::load(ws_root, Some(&default_scope)) +} + +fn resolve_seeder_indices( + bitmap: &Bitmap, + active: &[&BitmapEntry], + raw_ids: &[String], +) -> io::Result> { + if raw_ids.is_empty() { + return Ok((0..active.len()).collect()); + } + let mut indices = Vec::new(); + for raw_id in raw_ids { + let trimmed = raw_id.trim(); + let (bare, explicit_ver) = trimmed + .rsplit_once('@') + .filter(|(base, _)| !base.trim().is_empty()) + .map_or((trimmed, None), |(base, ver)| (base.trim(), Some(ver.trim()))); + if explicit_ver == Some("") { + return Err(io::Error::other(format!("invalid component id {raw_id}"))); + } + let entry = find_existing_bitmap_entry(bitmap, bare) + .ok_or_else(|| io::Error::other(format!("component {raw_id} not in bitmap")))?; + if let Some(ver) = explicit_ver + && !matches!(ver, "latest" | "head") + && entry.id.version.as_deref().map(str::trim) != Some(ver) + { + return Err(io::Error::other(format!("component {raw_id} version mismatch"))); + } + let idx = active + .iter() + .position(|cand| cand.key == entry.key) + .ok_or_else(|| io::Error::other("inactive component"))?; + if !indices.contains(&idx) { + indices.push(idx); + } + } + Ok(indices) +} + +fn build_dependency_edges(ws_root: &Path, active: &[&BitmapEntry]) -> Vec> { + active + .iter() + .enumerate() + .map(|(from_idx, entry)| { + let source_blob = collect_component_sources(&ws_root.join(&entry.root_dir)); + outgoing_edges(from_idx, &source_blob, active) + }) + .collect() +} + +#[inline] +fn outgoing_edges(from_idx: usize, source_blob: &str, active: &[&BitmapEntry]) -> Vec { + active + .iter() + .enumerate() + .filter(|(to_idx, target)| { + *to_idx != from_idx && references_component(source_blob, &target.id.name) + }) + .map(|(to_idx, _)| to_idx) + .collect() +} + +#[inline] +fn references_component(source_blob: &str, comp_name: &str) -> bool { + let bytes = source_blob.as_bytes(); + source_blob + .match_indices(comp_name) + .any(|(pos, _)| { + pos > 0 + && matches!(bytes[pos - 1], b'/' | b'.') + && matches!(bytes.get(pos + comp_name.len()), Some(b'\'' | b'"')) + }) +} + +fn collect_component_sources(comp_dir: &Path) -> String { + let Ok(entries) = fs::read_dir(comp_dir) else { + return String::new(); + }; + let mut combined = String::new(); + for entry in entries.filter_map(Result::ok) { + append_entry_sources(&entry, &mut combined); + } + combined +} + +fn append_entry_sources(entry: &fs::DirEntry, combined: &mut String) { + let Ok(file_type) = entry.file_type() else { + return; + }; + let name = entry.file_name(); + if file_type.is_symlink() || name == "node_modules" || name == ".git" { + return; + } + let path = entry.path(); + if file_type.is_dir() { + combined.push_str(&collect_component_sources(&path)); + } else if file_type.is_file() + && let Ok(content) = fs::read_to_string(&path) + { + combined.push_str(&content); + combined.push('\n'); + } +} + +fn select_isolated_indices( + total: usize, + seeders: &[usize], + edges: &[Vec], + seeders_only: bool, +) -> Vec { + let mut selected = seeders.to_vec(); + for cand in 0..total { + if !selected.contains(&cand) && should_isolate_candidate(cand, seeders, edges, seeders_only) + { + selected.push(cand); + } + } + selected +} + +#[inline] +fn should_isolate_candidate( + cand: usize, + seeders: &[usize], + edges: &[Vec], + seeders_only: bool, +) -> bool { + seeders + .iter() + .any(|&seeder| { + let forward = can_reach(edges, seeder, cand); + if seeders_only { forward && can_reach(edges, cand, seeder) } else { forward } + }) +} + +fn can_reach(edges: &[Vec], start: usize, target: usize) -> bool { + let mut visited = vec![false; edges.len()]; + let mut queue = VecDeque::from([start]); + visited[start] = true; + while let Some(curr) = queue.pop_front() { + if curr == target { + return true; + } + enqueue_unvisited(&edges[curr], &mut visited, &mut queue); + } + false +} + +#[inline] +fn enqueue_unvisited(neighbors: &[usize], visited: &mut [bool], queue: &mut VecDeque) { + for &next in neighbors { + if !visited[next] { + visited[next] = true; + queue.push_back(next); + } + } +} + +fn materialize_capsule( + ws_root: &Path, + capsules_root: &Path, + entry: &BitmapEntry, + manifest: &ManifestConfig, + create_args: &CreateArgs, + dependencies: &BTreeMap, +) -> io::Result { + let full_id = selection::entry_full_id(entry); + let mut dir_name = create_args.name + .clone() + .unwrap_or_else(|| full_id.replace('/', "_")); + if create_args.always_new { + dir_name.push('_'); + dir_name.push_str(&uuid_v4()?); + } + let capsule_dir = capsules_root.join(dir_name); + fs::create_dir_all(&capsule_dir)?; + let src_dir = ws_root.join(&entry.root_dir); + if src_dir.is_dir() && entry.root_dir != "." { + copy_component_files(&src_dir, &capsule_dir)?; + } + write_default_package_json(&capsule_dir, entry, manifest, dependencies)?; + fs::write(capsule_dir.join(".bit-capsule-ready"), "")?; + Ok(CreatedCapsule { id: full_id, path: capsule_dir.to_string_lossy().into_owned() }) +} diff --git a/native/cli/src/commands/capsule/create/files.rs b/native/cli/src/commands/capsule/create/files.rs new file mode 100644 index 000000000000..fdd6305c9ea5 --- /dev/null +++ b/native/cli/src/commands/capsule/create/files.rs @@ -0,0 +1,403 @@ +//! Isolated capsule source copying and `package.json` manifest generation ([`BitmapEntry`], [`ScopeObjects`], [`policy`], [`GeneratedPackage`]). + +mod policy; + +use bit_install::workspace::{BitmapEntry, ScopeObjects}; +use serde::Serialize; +use serde_json::{Map, Value}; +use std::{collections::BTreeMap, fs, io, path::Path}; +/// Serialized `componentId` field written into a capsule `package.json` manifest. +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct PackageComponentId<'a> { + name: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + version: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + scope: Option<&'a str>, +} + +#[derive(Serialize)] +#[serde(untagged)] +enum PackageComponentIdValue<'a> { + Generated(PackageComponentId<'a>), + Configured(Value), +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct GeneratedPackage<'a> { + name: String, + version: String, + main: String, + component_id: PackageComponentIdValue<'a>, + #[serde(flatten)] + dependencies: PackageDependencies, + license: String, + #[serde(flatten)] + dependency_metadata: DependencyMetadata, + #[serde(flatten)] + entry_points: EntryPointMetadata, +} + +#[derive(Serialize)] +struct ComponentPackage<'a> { + #[serde(flatten)] + generated: GeneratedPackage<'a>, + #[serde(flatten)] + metadata: &'a Map, +} + +pub(super) struct ManifestConfig { + pub(super) env: BuiltinEnv, + overrides: PackageOverrides, +} + +#[derive(Default)] +struct PackageOverrides { + name: Option, + main: Option, + license: Option, + entry_points: EntryPointOverrides, + optional_dependencies: Option>, + peer_dependencies_meta: Option>, + component_id: Option, + metadata: Map, +} + +#[derive(Default)] +struct EntryPointOverrides { + types: Option, + exports: Option, +} + +#[derive(Serialize)] +struct EntryPointMetadata { + #[serde(skip_serializing_if = "Option::is_none")] + types: Option, + #[serde(skip_serializing_if = "Option::is_none")] + exports: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct PackageDependencies { + dependencies: Map, + dev_dependencies: Map, + peer_dependencies: Map, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct DependencyMetadata { + optional_dependencies: BTreeMap, + peer_dependencies_meta: Map, +} + +#[derive(Clone, Copy)] +pub(super) enum BuiltinEnv { + Node, + React, + Html, + ReactNative, + Aspect, + Mdx, + Environment, + MdxReadme, + BitCustomAspect, + Empty, +} + +pub(super) fn supported_builtin_manifests( + ws_root: &Path, + active: &[&BitmapEntry], + selected: &[usize], +) -> io::Result>> { + let bytes = fs::read(ws_root.join("workspace.jsonc"))?; + let config: Value = bit_object_import::parse_jsonc(&bytes).map_err(io::Error::other)?; + if crate::commands::update_cmd::aspect_entry(&config, "teambit.workspace/variants") + .and_then(|(_, val)| val.as_object()) + .is_some_and(|variants| !variants.is_empty()) + { + return Ok(None); + } + let mut envs = Vec::with_capacity(selected.len()); + for &index in selected { + let Some(manifest) = plan_manifest(ws_root, active[index])? else { + return Ok(None); + }; + envs.push(manifest); + } + Ok(Some(envs)) +} + +fn plan_manifest(ws_root: &Path, entry: &BitmapEntry) -> io::Result> { + let config = entry.config.as_ref(); + let env = effective_env(ws_root, entry)?; + let Some(builtin) = policy::parse_builtin_env(env.as_deref()) else { + return Ok(None); + }; + let model_package_json = + if entry.id.version.is_some() { model_package_json(ws_root, entry)? } else { None }; + let bitmap_package_json = + find_bitmap_aspect(config, "teambit.pkg/pkg").and_then(|pkg| pkg.get("packageJson")); + let Some(overrides) = + additional_package_metadata(bitmap_package_json.or(model_package_json.as_ref())) + else { + return Ok(None); + }; + if !entry.root_dir.is_empty() + && ws_root + .join(&entry.root_dir) + .join("component.json") + .exists() + { + return Ok(None); + } + if policy::has_custom_builtin_env_config(config) { + return Ok(None); + } + Ok(Some(ManifestConfig { env: builtin, overrides })) +} + +fn model_package_json(ws_root: &Path, entry: &BitmapEntry) -> io::Result> { + let mut id = entry.id.clone(); + if id.scope.is_none() { + id.scope = id.effective_scope().map(str::to_owned); + } + let model = ScopeObjects::new(&crate::workspace::scope_path(ws_root)) + .component_or_head(&id)? + .ok_or_else(|| io::Error::other("versioned capsule component model is missing"))?; + Ok(model.extensions + .iter() + .find(|extension| crate::commands::update_cmd::matches_aspect(extension, "teambit.pkg/pkg")) + .and_then(|extension| extension.get("config")) + .and_then(|config| config.get("packageJson")) + .cloned()) +} + +fn additional_package_metadata(package_json: Option<&Value>) -> Option { + let Some(package_json) = package_json else { + return Some(PackageOverrides::default()); + }; + let mut metadata = package_json.as_object()?.clone(); + let name = take_nonempty_string(&mut metadata, "name")?; + let main = take_nonempty_string(&mut metadata, "main")?; + let license = take_nonempty_string(&mut metadata, "license")?; + let entry_points = take_entry_point_overrides(&mut metadata)?; + let optional_dependencies = take_string_map(&mut metadata, "optionalDependencies")?; + let peer_dependencies_meta = take_peer_meta(&mut metadata)?; + for field in ["dependencies", "devDependencies", "peerDependencies"] { + take_string_map(&mut metadata, field)?; + } + let component_id = take_component_id(&mut metadata)?; + take_nonempty_string(&mut metadata, "version")?; + Some(PackageOverrides { + name, + main, + license, + entry_points, + optional_dependencies, + peer_dependencies_meta, + component_id, + metadata, + }) +} + +#[inline] +fn take_entry_point_overrides(metadata: &mut Map) -> Option { + Some(EntryPointOverrides { + types: take_nonempty_string(metadata, "types")?, + exports: take_exports(metadata)?, + }) +} + +#[inline] +fn take_component_id(metadata: &mut Map) -> Option> { + match metadata.remove("componentId") { + None => Some(None), + Some(value @ Value::Object(_)) => Some(Some(value)), + _ => None, + } +} + +#[inline] +fn take_peer_meta(metadata: &mut Map) -> Option>> { + match metadata.remove("peerDependenciesMeta") { + None => Some(None), + Some(Value::Object(meta)) if meta.values().all(Value::is_object) => Some(Some(meta)), + _ => None, + } +} + +fn take_string_map( + metadata: &mut Map, + key: &str, +) -> Option>> { + let Some(value) = metadata.remove(key) else { return Some(None) }; + let entries = value.as_object()?; + entries + .iter() + .map(|(name, version)| { + let name = name.trim(); + let ver = version.as_str()?.trim(); + (!name.is_empty() && !ver.is_empty()).then(|| (name.to_owned(), ver.to_owned())) + }) + .collect::>>() + .map(Some) +} + +#[inline] +fn take_exports(metadata: &mut Map) -> Option> { + match metadata.remove("exports") { + None => Some(None), + Some(value @ Value::Object(_)) => Some(Some(value)), + Some(Value::String(value)) if !value.trim().is_empty() => Some(Some(Value::String(value))), + _ => None, + } +} + +#[inline] +fn take_nonempty_string(metadata: &mut Map, key: &str) -> Option> { + match metadata.remove(key) { + None => Some(None), + Some(Value::String(value)) if !value.trim().is_empty() => Some(Some(value)), + _ => None, + } +} + +fn find_bitmap_aspect<'a>( + config: Option<&'a Map>, + aspect: &str, +) -> Option<&'a Value> { + config? + .iter() + .find_map(|(key, val)| { + let key = key.trim(); + (key == aspect + || key + .strip_prefix(aspect) + .is_some_and(|rest| rest.starts_with('@'))) + .then_some(val) + }) +} + +fn effective_env(ws_root: &Path, entry: &BitmapEntry) -> io::Result> { + let bitmap_env = find_bitmap_aspect(entry.config.as_ref(), "teambit.envs/envs") + .and_then(|env| env["env"].as_str()) + .map(str::trim) + .filter(|env| !env.is_empty()); + if entry.id.version.is_none() { + return Ok(bitmap_env.map(str::to_owned)); + } + let mut id = entry.id.clone(); + if id.scope.is_none() { + id.scope = id.effective_scope().map(str::to_owned); + } + let model = ScopeObjects::new(&crate::workspace::scope_path(ws_root)) + .component_or_head(&id)? + .ok_or_else(|| io::Error::other("versioned capsule component model is missing"))?; + let model_env = model.extensions + .iter() + .find(|ext| crate::commands::update_cmd::matches_aspect(ext, "teambit.envs/envs")) + .and_then(|ext| ext["config"]["env"].as_str()) + .map(str::trim) + .filter(|env| !env.is_empty()); + Ok(model_env.or(bitmap_env).map(str::to_owned)) +} + +#[inline] +pub(super) fn component_dependency(entry: &BitmapEntry) -> (String, String) { + let scope = entry.id.effective_scope().unwrap_or("my-scope"); + let version = entry.id.version + .as_deref() + .map(str::trim) + .filter(|ver| !ver.is_empty()) + .unwrap_or("latest"); + (format!("@{scope}/{}", entry.id.name.replace('/', ".")), version.to_owned()) +} + +#[inline] +fn package_version(entry: &BitmapEntry) -> String { + let (_, version) = component_dependency(entry); + if version.len() == 40 && version.bytes().all(|byte| byte.is_ascii_hexdigit()) { + format!("0.0.0-{version}") + } else { + version + } +} + +pub(super) fn write_default_package_json( + capsule_dir: &Path, + entry: &BitmapEntry, + manifest: &ManifestConfig, + dependencies: &BTreeMap, +) -> io::Result<()> { + let pkg_name = manifest.overrides.name + .clone() + .unwrap_or_else(|| component_dependency(entry).0); + let stem = Path::new(&entry.main_file).with_extension(""); + let stem = stem.to_string_lossy().replace('\\', "/"); + let generated = GeneratedPackage { + name: pkg_name, + version: package_version(entry), + main: manifest.overrides.main + .clone() + .unwrap_or_else(|| { + if matches!(manifest.env, BuiltinEnv::Empty) { + entry.main_file.clone() + } else { + format!("dist/{stem}.js") + } + }), + component_id: configured_component_id(manifest, entry), + dependencies: policy::package_dependencies(manifest.env, dependencies), + license: manifest.overrides.license + .clone() + .unwrap_or_else(|| "SEE LICENSE IN UNLICENSED".into()), + dependency_metadata: DependencyMetadata { + optional_dependencies: manifest.overrides.optional_dependencies + .clone() + .unwrap_or_default(), + peer_dependencies_meta: manifest.overrides.peer_dependencies_meta + .clone() + .unwrap_or_default(), + }, + entry_points: policy::configured_entry_points(manifest, &stem), + }; + let pkg = ComponentPackage { generated, metadata: &manifest.overrides.metadata }; + fs::write(capsule_dir.join("package.json"), serde_json::to_string_pretty(&pkg)?) +} + +#[inline] +fn configured_component_id<'a>( + manifest: &ManifestConfig, + entry: &'a BitmapEntry, +) -> PackageComponentIdValue<'a> { + match &manifest.overrides.component_id { + Some(value) => PackageComponentIdValue::Configured(value.clone()), + None => PackageComponentIdValue::Generated(PackageComponentId { + name: &entry.id.name, + scope: entry.id.effective_scope(), + version: entry.id.version.as_deref(), + }), + } +} + +pub(super) fn copy_component_files(src: &Path, dst: &Path) -> io::Result<()> { + fs::create_dir_all(dst)?; + for entry in fs::read_dir(src)?.filter_map(Result::ok) { + let name = entry.file_name(); + if name == "node_modules" || name == ".git" { + continue; + } + let src_path = entry.path(); + let dst_path = dst.join(&name); + if src_path.is_dir() { + copy_component_files(&src_path, &dst_path)?; + } else if src_path.is_file() { + fs::copy(&src_path, &dst_path)?; + } + } + Ok(()) +} diff --git a/native/cli/src/commands/capsule/create/files/policy.rs b/native/cli/src/commands/capsule/create/files/policy.rs new file mode 100644 index 000000000000..f20f201d2f7e --- /dev/null +++ b/native/cli/src/commands/capsule/create/files/policy.rs @@ -0,0 +1,170 @@ +//! Released [`BuiltinEnv`] core-environment [`PackageDependencies`] policies for capsule manifests. +//! +//! Populates runtime, dev, and peer dependencies via [`package_dependencies`]. + +use super::{BuiltinEnv, EntryPointMetadata, ManifestConfig, PackageDependencies}; +use serde_json::{Map, Value}; +use std::collections::BTreeMap; + +/// Computes [`PackageDependencies`] for a capsule component under `env` and `component_dependencies`. +pub(super) fn package_dependencies( + env: BuiltinEnv, + component_dependencies: &BTreeMap, +) -> PackageDependencies { + let mut dependencies = Map::new(); + let mut dev_dependencies = Map::new(); + let mut peer_dependencies = Map::new(); + match env { + BuiltinEnv::Empty => {} + BuiltinEnv::Node + | BuiltinEnv::Html + | BuiltinEnv::ReactNative + | BuiltinEnv::BitCustomAspect => { + node_like(env, &mut dev_dependencies); + } + BuiltinEnv::React + | BuiltinEnv::Aspect + | BuiltinEnv::Mdx + | BuiltinEnv::Environment + | BuiltinEnv::MdxReadme => { + react_like(env, &mut dependencies, &mut dev_dependencies, &mut peer_dependencies); + } + } + for (name, version) in component_dependencies { + insert(&mut dependencies, name, version); + } + PackageDependencies { dependencies, dev_dependencies, peer_dependencies } +} + +/// Populates default `devDependencies` for Node-like builtin capsule environments. +fn node_like(env: BuiltinEnv, dev: &mut Map) { + insert(dev, "@types/jest", "26.0.20"); + insert(dev, "@types/node", "22.10.5"); + match env { + BuiltinEnv::Html => insert(dev, "@teambit/html", "latest"), + BuiltinEnv::ReactNative => insert(dev, "@teambit/react-native", "latest"), + BuiltinEnv::BitCustomAspect => insert(dev, "@teambit/bit-custom-aspect", "1.0.107"), + _ => {} + } +} + +fn react_like( + env: BuiltinEnv, + runtime: &mut Map, + dev: &mut Map, + peer: &mut Map, +) { + insert(runtime, "core-js", "^3.0.0"); + if matches!(env, BuiltinEnv::Mdx | BuiltinEnv::MdxReadme) { + insert(runtime, "@teambit/mdx.ui.mdx-scope-context", "1.0.0"); + insert(runtime, "@mdx-js/react", "^3.1.1"); + } + let runtime_babel = matches!(env, BuiltinEnv::Aspect | BuiltinEnv::Environment); + if runtime_babel { + insert(runtime, "@babel/runtime", "7.20.0"); + } + for (name, version) in [ + ("@types/node", "12.20.4"), + ("@types/react", "^19.0.0"), + ("@types/react-dom", "^19.0.0"), + ("@types/jest", "^26.0.0"), + ] { + insert(dev, name, version); + } + if !runtime_babel { + insert(dev, "@babel/runtime", "7.20.0"); + } + insert(dev, "@types/testing-library__jest-dom", "5.9.5"); + insert(peer, "react", "^17.0.0 || ^18.0.0 || ^19.0.0"); + insert(peer, "react-dom", "^17.0.0 || ^18.0.0 || ^19.0.0"); +} + +#[inline] +fn insert(map: &mut Map, name: &str, version: &str) { + map.insert(name.to_owned(), Value::String(version.to_owned())); +} + +pub(super) fn has_custom_builtin_env_config(config: Option<&Map>) -> bool { + let Some(config) = config else { return false }; + [ + "teambit.react/react", + "teambit.html/html", + "teambit.react/react-native", + "teambit.harmony/aspect", + "teambit.mdx/mdx", + "teambit.envs/env", + "teambit.mdx/readme", + "teambit.harmony/bit-custom-aspect", + "teambit.harmony/empty-env", + ] + .iter() + .any(|target| { + config + .iter() + .any(|(key, value)| { + let trimmed = key.trim(); + (trimmed == *target + || trimmed + .strip_prefix(*target) + .is_some_and(|rest| rest.starts_with('@'))) + && value + .as_object() + .is_none_or(|env| !env.is_empty()) + }) + }) +} + +#[inline] +pub(super) fn configured_entry_points(manifest: &ManifestConfig, stem: &str) -> EntryPointMetadata { + let mut entry_points = entry_point_metadata(manifest.env, stem); + if let Some(types) = manifest.overrides.entry_points.types + .as_deref() + .map(str::trim) + .filter(|types| !types.is_empty()) + { + entry_points.types = Some(types.to_owned()); + } + if let Some(exports) = &manifest.overrides.entry_points.exports { + entry_points.exports = Some(exports.clone()); + } + entry_points +} + +#[inline] +fn entry_point_metadata(env: BuiltinEnv, stem: &str) -> EntryPointMetadata { + let types = (!matches!(env, BuiltinEnv::Empty)).then(|| format!("{stem}.ts")); + let exports = matches!(env, BuiltinEnv::Aspect).then(|| { + let main = format!("./dist/{stem}.js"); + serde_json::json!({ + "node": { "require": main, "import": "./dist/esm.mjs" }, + "default": main, + }) + }); + EntryPointMetadata { types, exports } +} + +#[inline] +pub(super) fn parse_builtin_env(env: Option<&str>) -> Option { + let normalized = env + .map(str::trim) + .filter(|raw| !raw.is_empty()) + .map(|raw| { + raw.rsplit_once('@') + .filter(|(base, _)| !base.trim().is_empty()) + .map_or(raw, |(base, _)| base.trim()) + .trim_end_matches('/') + }); + match normalized { + None | Some("teambit.harmony/node") => Some(BuiltinEnv::Node), + Some("teambit.react/react") => Some(BuiltinEnv::React), + Some("teambit.html/html") => Some(BuiltinEnv::Html), + Some("teambit.react/react-native") => Some(BuiltinEnv::ReactNative), + Some("teambit.harmony/aspect") => Some(BuiltinEnv::Aspect), + Some("teambit.mdx/mdx") => Some(BuiltinEnv::Mdx), + Some("teambit.envs/env") => Some(BuiltinEnv::Environment), + Some("teambit.mdx/readme") => Some(BuiltinEnv::MdxReadme), + Some("teambit.harmony/bit-custom-aspect") => Some(BuiltinEnv::BitCustomAspect), + Some("teambit.harmony/empty-env") => Some(BuiltinEnv::Empty), + _ => None, + } +} diff --git a/native/cli/src/commands/capsule/create/metadata.rs b/native/cli/src/commands/capsule/create/metadata.rs new file mode 100644 index 000000000000..0de3d1c46aa8 --- /dev/null +++ b/native/cli/src/commands/capsule/create/metadata.rs @@ -0,0 +1,77 @@ +//! Root metadata ([`object_hash_short`]) written by `IsolatorMain` for workspace capsules. +//! +//! Writes root `package.json` and `.bit-capsule-origin.json` markers via [`write_root_metadata`]. + +use super::super::object_hash_short; +use chrono::{SecondsFormat, Utc}; +use serde::Serialize; +use std::{fs, io, path::Path, time::SystemTime}; + +/// Minimal root `package.json` payload identifying a Bit capsule root directory. +#[derive(Serialize)] +struct RootPackage<'a> { + name: &'a str, + #[serde(rename = "bit-capsule")] + bit_capsule: bool, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct OriginMarker<'a> { + origin_path: &'a str, + created_at: &'a str, + kind: &'static str, +} + +/// Writes root `package.json` and `.bit-capsule-origin.json` metadata under `capsules_root`. +pub(super) fn write_root_metadata(capsules_root: &Path, base_dir: &str) -> io::Result<()> { + let base_dir = base_dir.trim(); + if capsules_root + .as_os_str() + .as_encoded_bytes() + .iter() + .all(u8::is_ascii_whitespace) + || base_dir.is_empty() + || base_dir.chars().any(char::is_control) + { + return Err(io::Error::other("capsule root metadata path is empty")); + } + fs::create_dir_all(capsules_root)?; + let package = capsules_root.join("package.json"); + if !package.exists() { + let name = format!("capsules-{}", object_hash_short(base_dir)); + let content = serde_json::to_string(&RootPackage { name: &name, bit_capsule: true })?; + fs::write(package, format!("{content}\n"))?; + } + let marker = capsules_root.join(".bit-capsule-origin.json"); + if has_valid_origin_marker(&marker) { + if let Ok(file) = fs::OpenOptions::new().write(true).open(&marker) { + let _ = file.set_modified(SystemTime::now()); + } + } else { + let created_at = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true); + let content = serde_json::to_string(&OriginMarker { + origin_path: base_dir, + created_at: &created_at, + kind: "workspace", + })?; + let _ = fs::write(marker, format!("{content}\n")); + } + Ok(()) +} + +#[inline] +fn has_valid_origin_marker(marker: &Path) -> bool { + fs::read(marker) + .ok() + .and_then(|bytes| bit_object_import::parse_jsonc(&bytes).ok()) + .is_some_and(|val| { + ["originPath", "createdAt", "kind"] + .into_iter() + .all(|key| { + val[key] + .as_str() + .is_some_and(|text| !text.trim().is_empty()) + }) + }) +} diff --git a/native/cli/src/commands/capsule/create/root.rs b/native/cli/src/commands/capsule/create/root.rs new file mode 100644 index 000000000000..e06c227be090 --- /dev/null +++ b/native/cli/src/commands/capsule/create/root.rs @@ -0,0 +1,238 @@ +//! Capsule root path selection ([`compute_capsules_root_dirs`], [`resolve_capsules_dir`]) and base-directory validation. +//! +//! Parses [`CreateArgs`] flags via [`parse_create_flags`] and resolves target capsule directories. + +use super::{ + super::{ + ScopeRoot, Subcommand, compute_capsules_root_dirs, object_hash_short, resolve_capsules_dir, + }, + CreateArgs, +}; +use std::{ + io, + path::{Component, Path, PathBuf}, +}; + +/// Parses `bit capsule create` CLI flags and trailing positional arguments into a [`Subcommand`]. +pub(in crate::commands::capsule) fn parse_create_flags(rest: &[&str]) -> Option { + let mut create = CreateArgs::default(); + let mut args = rest.iter().copied(); + while let Some(arg) = args.next() { + if arg == "--" { + consume_trailing_capsule_ids(args, &mut create.ids)?; + break; + } + apply_create_arg(arg, &mut args, &mut create)?; + } + Some(Subcommand::Create(create)) +} + +/// Appends non-flag positional component IDs after `--` into `ids`. +fn consume_trailing_capsule_ids<'a>( + args: impl Iterator, + ids: &mut Vec, +) -> Option<()> { + for trailing in args { + let trimmed = trailing.trim(); + (!trimmed.is_empty() && !trimmed.starts_with('-')).then_some(())?; + ids.push(trimmed.to_owned()); + } + Some(()) +} + +fn apply_create_arg<'a>( + arg: &'a str, + args: &mut impl Iterator, + create: &mut CreateArgs, +) -> Option<()> { + if apply_create_bool(arg, create) || apply_create_combined_short(arg, create) { + return Some(()); + } + match arg { + "-i" | "--id" => create.name = Some(next_option_value(args)?.to_owned()), + "-b" | "--base-dir" => create.base_dir = Some(next_option_value(args)?.to_owned()), + "-r" | "--root-base-dir" => { + create.root_base_dir = Some(next_option_value(args)?.to_owned()); + } + "-p" | "--package-manager" => { + let _ = next_option_value(args)?; + } + _ => apply_create_inline_or_id(arg, create)?, + } + Some(()) +} + +fn apply_create_bool(arg: &str, create: &mut CreateArgs) -> bool { + let Some((flag, val)) = parse_bool_flag(arg) else { + return false; + }; + let allow_false = val || arg.starts_with("--"); + match flag { + "--install-packages" if !val => return true, + "-j" | "--json" if allow_false => create.json = val, + "-s" | "--seeders-only" if allow_false => create.seeders_only = val, + "-a" | "--always-new" if allow_false => create.always_new = val, + "--use-hash" => create.use_hash = val, + _ => return false, + } + true +} + +#[inline] +fn parse_bool_flag(arg: &str) -> Option<(&str, bool)> { + let Some((flag, raw)) = arg.split_once('=') else { + return Some((arg.trim(), true)); + }; + let raw = raw.trim(); + if raw.eq_ignore_ascii_case("true") || raw == "1" { + Some((flag.trim(), true)) + } else if raw.eq_ignore_ascii_case("false") || raw == "0" { + Some((flag.trim(), false)) + } else { + None + } +} + +fn apply_create_inline_or_id(arg: &str, create: &mut CreateArgs) -> Option<()> { + if let Some((key, val)) = arg.split_once('=') { + let val = val.trim(); + if val.is_empty() || val.starts_with('-') { + return None; + } + match key.trim() { + "--id" | "-i" => create.name = Some(val.to_owned()), + "--base-dir" | "-b" => create.base_dir = Some(val.to_owned()), + "--root-base-dir" | "-r" => create.root_base_dir = Some(val.to_owned()), + "--package-manager" | "-p" => {} + _ => return None, + } + } else if !arg.starts_with('-') && !arg.trim().is_empty() { + create.ids.push(arg.trim().to_owned()); + } else { + return None; + } + Some(()) +} + +fn apply_create_combined_short(arg: &str, create: &mut CreateArgs) -> bool { + if !arg.starts_with('-') + || arg.starts_with("--") + || arg.len() <= 2 + || !arg[1..] + .bytes() + .all(|byte| matches!(byte, b'j' | b's' | b'a')) + { + return false; + } + for byte in arg[1..].bytes() { + match byte { + b'j' => create.json = true, + b's' => create.seeders_only = true, + _ => create.always_new = true, + } + } + true +} + +#[inline] +fn next_option_value<'a>(args: &mut impl Iterator) -> Option<&'a str> { + let value = args.next()?.trim(); + (!value.is_empty() && !value.starts_with('-')).then_some(value) +} + +pub(super) fn resolve_create_root( + cwd: &Path, + root: &ScopeRoot, + create_args: &CreateArgs, +) -> io::Result { + let default_root = PathBuf::from(resolve_capsules_dir(cwd)?); + let root_base = match &create_args.root_base_dir { + Some(value) => validate_root_base_dir(value, cwd)?, + None => default_root.clone(), + }; + if let Some(base_dir) = &create_args.base_dir { + let relative = validate_base_dir(base_dir)?; + return Ok(normalize_lexical(&root_base.join(if create_args.use_hash { + PathBuf::from(object_hash_short(base_dir)) + } else { + relative + }))); + } + if create_args.use_hash { + return Ok(root_base.join(object_hash_short(&root.path.to_string_lossy()))); + } + let dirs = compute_capsules_root_dirs(cwd, root)?; + let ws_dir = PathBuf::from(dirs.workspace.ok_or_else(|| { + io::Error::other("missing workspace capsules root dir") + })?); + if create_args.root_base_dir.is_none() { + return Ok(ws_dir); + } + let sub = ws_dir + .strip_prefix(&default_root) + .map_err(|_| io::Error::other("custom capsule root cannot map workspace path"))?; + Ok(root_base.join(sub)) +} + +fn validate_base_dir(value: &str) -> io::Result { + let path = Path::new(value); + let unix_absolute = cfg!(unix) && path.is_absolute(); + if path.as_os_str().is_empty() + || path + .components() + .any(|part| { + !(matches!(part, Component::Normal(_) | Component::CurDir | Component::ParentDir) + || unix_absolute && matches!(part, Component::RootDir)) + }) + { + return Err(io::Error::other("unsupported capsule base dir")); + } + let relative = path + .components() + .filter_map(|part| match part { + Component::Normal(_) | Component::ParentDir => Some(part.as_os_str()), + _ => None, + }) + .collect::(); + if relative.as_os_str().is_empty() { + return Err(io::Error::other("unsupported capsule base dir")); + } + Ok(relative) +} + +fn validate_root_base_dir(value: &str, cwd: &Path) -> io::Result { + let path = Path::new(value); + if path.as_os_str().is_empty() + || path + .components() + .any(|part| { + !matches!( + part, + Component::Prefix(_) + | Component::RootDir + | Component::CurDir + | Component::ParentDir + | Component::Normal(_), + ) + }) + { + return Err(io::Error::other("unsupported capsule root base dir")); + } + let joined = if path.is_absolute() { path.to_path_buf() } else { cwd.join(path) }; + Ok(normalize_lexical(&joined)) +} + +#[inline] +fn normalize_lexical(path: &Path) -> PathBuf { + let mut normalized = PathBuf::new(); + for component in path.components() { + match component { + Component::CurDir => {} + Component::ParentDir => { + normalized.pop(); + } + other => normalized.push(other.as_os_str()), + } + } + normalized +} diff --git a/native/cli/src/commands/capsule/list_stats.rs b/native/cli/src/commands/capsule/list_stats.rs new file mode 100644 index 000000000000..d3f40fe45f8c --- /dev/null +++ b/native/cli/src/commands/capsule/list_stats.rs @@ -0,0 +1,268 @@ +//! Capsule directory size and count statistics ([`resolve_capsules_dir`]) for `bit capsule list`. +//! +//! Scans [`ORIGIN_FILE`] markers and aggregates [`RootEntry`] disk usage metrics. + +use super::{Chalk, resolve_capsules_dir}; +use crate::{commands::lane::scope_root::ScopeRoot, workspace}; +use bit_workspace_config::{ + environment_value, global_config_dir, merge_layers, parse_jsonc, read_global_config, + workspace_values, +}; +use serde::Serialize; +use serde_json::{Map, Value}; +use sha1::{Digest, Sha1}; +use std::{ + fmt::Write as _, + fs, io, + path::Path, + time::{SystemTime, UNIX_EPOCH}, +}; + +const ORIGIN_FILE: &str = ".bit-capsule-origin.json"; +const VALID_KINDS: &[&str] = &["workspace", "scope-aspects-root", "scope-aspect", "scope"]; + +/// Capsule root directory metadata and disk usage summary for `bit capsule list`. +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct RootEntry { + pub(super) path: String, + pub(super) kind: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) origin_path: Option, + pub(super) last_used_ms: u64, + pub(super) size_bytes: u64, +} + +/// Collects sorted [`RootEntry`] metadata for all capsule directories under the resolved capsule root. +pub(super) fn collect(cwd: &Path, with_sizes: bool) -> io::Result> { + let root = resolve_capsules_dir(cwd)?; + let entries = match fs::read_dir(root) { + Ok(entries) => entries, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(error) => return Err(error), + }; + let mut paths = Vec::new(); + for entry in entries { + let entry = entry?; + if !entry.file_type().is_ok_and(|kind| kind.is_dir()) { + continue; + } + let name = entry.file_name(); + if name != "node_modules" && !name.as_encoded_bytes().starts_with(b".") { + paths.push(entry.path()); + } + } + paths.sort(); + Ok(paths + .iter() + .map(|path| inspect(path, with_sizes)) + .collect()) +} + +pub(super) fn inspect(path: &Path, with_sizes: bool) -> RootEntry { + let marker = path.join(ORIGIN_FILE); + let origin = fs::read(&marker) + .ok() + .and_then(|bytes| parse_jsonc(&bytes).ok()); + let valid = origin + .as_ref() + .filter(|value| { + value + .get("originPath") + .and_then(Value::as_str) + .is_some_and(|origin_path| !origin_path.trim().is_empty()) + && value + .get("createdAt") + .and_then(Value::as_str) + .is_some_and(|created_at| !created_at.trim().is_empty()) + && value + .get("kind") + .and_then(Value::as_str) + .is_some_and(|kind| VALID_KINDS.contains(&kind.trim())) + }); + let stamp = valid + .and_then(|_| fs::metadata(&marker).ok()) + .or_else(|| fs::metadata(path).ok()) + .and_then(|meta| meta.modified().ok()); + RootEntry { + path: path.to_string_lossy().into_owned(), + kind: valid + .and_then(|value| value["kind"].as_str().map(str::trim)) + .unwrap_or("unmarked") + .into(), + origin_path: valid + .and_then(|value| value["originPath"].as_str()) + .map(|origin_path| origin_path.trim().to_owned()), + last_used_ms: stamp.map_or(0, millis), + size_bytes: if with_sizes { dir_size(path) } else { 0 }, + } +} + +#[inline] +pub(super) fn millis(time: SystemTime) -> u64 { + time.duration_since(UNIX_EPOCH) + .map_or(0, |duration| ((duration.as_nanos() + 500_000) / 1_000_000) as u64) +} + +#[inline] +pub(super) fn now_millis() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |duration| duration.as_millis() as u64) +} + +pub(super) fn dir_size(path: &Path) -> u64 { + let Ok(entries) = fs::read_dir(path) else { return 0 }; + entries + .filter_map(Result::ok) + .map(|entry| { + let Ok(kind) = entry.file_type() else { return 0 }; + if kind.is_dir() { + dir_size(&entry.path()) + } else if kind.is_file() { + fs::symlink_metadata(entry.path()).map_or(0, |meta| meta.len()) + } else { + 0 + } + }) + .sum() +} + +#[inline] +pub(super) fn total_size(roots: &[RootEntry]) -> u64 { + roots + .iter() + .map(|root| root.size_bytes) + .sum() +} + +pub(super) fn max_age_days(config: &Map) -> f64 { + let env = environment_value("capsules_max_age_days").map(Value::String); + let Some(value) = env + .as_ref() + .or_else(|| config.get("capsules_max_age_days")) + else { + return 30.0; + }; + let number = match value { + Value::Number(n) => n.as_f64(), + Value::String(s) if !s.is_empty() => { + let trimmed = s.trim(); + if trimmed.is_empty() { Some(0.0) } else { trimmed.parse().ok() } + } + Value::Bool(b) => Some(f64::from(u8::from(*b))), + _ => None, + }; + number + .filter(|n: &f64| n.is_finite()) + .map_or(30.0, |n| n.max(0.0)) +} + +pub(super) fn summary(roots: &[RootEntry], age_days: f64, chalk: Chalk) -> String { + let orphan = roots + .iter() + .filter(|root| { + root.origin_path + .as_ref() + .is_some_and(|path| !Path::new(path).exists()) + }) + .count(); + let cutoff = age_days.mul_add(-86_400_000.0, now_millis() as f64); + let stale = roots + .iter() + .filter(|root| root.kind == "scope-aspects-root") + .map(|root| stale_children(Path::new(&root.path), cutoff)) + .sum::(); + format!( + "cache total: {} across {} subdir(s) — {orphan} orphan(s), {stale} stale aspect-version(s) (>{age_days}d)", + chalk.bold(&format_bytes(total_size(roots))), + roots.len(), + ) +} + +fn stale_children(path: &Path, cutoff: f64) -> usize { + let Ok(entries) = fs::read_dir(path) else { return 0 }; + entries + .filter_map(Result::ok) + .filter(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir())) + .filter(|entry| { + let name = entry.file_name(); + name != "node_modules" && !name.as_encoded_bytes().starts_with(b".") + }) + .filter(|entry| { + let marker = entry.path().join(ORIGIN_FILE); + let stamp = fs::metadata(marker) + .or_else(|_| fs::metadata(entry.path())) + .and_then(|meta| meta.modified()); + stamp.is_ok_and(|time| (millis(time) as f64) < cutoff) + }) + .count() +} + +pub(super) fn format_bytes(bytes: u64) -> String { + if bytes == 0 { + return "0 B".into(); + } + let units = ["B", "KB", "MB", "GB", "TB"]; + let index = ((bytes as f64).log(1024.0).floor() as usize).min(units.len() - 1); + if index == 0 { + format!("{bytes} B") + } else { + let tenths = ((bytes as f64 / 1024_f64.powi(index as i32)) * 10.0).round(); + format!("{:.1} {}", tenths / 10.0, units[index]) + } +} + +#[inline] +pub(in crate::commands::capsule) fn lookup_config_str( + combined: &Map, + key: &str, +) -> Option { + environment_value(key) + .map(|val| val.trim().to_owned()) + .filter(|val| !val.is_empty()) + .or_else(|| { + combined + .get(key) + .and_then(Value::as_str) + .map(str::trim) + .filter(|val| !val.is_empty()) + .map(str::to_owned) + }) +} + +pub(in crate::commands::capsule) fn read_combined_config( + root: &ScopeRoot, +) -> io::Result> { + let global = read_global_config(&global_config_dir())?; + let scope_dir = if root.bare { root.path.clone() } else { workspace::scope_path(&root.path) }; + let scope_json = scope_dir.join("scope.json"); + let scope_layer = if scope_json.is_file() { + parse_jsonc(&fs::read(scope_json)?)? + .get("config") + .cloned() + .unwrap_or(Value::Null) + } else { + Value::Null + }; + let ws_layer = if root.bare { + Value::Null + } else { + Value::Object(workspace_values(&parse_jsonc(&fs::read(root.path.join( + "workspace.jsonc", + ))?)?)) + }; + Ok(merge_layers(&global, &scope_layer, &ws_layer)) +} + +#[inline] +pub(in crate::commands::capsule) fn object_hash_short(text: &str) -> String { + let payload = format!("string:{}:{text}", text.encode_utf16().count()); + let digest = Sha1::digest(payload.as_bytes()); + let mut out = String::with_capacity(10); + for byte in &digest[..5] { + let _ = write!(out, "{byte:02x}"); + } + out.truncate(9); + out +} diff --git a/native/cli/src/commands/capsule/outside.rs b/native/cli/src/commands/capsule/outside.rs new file mode 100644 index 000000000000..1a272219ab20 --- /dev/null +++ b/native/cli/src/commands/capsule/outside.rs @@ -0,0 +1,204 @@ +//! Capsule [`Subcommand`] behavior ([`CreateArgs`], [`delete_and_report`], [`prune`]) outside a workspace or scope. +//! +//! Handles [`run_outside_scope`] for `capsule list`, `capsule delete`, `capsule create`, and `capsule prune`. + +use super::{Subcommand, create::CreateArgs, delete_and_report, prune}; +use crate::{ + commands::{color::Chalk, globals::resolve_capsules_dir, workspace_command}, + native::Outcome, +}; +use std::{ + ffi::OsString, + io::{self, Write}, + path::Path, + process::ExitCode, +}; + +/// Executes `bit capsule` [`Subcommand`]s when invoked outside a workspace or bare scope. +pub(super) fn run_outside_scope( + cwd: &Path, + args: &[OsString], + argv: &[&str], + chalk: Chalk, + subcommand: &Subcommand, +) -> io::Result { + match subcommand { + Subcommand::Create(create_args) => run_create_outside_scope(chalk, argv, create_args), + Subcommand::Delete { all: false, scope_aspects: true } => run_outside_failure( + chalk, + "delete", + false, + "Cannot read properties of undefined (reading 'getAspectCapsulePath')", + ), + Subcommand::Delete { all: true, .. } | Subcommand::Delete { scope_aspects: false, .. } => { + run_delete_all_outside_scope(cwd, args, chalk) + } + Subcommand::Prune(prune_args) => prune::run_outside(cwd, args, chalk, prune_args), + Subcommand::BareList | Subcommand::List { .. } => run_list_outside_scope(chalk, subcommand), + } +} + +/// Handles `bit capsule create` when invoked outside any workspace or bare scope. +fn run_create_outside_scope(chalk: Chalk, argv: &[&str], args: &CreateArgs) -> io::Result { + if !supports_create_outside(argv, args) { + return Ok(Outcome::Declined("capsule create outside scope delegated".into())); + } + let has_base_dir = args.base_dir + .as_deref() + .is_some_and(|dir| !dir.trim().is_empty()); + let error = if args.ids.is_empty() { + "undefined is not iterable (cannot read property Symbol(Symbol.iterator))" + } else if has_base_dir || args.use_hash { + "Cannot read properties of undefined (reading 'resolveMultipleComponentIds')" + } else { + "Cannot read properties of undefined (reading 'shouldUseHashForCapsules')" + }; + run_outside_failure(chalk, "create", args.json, error) +} + +pub(super) fn run_create_in_bare_scope( + chalk: Chalk, + command_args: &[OsString], + args: &CreateArgs, +) -> io::Result { + let Some(argv) = workspace_command::utf8_args(command_args) else { + return Ok(Outcome::Declined("capsule create non-UTF-8 arguments".into())); + }; + if !supports_create_outside(&argv, args) { + return Ok(Outcome::Declined("capsule create bare scope delegated".into())); + } + let error = match args.ids.first() { + None => "undefined is not iterable (cannot read property Symbol(Symbol.iterator))".into(), + Some(id) if safe_unscoped_id(id) => { + format!("scope.getParsedId, the component {} must include a scope-name", id.trim()) + } + _ => return Ok(Outcome::Declined("capsule create scoped ID delegated".into())), + }; + run_outside_failure(chalk, "create", args.json, &error) +} + +#[inline] +fn safe_unscoped_id(id: &str) -> bool { + let id = id.trim(); + !id.is_empty() + && id + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) +} + +fn supports_create_outside(argv: &[&str], args: &CreateArgs) -> bool { + let filtered = argv + .iter() + .copied() + .filter(|arg| !matches!(*arg, "--no-color" | "--no-color=true" | "--no-color=false")) + .collect::>(); + let argv = filtered.as_slice(); + if supports_named_create_outside(argv, args) { + return true; + } + match argv { + ["capsule", "create"] + | [ + "capsule", + "create", + "--json" | "-j" | "--seeders-only" | "-s" | "--always-new" | "-a" | "--use-hash", + ] + | ["capsule", "create", "--base-dir" | "-b" | "--root-base-dir" | "-r", _] => { + args.ids.is_empty() + } + ["capsule", "create", _] + | [ + "capsule", + "create", + _, + "--json" | "-j" | "--seeders-only" | "-s" | "--always-new" | "-a" | "--use-hash", + ] + | ["capsule", "create", _, "--base-dir" | "-b" | "--root-base-dir" | "-r", _] => { + args.ids.len() == 1 + } + ["capsule", "create", _, _] | ["capsule", "create", _, _, "--json" | "-j"] => { + args.ids.len() == 2 + } + _ => false, + } +} + +#[inline] +fn supports_named_create_outside(argv: &[&str], args: &CreateArgs) -> bool { + match argv { + ["capsule", "create", "--id" | "-i" | "--package-manager" | "-p", _] + | ["capsule", "create", "--id" | "-i" | "--package-manager" | "-p", _, "--json" | "-j"] => { + args.ids.is_empty() + } + ["capsule", "create", _, "--id" | "-i" | "--package-manager" | "-p", _] + | ["capsule", "create", "--id" | "-i" | "--package-manager" | "-p", _, _] + | [ + "capsule", + "create", + _, + "--id" | "-i" | "--package-manager" | "-p", + _, + "--json" | "-j", + ] + | [ + "capsule", + "create", + "--id" | "-i" | "--package-manager" | "-p", + _, + _, + "--json" | "-j", + ] => args.ids.len() == 1, + _ => false, + } +} + +#[inline] +fn ci_enabled() -> bool { + std::env::var_os("CI") + .is_some_and(|value| { + let text = value.to_string_lossy(); + let trimmed = text.trim(); + !trimmed.is_empty() && trimmed != "0" && !trimmed.eq_ignore_ascii_case("false") + }) +} + +fn run_outside_failure(chalk: Chalk, label: &str, json: bool, error: &str) -> io::Result { + if ci_enabled() { + return Ok(Outcome::Declined("capsule CI error stack delegated".into())); + } + let output = workspace_command::loader_output(chalk, json); + output.restart(&format!(r#"running command "{label}"..."#)); + output.stop(); + writeln!(io::stderr(), "{error}")?; + Ok(Outcome::Exit(ExitCode::FAILURE)) +} + +fn run_list_outside_scope(chalk: Chalk, subcommand: &Subcommand) -> io::Result { + if ci_enabled() { + return Ok(Outcome::Declined("capsule list CI error stack delegated".into())); + } + let json = matches!(subcommand, Subcommand::List { json: true, .. }); + let output = workspace_command::loader_output(chalk, json); + output.restart(&format!(r#"running command "{}"..."#, subcommand.loader_label())); + output.stop(); + writeln!( + io::stderr(), + "This command requires a Bit workspace or scope.\nTo initialize a workspace: bit init", + )?; + Ok(Outcome::Exit(ExitCode::FAILURE)) +} + +fn run_delete_all_outside_scope( + cwd: &Path, + args: &[OsString], + chalk: Chalk, +) -> io::Result { + let Some(target_dir) = resolve_capsules_dir(cwd) + .ok() + .filter(|dir| !dir.trim().is_empty() && Path::new(dir.trim()).is_absolute()) + else { + return Ok(Outcome::Declined("capsule delete --all cache root delegated".into())); + }; + let report = delete_and_report(&target_dir, chalk)?; + workspace_command::execute_with_scope(cwd, args, "delete", &report) +} diff --git a/native/cli/src/commands/capsule/prune.rs b/native/cli/src/commands/capsule/prune.rs new file mode 100644 index 000000000000..c56d7aac1989 --- /dev/null +++ b/native/cli/src/commands/capsule/prune.rs @@ -0,0 +1,383 @@ +//! Native `capsule prune` ([`History`], [`Report`], [`emit_report`]) planning and removal. +//! +//! Parses [`PruneArgs`] and prunes stale or orphaned capsule roots as [`Removed`] entries. + +use super::{ + Chalk, History, Outcome, Report, ScopeRoot, emit_report, list_stats, resolve_capsules_dir, + workspace_command, +}; +use bit_workspace_config::{ + environment_value, global_config_dir, merge_layers, read_global_config, +}; +use chrono::{Datelike, Local}; +use serde::Serialize; +use serde_json::{Map, Value}; +use std::{ + ffi::OsString, + fs, + io::{self, Write}, + path::{Path, PathBuf}, + process::ExitCode, +}; + +/// Parsed CLI flags for `bit capsule prune`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(in crate::commands) struct PruneArgs { + dry_run: bool, + older_than: Option, + keep_workspace_caps: bool, + no_orphans: bool, + with_sizes: bool, + json: bool, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct Removed { + path: String, + kind: String, + reason: String, + size_bytes: u64, + #[serde(skip_serializing_if = "Option::is_none")] + origin_path: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct PruneReport { + removed: Vec, + total_removed_bytes: u64, + total_size_before_bytes: u64, + total_size_after_bytes: u64, + dry_run: bool, +} + +pub(super) fn parse_flags(rest: &[&str]) -> Option { + let mut parsed = PruneArgs { + dry_run: false, + older_than: None, + keep_workspace_caps: false, + no_orphans: false, + with_sizes: false, + json: false, + }; + let mut iter = rest.iter(); + while let Some(&arg) = iter.next() { + match arg { + "--" => return iter.next().is_none().then_some(parsed), + "--dry-run" | "--dry-run=true" => parsed.dry_run = true, + "--dry-run=false" => parsed.dry_run = false, + "--keep-workspace-caps" | "--keep-workspace-caps=true" => { + parsed.keep_workspace_caps = true; + } + "--keep-workspace-caps=false" => parsed.keep_workspace_caps = false, + "--no-orphans" | "--no-orphans=true" => parsed.no_orphans = true, + "--no-orphans=false" => parsed.no_orphans = false, + "--with-sizes" | "--with-sizes=true" => parsed.with_sizes = true, + "--with-sizes=false" => parsed.with_sizes = false, + "-j" | "-j=true" | "--json" | "--json=true" => parsed.json = true, + "-j=false" | "--json=false" => parsed.json = false, + "--older-than" => { + let next = iter.next()?.trim(); + (!next.is_empty() && !next.starts_with('-')).then_some(())?; + parsed.older_than = Some(next.to_owned()); + } + value if value.starts_with("--older-than=") => { + let val = value["--older-than=".len()..].trim(); + (!val.is_empty() && !val.starts_with('-')).then_some(())?; + parsed.older_than = Some(val.to_owned()); + } + _ => return None, + } + } + Some(parsed) +} + +pub(super) fn run( + cwd: &Path, + root: &ScopeRoot, + args: &[OsString], + chalk: Chalk, + opts: &PruneArgs, +) -> io::Result { + let Ok(report) = build(cwd, Some(root), opts) else { + return Ok(Outcome::Declined("capsule prune cache scan delegated".into())); + }; + if !opts.dry_run { + remove_selected(cwd, &report)?; + } + if opts.json { + let history = (!root.bare).then(|| History::start(&root.path, args)); + let mut stdout = io::stdout().lock(); + stdout.write_all(serde_json::to_string_pretty(&report)?.as_bytes())?; + stdout.flush()?; + if let Some(history) = history { + history.end(true); + } + return Ok(Outcome::Exit(ExitCode::SUCCESS)); + } + emit_report(root, args, "prune", render(&report, opts.with_sizes, chalk)) +} + +pub(super) fn run_outside( + cwd: &Path, + args: &[OsString], + chalk: Chalk, + opts: &PruneArgs, +) -> io::Result { + let Ok(report) = build(cwd, None, opts) else { + return Ok(Outcome::Declined("capsule prune cache scan delegated".into())); + }; + if !opts.dry_run { + remove_selected(cwd, &report)?; + } + if opts.json { + let mut stdout = io::stdout().lock(); + stdout.write_all(serde_json::to_string_pretty(&report)?.as_bytes())?; + stdout.flush()?; + return Ok(Outcome::Exit(ExitCode::SUCCESS)); + } + let data = render(&report, opts.with_sizes, chalk); + let report = Report { data, code: 0, details: None }; + workspace_command::execute_with_scope(cwd, args, "prune", &report) +} + +fn build(cwd: &Path, root: Option<&ScopeRoot>, opts: &PruneArgs) -> io::Result { + let config = prune_config(root)?; + let configured_dated = environment_value("capsules_scopes_aspects_dated_dir"); + let dated = configured_dated + .as_deref() + .or_else(|| config.get("capsules_scopes_aspects_dated_dir").and_then(Value::as_str)) + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("dated-capsules"); + let roots = list_stats::collect(cwd, opts.with_sizes)?; + let age_days = parse_age(opts, &config); + let cutoff = age_days.mul_add(-86_400_000.0, list_stats::now_millis() as f64); + let mut removed = Vec::new(); + for entry in &roots { + if Path::new(&entry.path) + .file_name() + .is_some_and(|name| name == dated) + { + select_dated_children(Path::new(&entry.path), opts.with_sizes, &mut removed); + } else { + select_root(entry, opts, cutoff, age_days, &mut removed); + } + } + let before = list_stats::total_size(&roots); + let removed_bytes = removed + .iter() + .map(|entry: &Removed| entry.size_bytes) + .sum(); + Ok(PruneReport { + removed, + total_removed_bytes: removed_bytes, + total_size_before_bytes: before, + total_size_after_bytes: before.saturating_sub(removed_bytes), + dry_run: opts.dry_run, + }) +} + +#[inline] +fn prune_config(root: Option<&ScopeRoot>) -> io::Result> { + if let Some(root) = root { + list_stats::read_combined_config(root) + } else { + Ok(merge_layers(&read_global_config(&global_config_dir())?, &Value::Null, &Value::Null)) + } +} + +#[inline] +fn parse_age(opts: &PruneArgs, config: &Map) -> f64 { + let cli = opts.older_than + .as_deref() + .and_then(|raw| { + let text = raw.trim(); + if text.is_empty() { Some(0.0) } else { text.parse::().ok() } + }) + .filter(|value| value.is_finite()); + cli.unwrap_or_else(|| list_stats::max_age_days(config)).max(0.0) +} + +fn select_root( + entry: &list_stats::RootEntry, + opts: &PruneArgs, + cutoff: f64, + age_days: f64, + removed: &mut Vec, +) { + let path = Path::new(&entry.path); + match entry.kind.as_str() { + "workspace" if !opts.keep_workspace_caps => record(entry, "workspace-cap", removed), + "scope" | "unmarked" => select_scope_or_unmarked(entry, opts, cutoff, age_days, removed), + "scope-aspects-root" => select_aspect_children(path, opts.with_sizes, cutoff, removed), + _ => {} + } +} + +fn select_scope_or_unmarked( + entry: &list_stats::RootEntry, + opts: &PruneArgs, + cutoff: f64, + age_days: f64, + removed: &mut Vec, +) { + let orphan = !opts.no_orphans + && entry.origin_path + .as_ref() + .is_some_and(|origin| !Path::new(origin).exists()); + if orphan { + record(entry, "orphan", removed); + return; + } + if (entry.last_used_ms as f64) >= cutoff { + return; + } + let path = Path::new(&entry.path); + if entry.kind == "unmarked" && looks_like_aspects_root(path) { + select_aspect_children(path, opts.with_sizes, cutoff, removed); + } else { + record(entry, &format!("older-than-{age_days}d"), removed); + } +} + +fn looks_like_aspects_root(path: &Path) -> bool { + let Ok(entries) = fs::read_dir(path) else { return false }; + entries + .filter_map(Result::ok) + .any(|entry| { + entry.file_type().is_ok_and(|kind| kind.is_dir()) + && entry + .file_name() + .as_encoded_bytes() + .contains(&b'@') + }) +} + +fn select_aspect_children(path: &Path, with_sizes: bool, cutoff: f64, removed: &mut Vec) { + let Ok(entries) = fs::read_dir(path) else { return }; + let mut children = entries + .filter_map(Result::ok) + .filter(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir())) + .filter(|entry| { + let name = entry.file_name(); + name != "node_modules" && !name.as_encoded_bytes().starts_with(b".") + }) + .map(|entry| entry.path()) + .collect::>(); + children.sort(); + for child in &children { + let entry = list_stats::inspect(child, with_sizes); + if (entry.last_used_ms as f64) < cutoff { + record(&entry, "aspect-older-than-cutoff", removed); + } + } +} + +fn select_dated_children(path: &Path, with_sizes: bool, removed: &mut Vec) { + let now = Local::now(); + let today = format!("{}-{}-{}", now.year(), now.month(), now.day()); + let Ok(entries) = fs::read_dir(path) else { return }; + let mut children = entries + .filter_map(Result::ok) + .filter(|entry| { + if !entry.file_type().is_ok_and(|kind| kind.is_dir()) { + return false; + } + let name = entry.file_name(); + name != "node_modules" + && !name.as_encoded_bytes().starts_with(b".") + && name != today.as_str() + }) + .map(|entry| entry.path()) + .collect::>(); + children.sort(); + for child in children { + removed.push(Removed { + path: child.to_string_lossy().into_owned(), + kind: "unmarked".into(), + reason: "dated-capsules-not-today".into(), + size_bytes: if with_sizes { list_stats::dir_size(&child) } else { 0 }, + origin_path: None, + }); + } +} + +fn remove_selected(cwd: &Path, report: &PruneReport) -> io::Result<()> { + if report.removed.is_empty() { + return Ok(()); + } + let cache = PathBuf::from(resolve_capsules_dir(cwd)?).canonicalize()?; + // Only remove planned directories inside the resolved cache. Re-check after planning + // so a replaced symlink cannot direct deletion outside the cache. + for entry in &report.removed { + let target = Path::new(&entry.path); + let parent = target.parent().ok_or_else(|| io::Error::other("invalid capsule path"))?; + let resolved_parent = parent.canonicalize()?; + if !resolved_parent.starts_with(&cache) || !fs::symlink_metadata(target)?.is_dir() { + return Err(io::Error::other("capsule path changed while pruning")); + } + fs::remove_dir_all(target)?; + } + Ok(()) +} + +#[inline] +fn record(entry: &list_stats::RootEntry, reason: &str, removed: &mut Vec) { + let kind = if reason == "aspect-older-than-cutoff" { "scope-aspect" } else { &entry.kind }; + removed.push(Removed { + path: entry.path.clone(), + kind: kind.into(), + reason: reason.into(), + size_bytes: entry.size_bytes, + origin_path: entry.origin_path.clone(), + }); +} + +fn render(report: &PruneReport, with_sizes: bool, chalk: Chalk) -> String { + let formatter = super::Formatter::new(chalk); + let mut lines = vec![if report.dry_run { + formatter.title(&format!("[dry-run] would remove {} capsule(s)", report.removed.len())) + } else { + formatter.success_summary(&format!("removed {} capsule(s)", report.removed.len())) + }]; + if with_sizes && report.total_size_before_bytes > 0 { + lines.push(formatter.item(&format!( + "cache: {} → {} (freed {})", + list_stats::format_bytes(report.total_size_before_bytes), + list_stats::format_bytes(report.total_size_after_bytes), + list_stats::format_bytes(report.total_removed_bytes), + ))); + } else { + lines.push(formatter.hint( + "re-run with --with-sizes to see byte totals (walks the full cache)", + )); + } + lines.extend( + report.removed + .iter() + .take(50) + .map(|item| render_item(item, chalk, &formatter)), + ); + if report.removed.len() > 50 { + lines.push(formatter.hint(&format!( + "...and {} more (use --json to see the full list)", + report.removed.len() - 50, + ))); + } + lines.join("\n") +} + +#[inline] +fn render_item(item: &Removed, chalk: Chalk, formatter: &super::Formatter) -> String { + let origin = item.origin_path + .as_ref() + .map_or_else(String::new, |origin| format!(" {}", chalk.dim(&format!("← {origin}")))); + formatter.item(&format!( + "{} {}{origin} {}", + chalk.dim(&format!("[{} · {}]", item.kind, item.reason)), + item.path, + chalk.dim(&format!("({})", list_stats::format_bytes(item.size_bytes))), + )) +} diff --git a/native/cli/src/commands/capsule/tests.rs b/native/cli/src/commands/capsule/tests.rs new file mode 100644 index 000000000000..c3b52905bbb4 --- /dev/null +++ b/native/cli/src/commands/capsule/tests.rs @@ -0,0 +1,99 @@ +//! Unit tests for `capsule` [`parse_subcommand`] parsing and [`object_hash_short`] SHA-1 hashing. +//! +//! Verifies [`Subcommand`] and [`CreateArgs`] flag parsing for `list`, `create`, and `delete`. + +use super::{Subcommand, create::CreateArgs, object_hash_short, parse_subcommand}; + +/// Verifies `bit capsule` subcommand parsing across `list`, `delete`, `prune`, and `create`. +/// +/// Covers short and long flags as well as `--id` and `--base-dir` assignments. +#[test] +fn parses_capsule_subcommands() { + assert_eq!(parse_subcommand(&[]), Some(Subcommand::BareList)); + assert_eq!( + parse_subcommand(&["list"]), + Some(Subcommand::List { json: false, with_stats: false }), + ); + assert_eq!( + parse_subcommand(&["list", "--json"]), + Some(Subcommand::List { json: true, with_stats: false }), + ); + assert_eq!( + parse_subcommand(&["delete", "--scope-aspects"]), + Some(Subcommand::Delete { all: false, scope_aspects: true }), + ); + assert_eq!( + parse_subcommand(&["delete", "-a"]), + Some(Subcommand::Delete { all: true, scope_aspects: false }), + ); + assert_eq!( + parse_subcommand(&["list", "--with-stats"]), + Some(Subcommand::List { json: false, with_stats: true }), + ); + assert!(matches!( + parse_subcommand(&["prune", "--dry-run", "--with-sizes", "--json"]), + Some(Subcommand::Prune(_)), + )); + assert!(matches!(parse_subcommand(&["prune", "--json"]), Some(Subcommand::Prune(_)))); + assert_eq!( + parse_subcommand(&["create", "--seeders-only", "comp1", "-j"]), + Some(Subcommand::Create(CreateArgs { + ids: vec!["comp1".into()], + seeders_only: true, + json: true, + base_dir: None, + always_new: false, + name: None, + root_base_dir: None, + use_hash: false, + })), + ); + assert_eq!( + parse_subcommand(&["create", "comp1", "--base-dir", "custom"]), + Some(Subcommand::Create(CreateArgs { + ids: vec!["comp1".into()], + seeders_only: false, + json: false, + base_dir: Some("custom".into()), + always_new: false, + name: None, + root_base_dir: None, + use_hash: false, + })), + ); + assert!(matches!( + parse_subcommand(&["create", "comp1", "--id", "custom"]), + Some(Subcommand::Create(CreateArgs { name: Some(name), .. })) if name == "custom" + )); + assert!(matches!( + parse_subcommand(&["create", "comp1", "-a"]), + Some(Subcommand::Create(CreateArgs { always_new: true, .. })) + )); + assert!(matches!( + parse_subcommand(&[ + "create", + "comp1", + "--id=custom", + "--base-dir=sub", + "--json=true", + "--no-color", + ]), + Some(Subcommand::Create(CreateArgs { + json: true, + name: Some(name), + base_dir: Some(base), + .. + })) if name == "custom" && base == "sub" + )); + assert!(matches!(parse_subcommand(&["prune"]), Some(Subcommand::Prune(_)))); +} + +#[test] +fn matches_object_hash_short_sha1() { + assert_eq!(object_hash_short("/tmp/test-ws"), "4d90daa1e"); +} + +#[test] +fn unrecognized_capsule_subcommand_returns_none() { + assert_eq!(parse_subcommand(&["unknown-subcommand"]), None); +} diff --git a/native/cli/src/commands/cat.rs b/native/cli/src/commands/cat.rs new file mode 100644 index 000000000000..8861324f8b7c --- /dev/null +++ b/native/cli/src/commands/cat.rs @@ -0,0 +1,393 @@ +//! `CatCmd` ([`Options`], [`parse`]): inspect validated stored Sources without returning file bodies through Node. +//! +//! Coordinates [`options`] parsing, [`docs`] preparation, and [`report`] rendering for `bit cat` via [`COMMAND`]. + +mod options; +mod report; + +use self::options::{Options, parse}; +use super::{ + Command, + add::docs, + engine::Engine, + list::bitmap, + status::{environments::Environments, js::decline}, + workspace_command, +}; +use crate::{ + discovery::{self, Target}, + history::History, + host::Host, + native::Outcome, + workspace, +}; +use serde_json::{Value, json}; +use std::{ + env, + ffi::OsString, + io::{self, Write}, + path::Path, + process::ExitCode, +}; + +/// Native `bit cat` command descriptor. +pub const COMMAND: Command = Command { name: "cat", aliases: &[], run }; + +const FILE_WITH_CONFIG_ERR: &str = + "--file cannot be used with --config. use --all to show both files and config"; +const NO_HOST_ERR: &str = + "unable to find a component host. please run this command from within a Bit workspace or scope"; + +fn run(args: &[OsString]) -> io::Result { + let Some(arguments) = workspace_command::utf8_args(args) else { + return Ok(Outcome::Declined("outside native cat grammar".into())); + }; + let Some(options) = parse(&arguments[1..]) else { + return Ok(Outcome::Declined("outside native cat grammar".into())); + }; + let cwd = env::current_dir()?; + if options.file.is_some() && options.config && !options.all { + return emit_file_with_config_error(&cwd, args, options.json); + } + let Some(root) = workspace::root(&cwd) else { + return run_outside_workspace(&cwd, args, options.json); + }; + let prepared = match prepare(&root, &options) { + Ok(prepared) => prepared, + Err(Failure::Declined(error)) => return Ok(Outcome::Declined(format!("cat: {error}"))), + Err(Failure::Committed(error, output)) => { + History::start(&root, args).end(false); + io::stdout().write_all(output.as_bytes())?; + return Err(error); + } + }; + present(&root, args, &options, prepared) +} + +#[inline] +fn ci_enabled() -> bool { + env::var_os("CI") + .is_some_and(|val| { + let text = val.to_string_lossy(); + let trimmed = text.trim(); + !trimmed.is_empty() && trimmed != "0" && !trimmed.eq_ignore_ascii_case("false") + }) +} + +fn emit_file_with_config_error(cwd: &Path, args: &[OsString], json: bool) -> io::Result { + if ci_enabled() { + return Ok(Outcome::Declined("published cat option error".into())); + } + if let Some(root) = workspace::root(cwd).filter(|root| !root.as_os_str().is_empty()) { + let fail = |_: &bit_install::output::Output| Err(io::Error::other(FILE_WITH_CONFIG_ERR)); + return if json { + workspace_command::execute_json(&root, args, "cat", fail) + } else { + workspace_command::execute_then(&root, args, "cat", fail) + }; + } + let err = io::Error::other(FILE_WITH_CONFIG_ERR); + workspace_command::execute_failure_scope(cwd, args, "cat", json, "", err) +} + +fn run_outside_workspace(cwd: &Path, args: &[OsString], json: bool) -> io::Result { + if ci_enabled() + || cwd + .ancestors() + .any(|dir| { + dir.join("scope.json").is_file() + || dir.join(".bit").is_dir() + || dir.join(".git/bit").is_dir() + }) + { + return Ok(Outcome::Declined("cat requires a supported workspace".into())); + } + let err = io::Error::other(NO_HOST_ERR); + workspace_command::execute_failure_scope(cwd, args, "cat", json, "", err) +} + +fn present( + root: &Path, + args: &[OsString], + options: &Options<'_>, + mut prepared: Prepared, +) -> io::Result { + let rendered = match report::render(&prepared.value, options) { + Ok(rendered) => rendered, + Err(error) if prepared.metadata || prepared.committed => { + History::start(root, args).end(false); + return Err(error); + } + Err(error) => return Ok(Outcome::Declined(format!("cat: {error}"))), + }; + io::stdout().write_all(prepared.output.as_bytes())?; + if !options.json { + let report = workspace_command::results::Report { data: rendered, code: 0, details: None }; + return workspace_command::execute_with(root, args, "cat", &report, |_| { + prepared.docs + .take() + .map_or(Ok(()), docs::CatDocs::publish) + }); + } + json_output(root, args, &rendered, prepared.docs.take()) +} + +fn json_output( + root: &Path, + args: &[OsString], + rendered: &str, + cache: Option, +) -> io::Result { + let history = History::start(root, args); + let result = (|| { + if let Some(cache) = cache { + cache.publish()?; + } + let mut stdout = io::stdout().lock(); + stdout.write_all(rendered.as_bytes())?; + stdout.flush() + })(); + history.end(result.is_ok()); + result?; + Ok(Outcome::Exit(ExitCode::SUCCESS)) +} + +struct Prepared { + value: Value, + output: String, + metadata: bool, + committed: bool, + docs: Option, +} + +enum Failure { + Declined(io::Error), + Committed(io::Error, String), +} + +impl From for Failure { + #[inline] + fn from(error: io::Error) -> Self { + Self::Declined(error) + } +} + +#[derive(Default)] +struct Read { + value: Value, + docs: Option, + output: String, + committed: bool, +} + +fn prepare(root: &Path, options: &Options<'_>) -> Result { + let raw = options.id.ok_or_else(|| decline("cat identity"))?.trim(); + let (requested, explicit) = raw + .rsplit_once('@') + .map_or((raw, None), |(id, version)| (id.trim(), Some(version.trim()))); + if requested.is_empty() || explicit.is_some_and(str::is_empty) { + return Err(decline("cat identity or version").into()); + } + let entry = selected_entry(root, requested)?; + let mut engine = Engine::new()?; + require_component(root, &entry, &mut engine)?; + let current_version = + explicit.is_none_or(|version| entry.version.as_deref().map(str::trim) == Some(version)); + let mut source = if current_version { + read_current(root, &entry, options)? + } else { + let ver = explicit.ok_or_else(|| decline("cat stored version"))?; + Read { value: read_stored(root, &entry, ver, &mut engine)?, ..Default::default() } + }; + source.value["id"] = json!(entry.identity); + let metadata = + options.config || options.all || (current_version && source.docs.is_none()); + if metadata { + source.output = + cat_metadata(root, raw, &mut source.value, current_version.then_some(&entry))?; + } + Ok(Prepared { + value: source.value, + output: source.output, + metadata, + committed: source.committed, + docs: source.docs, + }) +} + +fn read_current( + root: &Path, + entry: &bitmap::Entry, + options: &Options<'_>, +) -> Result { + let current = workspace_files(root, entry)?; + if let Some(file) = options.file { + report::select(¤t.files, file)?; + } + let mut source = Read { + value: json!({"version":entry.version.as_deref().unwrap_or("latest"),"files":current.files}), + docs: if options.config || options.all { + None + } else { + docs::cat_prepare(root, current.docs) + }, + ..Default::default() + }; + if let Some(plan) = source.docs.as_mut() + && !plan.misses().is_empty() + { + if let Some(output) = parse_docs(root, plan)? { + source.committed = true; + source.output = output; + } else { + source.docs = None; + } + } + Ok(source) +} + +fn parse_docs(root: &Path, plan: &mut docs::CatDocs) -> Result, Failure> { + let Target::Entry(cli) = discovery::find()? else { + return Err(Failure::Declined(decline("published cat semantic parser unavailable"))); + }; + let host = Host::new(cli, root.to_owned(), false); + let supported = host + .call("catSemanticDocsSupport", &json!({})) + .and_then(|value| serde_json::from_value::(value).map_err(io::Error::other)) + .map_err(|error| Failure::Committed(error, host.take_stdout()))?; + if !supported { + return Ok(None); + } + let writes = host + .call( + "catSemanticDocs", + &json!({ + "components":plan.components(), + "scopePath":bit_object_import::scope_path(root), + }), + ) + .map_err(|error| Failure::Committed(error, host.take_stdout()))?; + if let Some(error) = + plan.absorb(&writes).map_err(|error| Failure::Committed(error, host.take_stdout()))? + { + return Err(Failure::Committed(io::Error::other(error), host.take_stdout())); + } + Ok(Some(host.take_stdout())) +} + +#[inline] +fn read_stored( + root: &Path, + entry: &bitmap::Entry, + version: &str, + engine: &mut Engine, +) -> io::Result { + engine.request(&json!({ + "kind":"componentCat", "root":root, + "storedIdentity":entry.identity, "version":version, + })) +} + +fn selected_entry(root: &Path, requested: &str) -> io::Result { + let requested = requested.trim(); + let mut matches = bitmap::read(root)?.entries + .into_iter() + .filter(|entry| { + let identity = entry.identity.trim(); + identity == requested + || identity + .split_once('/') + .is_some_and(|(_, name)| name.trim() == requested) + }); + let entry = matches.next().ok_or_else(|| decline("cat identity absent"))?; + if matches.next().is_some() { + return Err(decline("cat identity ambiguous")); + } + Ok(entry) +} + +fn require_component(root: &Path, entry: &bitmap::Entry, engine: &mut Engine) -> io::Result<()> { + let context: Value = engine.request(&json!({ + "kind":"statusContext", "root":root, "home":workspace_command::home(), "history":false, + }))?; + let entry_id = entry.identity.trim(); + let target = context["targets"] + .as_array() + .and_then(|targets| { + targets + .iter() + .find(|target| target["identity"].as_str().map(str::trim) == Some(entry_id)) + }) + .ok_or_else(|| decline("cat current component context"))?; + Environments::require_native_write_for(root, &context, std::slice::from_ref(target)) +} + +fn cat_metadata( + root: &Path, + id: &str, + value: &mut Value, + current: Option<&bitmap::Entry>, +) -> Result { + let Target::Entry(cli) = discovery::find()? else { + return Err(Failure::Declined(decline("published CatCmd host unavailable"))); + }; + let host = Host::new(cli, root.to_owned(), false); + let supported: bool = host + .call("catSupport", &json!({})) + .and_then(|value| serde_json::from_value(value).map_err(io::Error::other))?; + if !supported { + return Err(Failure::Declined(decline("published CatCmd metadata unavailable"))); + } + let mut metadata: Value = host + .call("catMetadata", &json!({"id":id})) + .map_err(|error| Failure::Committed(error, host.take_stdout()))?; + value["id"] = metadata["id"].take(); + value["version"] = metadata["version"].take(); + value["config"] = metadata["config"].take(); + if let Some(entry) = current { + let refreshed = workspace_files(root, entry) + .map_err(|error| Failure::Committed(error, host.take_stdout()))?; + if value["files"].as_array() != Some(&refreshed.files) { + return Err(Failure::Committed( + io::Error::other("cat workspace sources changed during metadata capture"), + host.take_stdout(), + )); + } + } + Ok(host.take_stdout()) +} + +struct WorkspaceFiles { + files: Vec, + docs: Value, +} + +fn workspace_files(root: &Path, entry: &bitmap::Entry) -> io::Result { + if !report::safe_relative(&entry.root_dir) { + return Err(decline("cat workspace root path")); + } + let config = bit_install::config::WorkspaceConfig::load(root)?; + let core: Vec = serde_json::from_str(include_str!( + "../../../../scopes/harmony/testing/load-aspect/core-aspects-ids.json" + ))?; + let workspace = bit_install::workspace::Workspace::load(root, &config, &core)?; + let (component, bitmap_entry) = workspace.components + .iter() + .zip(&workspace.entries) + .find(|(component, _)| component.id.to_string_without_version() == entry.identity) + .ok_or_else(|| decline("cat workspace component absent"))?; + if component.root_dir != entry.root_dir || !component.files.contains(&bitmap_entry.main_file) { + return Err(decline("cat workspace directory changed")); + } + // Both Workspace::load and BitMap.trackDirectoryChanges rescan a component + // with a rootDir; published getFilesByDir marks every scanned file test:false. + let docs = json!({ + "directory":component.root_dir, + "files":component.files.iter().map(|file| json!({ + "relativePath":file, + "name":file.rsplit('/').next().unwrap_or(file), + "test":false, + })).collect::>(), + }); + Ok(WorkspaceFiles { files: report::source_files(&component.dir, &component.files)?, docs }) +} diff --git a/native/cli/src/commands/cat/options.rs b/native/cli/src/commands/cat/options.rs new file mode 100644 index 000000000000..35ae526aac10 --- /dev/null +++ b/native/cli/src/commands/cat/options.rs @@ -0,0 +1,127 @@ +//! [`Options`] and [`parse`] option parsing for `bit cat`. +//! +//! Validates component ID positionals and `--file`, `--config`, `--all`, and `--json` flags. + +/// Parsed CLI flags and positional component ID for `bit cat`. +#[derive(Default)] +pub(super) struct Options<'a> { + pub(super) id: Option<&'a str>, + pub(super) file: Option<&'a str>, + pub(super) config: bool, + pub(super) all: bool, + pub(super) json: bool, +} + +/// Parses `args` into [`Options`], requiring exactly one component ID positional argument. +pub(super) fn parse<'a>(args: &[&'a str]) -> Option> { + let mut options = Options::default(); + let mut index = 0; + while let Some(&arg) = args.get(index) { + if arg == "--" { + for &rest in &args[index + 1..] { + let trimmed = rest.trim(); + (!trimmed.is_empty() + && !trimmed.starts_with('-') + && options.id.replace(trimmed).is_none()) + .then_some(())?; + } + break; + } + index += apply_cat_arg(&mut options, arg, args.get(index + 1).copied())?; + } + options.id.map(|_| options) +} + +fn apply_cat_arg<'a>( + options: &mut Options<'a>, + arg: &'a str, + next: Option<&'a str>, +) -> Option { + if let Some(val) = arg + .strip_prefix("--file=") + .or_else(|| arg.strip_prefix("-f=")) + { + return set_cat_file(options, val, 1); + } + if apply_cat_combined_short(options, arg) + || apply_cat_bool(options, arg) + || matches!(arg, "--no-color" | "--no-color=true" | "--no-color=false") + { + return Some(1); + } + match arg { + "--file" | "-f" => set_cat_file(options, next?, 2), + id if !id.starts_with('-') && !id.trim().is_empty() && options.id.is_none() => { + options.id = Some(id.trim()); + Some(1) + } + _ => None, + } +} + +fn apply_cat_bool(options: &mut Options<'_>, arg: &str) -> bool { + let (flag, val) = match arg.split_once('=') { + Some((flag, raw)) => { + let Some(val) = parse_bool_val(raw) else { return false }; + (flag.trim(), val) + } + None => (arg.trim(), true), + }; + if !val && !arg.starts_with("--") { + return false; + } + match flag { + "--config" | "-c" => options.config = val, + "--all" | "-a" => options.all = val, + "--json" | "-j" => options.json = val, + _ => return false, + } + true +} + +#[inline] +fn parse_bool_val(raw: &str) -> Option { + let trimmed = raw.trim(); + if trimmed.eq_ignore_ascii_case("true") || trimmed == "1" { + Some(true) + } else if trimmed.eq_ignore_ascii_case("false") || trimmed == "0" { + Some(false) + } else { + None + } +} + +#[inline] +fn apply_cat_combined_short(options: &mut Options<'_>, arg: &str) -> bool { + if !arg.starts_with('-') + || arg.starts_with("--") + || arg.len() <= 2 + || !arg[1..] + .bytes() + .all(|byte| matches!(byte, b'c' | b'a' | b'j')) + { + return false; + } + for byte in arg[1..].bytes() { + match byte { + b'c' => options.config = true, + b'a' => options.all = true, + _ => options.json = true, + } + } + true +} + +#[inline] +fn set_cat_file<'a>(options: &mut Options<'a>, val: &'a str, consumed: usize) -> Option { + let trimmed = val.trim(); + if options.file.is_some() + || trimmed.is_empty() + || trimmed.starts_with('-') + || trimmed.chars().any(char::is_control) + { + return None; + } + options.file = Some(trimmed); + Some(consumed) +} diff --git a/native/cli/src/commands/cat/report.rs b/native/cli/src/commands/cat/report.rs new file mode 100644 index 000000000000..f56b1f788c84 --- /dev/null +++ b/native/cli/src/commands/cat/report.rs @@ -0,0 +1,176 @@ +//! `CatCmd.report` and `CatCmd.json` ([`Options`]) presentation of native Source bodies. +//! +//! Renders file contents and component configuration via [`render`]. + +use super::Options; +use crate::commands::status::js::decline; +use serde_json::{Map, Value, json}; +use std::{ + io, + path::{Component, Path}, +}; + +/// Renders `bit cat` output in text or `--json` mode according to `options`. +pub(super) fn render(value: &Value, options: &Options<'_>) -> io::Result { + let files = value["files"].as_array().ok_or_else(|| decline("cat files"))?; + let show_files = !options.config || options.all; + let show_config = options.config || options.all; + let selected = options.file.map(|file| select(files, file)).transpose()?; + if options.json { + return json_report(value, files, selected, show_files, show_config); + } + let mut sections = Vec::with_capacity(2); + if show_files { + let slice = selected.as_ref().map_or(files.as_slice(), std::slice::from_ref); + sections.push(text_files(slice, options.file.is_some())?); + } + if show_config { + sections.push(text_config(&value["config"])?); + } + Ok(sections.join("\n\n")) +} + +/// Formats the `--json` payload for `bit cat` from `value`, `files`, and optional `selected` file. +fn json_report( + value: &Value, + files: &[Value], + selected: Option, + show_files: bool, + show_config: bool, +) -> io::Result { + let mut output = Map::with_capacity(4); + output.insert("id".into(), value["id"].clone()); + let version = if value["version"].is_null() { &value["head"] } else { &value["version"] }; + if !version.is_null() { + output.insert("version".into(), version.clone()); + } + if show_files { + output.insert( + "files".into(), + json!(selected.map_or_else(|| files.to_vec(), |file| vec![file])), + ); + } + if show_config { + output.insert("config".into(), value["config"].clone()); + } + serde_json::to_string_pretty(&Value::Object(output)).map_err(io::Error::other) +} + +pub(super) fn select(files: &[Value], requested: &str) -> io::Result { + let normalized = requested + .trim() + .replace('\\', "/") + .trim_start_matches("./") + .trim_start_matches('/') + .to_owned(); + if normalized.is_empty() { + return Err(decline("cat requested file absent")); + } + files + .iter() + .find(|file| { + file_path(file) + .is_some_and(|candidate| { + candidate + .replace('\\', "/") + .trim_start_matches("./") + .trim_start_matches('/') + == normalized + }) + }) + .cloned() + .ok_or_else(|| decline("cat requested file absent")) +} + +fn text_files(files: &[Value], single: bool) -> io::Result { + files + .iter() + .map(|file| { + let path = file_path(file).ok_or_else(|| decline("cat file path"))?; + let content = file["content"].as_str().ok_or_else(|| decline("cat file content"))?; + Ok(if single { content.to_owned() } else { format!("--- {path} ---\n{content}") }) + }) + .collect::>>() + .map(|files| files.join("\n\n")) +} + +#[inline] +fn file_path(file: &Value) -> Option<&str> { + file["path"] + .as_str() + .map(str::trim) + .filter(|path| !path.is_empty()) + .or_else(|| { + file["relativePath"] + .as_str() + .map(str::trim) + .filter(|path| !path.is_empty()) + }) +} + +fn text_config(config: &Value) -> io::Result { + let mut lines = Vec::new(); + if let Some(env) = config["env"] + .as_str() + .map(str::trim) + .filter(|env| !env.is_empty()) + { + lines.push(format!("env: {env}")); + } + for field in ["dependencies", "devDependencies", "peerDependencies"] { + let Some(deps) = config[field].as_object() else { continue }; + if deps.is_empty() { + continue; + } + lines.push(format!("{field}:")); + for (name, version) in deps { + let name = name.trim(); + let version = version + .as_str() + .map(str::trim) + .ok_or_else(|| decline("cat dependency version"))?; + lines.push(format!(" {name}: {version}")); + } + } + Ok(lines.join("\n")) +} + +pub(super) fn source_files(directory: &Path, names: &[String]) -> io::Result> { + if names.len() > 16_384 { + return Err(decline("cat workspace file count budget")); + } + let mut current = Vec::with_capacity(names.len()); + let mut remaining = 96 * 1024 * 1024_u64; + for name in names { + if !safe_relative(name) { + return Err(decline("cat workspace file path")); + } + let path = directory.join(name); + let metadata = std::fs::symlink_metadata(&path)?; + if metadata.file_type().is_symlink() + || !metadata.is_file() + || metadata.len() > 8 * 1024 * 1024 + || metadata.len() > remaining + { + return Err(decline("cat workspace file shape or size")); + } + let bytes = std::fs::read(path)?; + remaining = remaining + .checked_sub(bytes.len() as u64) + .ok_or_else(|| decline("cat workspace source budget"))?; + current.push(json!({"path":name,"content":String::from_utf8_lossy(&bytes)})); + } + Ok(current) +} + +#[inline] +pub(super) fn safe_relative(path: &str) -> bool { + let path = path.trim(); + path == "." + || (!path.is_empty() + && !path.contains('\\') + && !path.chars().any(char::is_control) + && Path::new(path) + .components() + .all(|component| matches!(component, Component::Normal(_)))) +} diff --git a/native/cli/src/commands/cat_scope.rs b/native/cli/src/commands/cat_scope.rs new file mode 100644 index 000000000000..928bab69838e --- /dev/null +++ b/native/cli/src/commands/cat_scope.rs @@ -0,0 +1,380 @@ +//! Native `bit cat-scope [scopePath] [-f|--full] [-j|--json] [-e|--json-extra]` (`cat-scope-cmd.ts`). +//! +//! Lists stored `ModelComponent` entries in table or [`json`] format for local and bare scopes. + +mod json; +#[cfg(test)] +mod tests; + +use super::{ + Command, + debug_cat::{emit_scope_not_found, emit_text, sha1_hex}, + lane::scope_root::{self, ScopeRoot}, + list::table, + workspace_command, +}; +use crate::{history::History, native::Outcome, workspace}; +use serde_json::{Map, Value}; +use std::{ + env, + ffi::OsString, + fs, + io::{self, Write}, + path::{Path, PathBuf}, + process::ExitCode, +}; + +/// Native `bit cat-scope` command descriptor. +pub const COMMAND: Command = Command { name: "cat-scope", aliases: &[], run }; + +/// Parsed CLI flags and optional scope path argument for `bit cat-scope`. +#[derive(Debug, Default, PartialEq, Eq)] +struct Options { + scope_path: Option, + full: bool, + json: bool, + json_extra: bool, +} + +struct ScopeEntry { + id: String, + hash: String, + kind: &'static str, + json_obj: Option, +} + +fn parse_options(args: &[&str]) -> Option { + let mut opts = Options::default(); + let mut iter = args.iter().copied(); + while let Some(arg) = iter.next() { + if arg == "--" { + consume_trailing_scope_path(iter, &mut opts.scope_path)?; + break; + } + apply_cat_scope_arg(&mut opts, arg)?; + } + Some(opts) +} + +#[inline] +fn consume_trailing_scope_path<'a>( + iter: impl Iterator, + scope_path: &mut Option, +) -> Option<()> { + for rest in iter { + let trimmed = rest.trim(); + if trimmed.is_empty() || trimmed.starts_with('-') || scope_path.is_some() { + return None; + } + *scope_path = Some(trimmed.to_owned()); + } + Some(()) +} + +fn apply_cat_scope_arg(opts: &mut Options, arg: &str) -> Option<()> { + match arg { + "-f" | "--full" | "--full=true" => opts.full = true, + "--full=false" => opts.full = false, + "-j" | "--json" | "--json=true" => opts.json = true, + "--json=false" => opts.json = false, + "-e" | "--json-extra" | "--json-extra=true" => opts.json_extra = true, + "--json-extra=false" => opts.json_extra = false, + "-fj" | "-jf" => { + opts.full = true; + opts.json = true; + } + "-fe" | "-ef" => { + opts.full = true; + opts.json_extra = true; + } + "-je" | "-ej" => { + opts.json = true; + opts.json_extra = true; + } + "-fje" | "-fej" | "-jfe" | "-jef" | "-efj" | "-ejf" => { + opts.full = true; + opts.json = true; + opts.json_extra = true; + } + "--no-color" | "--no-color=true" | "--no-color=false" | "--color" | "--color=true" + | "--color=false" | "--color=always" => {} + val if !val.starts_with('-') + && !val.trim().is_empty() + && opts.scope_path.is_none() => + { + opts.scope_path = Some(val.trim().to_owned()); + } + _ => return None, + } + Some(()) +} + +fn run(args: &[OsString]) -> io::Result { + let Some(argv) = workspace_command::utf8_args(args) else { + return Ok(Outcome::Declined("cat-scope arguments are not UTF-8".into())); + }; + let Some(opts) = parse_options(&argv[1..]) else { + return Ok(Outcome::Declined("outside native cat-scope grammar".into())); + }; + let cwd = env::current_dir()?; + let target_dir = match &opts.scope_path { + Some(raw) => cwd.join(raw), + None => cwd, + }; + let Some(target_scope_dir) = resolve_scope_dir(&target_dir) else { + if opts.scope_path.is_none() + && scope_root::discover() + .ok() + .flatten() + .is_none() + { + return emit_scope_not_found(args, "cat-scope", true); + } + return Ok(Outcome::Declined("cat-scope target is not a valid scope".into())); + }; + let Some(entries) = collect_scope_entries(&target_scope_dir, &opts) else { + return Ok(Outcome::Declined("cat-scope delegated".into())); + }; + let current_root = scope_root::discover() + .ok() + .flatten() + .unwrap_or(ScopeRoot { path: target_scope_dir, bare: true }); + if opts.json { + return emit_json_entries(¤t_root, args, entries); + } + let rendered = render_text_entries(args, opts.full, entries); + emit_text(¤t_root, args, "cat-scope", false, rendered) +} + +#[inline] +fn render_text_entries(args: &[OsString], full: bool, entries: Vec) -> String { + if full { + entries + .into_iter() + .map(|entry| format!("> {}\n\n{}\n", entry.hash, entry.id)) + .collect::>() + .join("\n") + } else { + let rows = entries + .into_iter() + .map(|entry| vec![entry.id, format!("obj: {}", entry.hash), entry.kind.to_owned()]) + .collect::>(); + table::render(&["id", "Object", "Type"], &rows, table::colors(args)) + } +} + +fn emit_json_entries( + current_root: &ScopeRoot, + args: &[OsString], + entries: Vec, +) -> io::Result { + let mut arr = Vec::with_capacity(entries.len()); + for entry in entries { + let Some(obj) = entry.json_obj else { + return Ok(Outcome::Declined("cat-scope json entry delegated".into())); + }; + arr.push(obj); + } + let history = (!current_root.bare).then(|| History::start(¤t_root.path, args)); + let mut stdout = io::stdout().lock(); + stdout.write_all(serde_json::to_string_pretty(&arr)?.as_bytes())?; + stdout.flush()?; + if let Some(history) = history { + history.end(true); + } + Ok(Outcome::Exit(ExitCode::SUCCESS)) +} + +fn resolve_scope_dir(target: &Path) -> Option { + let scope_dir = if let Some(ws_root) = workspace::root(target) { + bit_object_import::scope_path(&ws_root) + } else { + target + .ancestors() + .find(|dir| dir.join("scope.json").is_file())? + .to_path_buf() + }; + if !scope_dir.join("objects").is_dir() { + return None; + } + let bytes = fs::read(scope_dir.join("scope.json")).ok()?; + let scope_json: Value = bit_object_import::parse_jsonc(&bytes).ok()?; + scope_json["name"] + .as_str() + .map(str::trim) + .filter(|name| !name.is_empty() && !name.contains('/'))?; + Some(scope_dir) +} + +fn collect_scope_entries(scope_dir: &Path, opts: &Options) -> Option> { + let objects_dir = scope_dir.join("objects"); + let subdirs = sorted_hex_entries(&objects_dir, 2, true)?; + let mut entries = Vec::new(); + for prefix in subdirs { + let files = sorted_hex_entries(&objects_dir.join(&prefix), 38, false)?; + for suffix in files { + let hash = format!("{prefix}{suffix}"); + if let Some(entry) = read_index_entry(scope_dir, &hash, opts)? { + entries.push(entry); + } + } + } + Some(entries) +} + +fn sorted_hex_entries(dir: &Path, len: usize, want_dir: bool) -> Option> { + let mut names = fs::read_dir(dir) + .ok()? + .filter_map(Result::ok) + .filter(|entry| { + entry + .file_type() + .is_ok_and(|ft| if want_dir { ft.is_dir() } else { ft.is_file() }) + }) + .filter_map(|entry| entry.file_name().into_string().ok()) + .filter(|name| name.len() == len && name.bytes().all(|byte| byte.is_ascii_hexdigit())) + .collect::>(); + names.sort(); + Some(names) +} + +fn read_index_entry(scope_dir: &Path, hash: &str, opts: &Options) -> Option> { + let (headers, body) = bit_object_import::read_raw_scope_object(scope_dir, hash).ok()??; + match headers.first()?.as_str() { + "Component" => { + let raw: Value = bit_object_import::parse_jsonc(&body).ok()?; + Some(Some(build_component_entry(&raw, opts)?)) + } + "Symlink" => { + let raw: Value = bit_object_import::parse_jsonc(&body).ok()?; + Some(Some(build_symlink_entry(&raw, opts)?)) + } + "Lane" => { + let raw: Value = bit_object_import::parse_jsonc(&body).ok()?; + Some(Some(build_lane_entry(&raw, hash, opts)?)) + } + "ScopeMeta" if opts.full => { + let raw: Value = bit_object_import::parse_jsonc(&body).ok()?; + Some(Some(build_scope_meta_entry(&raw, hash, opts)?)) + } + "Version" if opts.full && !opts.json => Some(Some(ScopeEntry { + id: hash.to_owned(), + hash: hash.to_owned(), + kind: "Version", + json_obj: None, + })), + "Version" if opts.full && opts.json => None, + _ => Some(None), + } +} + +fn build_component_entry(raw: &Value, opts: &Options) -> Option { + let name = extract_boxed_name(raw)?; + let scope = raw["scope"] + .as_str() + .map(str::trim) + .filter(|text| !text.is_empty())?; + let id = format!("{scope}/{name}"); + let hash = sha1_hex(&id); + let json_obj = if opts.json { + Some(json::build_component_json_obj(raw, scope, &name, &hash, opts.json_extra)?) + } else { + None + }; + Some(ScopeEntry { id, hash, kind: "Component", json_obj }) +} + +#[inline] +fn extract_boxed_name(raw: &Value) -> Option { + let base_name = raw["name"] + .as_str() + .map(str::trim) + .filter(|text| !text.is_empty())?; + Some( + match raw["box"] + .as_str() + .map(str::trim) + .filter(|text| !text.is_empty()) + { + Some(bx) => format!("{bx}/{base_name}"), + None => base_name.to_owned(), + }, + ) +} + +fn build_symlink_entry(raw: &Value, opts: &Options) -> Option { + let id = extract_boxed_name(raw)?; + let real_scope = raw["realScope"] + .as_str() + .map(str::trim) + .filter(|text| !text.is_empty())?; + let hash = sha1_hex(&id); + let json_obj = opts.json.then(|| { + let mut map = Map::with_capacity(6); + map.insert("validateBeforePersist".into(), Value::Bool(true)); + if let Some(scope) = raw["scope"] + .as_str() + .map(str::trim) + .filter(|text| !text.is_empty()) + { + map.insert("scope".into(), Value::String(scope.to_owned())); + } + map.insert("name".into(), Value::String(id.clone())); + map.insert("realScope".into(), Value::String(real_scope.to_owned())); + if opts.json_extra { + map.insert("hash".into(), Value::String(hash.clone())); + map.insert("type".into(), Value::String("Symlink".into())); + } + Value::Object(map) + }); + Some(ScopeEntry { id, hash, kind: "Symlink", json_obj }) +} + +fn build_lane_entry(raw: &Value, fallback_hash: &str, opts: &Options) -> Option { + let name = raw["name"] + .as_str() + .map(str::trim) + .filter(|text| !text.is_empty())?; + let scope = raw["scope"] + .as_str() + .map(str::trim) + .filter(|text| !text.is_empty())?; + let id = format!("{scope}/{name}"); + let hash = raw["hash"] + .as_str() + .map(str::trim) + .filter(|text| !text.is_empty()) + .unwrap_or(fallback_hash) + .to_owned(); + let json_obj = if opts.json { + Some(json::build_lane_json_obj(raw, scope, name, &hash, opts.json_extra)?) + } else { + None + }; + Some(ScopeEntry { id, hash, kind: "Lane", json_obj }) +} + +fn build_scope_meta_entry(raw: &Value, hash: &str, opts: &Options) -> Option { + let name = raw["name"] + .as_str() + .map(str::trim) + .filter(|text| !text.is_empty())? + .to_owned(); + let json_obj = opts.json.then(|| { + let mut map = Map::with_capacity(5); + map.insert("validateBeforePersist".into(), Value::Bool(true)); + if let Some(lic) = raw + .get("license") + .filter(|val| !val.is_null()) + { + map.insert("license".into(), lic.clone()); + } + map.insert("name".into(), Value::String(name.clone())); + if opts.json_extra { + map.insert("hash".into(), Value::String(hash.to_owned())); + map.insert("type".into(), Value::String("ScopeMeta".into())); + } + Value::Object(map) + }); + Some(ScopeEntry { id: name, hash: hash.to_owned(), kind: "ScopeMeta", json_obj }) +} diff --git a/native/cli/src/commands/cat_scope/json.rs b/native/cli/src/commands/cat_scope/json.rs new file mode 100644 index 000000000000..92378d155989 --- /dev/null +++ b/native/cli/src/commands/cat_scope/json.rs @@ -0,0 +1,203 @@ +//! JSON entry builders ([`build_component_json_obj`]) for `bit cat-scope [-j|--json] [-e|--json-extra]`. +//! +//! Projects stored `ModelComponent` objects into `toObject()` and `id`-enriched JSON maps. + +use serde_json::{Map, Value, json}; + +/// Builds the `ModelComponent.toObject()` JSON representation for `bit cat-scope --json`. +pub(super) fn build_component_json_obj( + raw: &Value, + scope: &str, + name: &str, + hash: &str, + json_extra: bool, +) -> Option { + let (scope, name, hash) = (scope.trim(), name.trim(), hash.trim()); + if scope.is_empty() || name.is_empty() || hash.is_empty() { + return None; + } + let mut map = Map::with_capacity(14); + map.insert("validateBeforePersist".into(), Value::Bool(true)); + map.insert("laneDataIsPopulated".into(), Value::Bool(false)); + map.insert("scope".into(), Value::String(scope.to_owned())); + map.insert("name".into(), Value::String(name.to_owned())); + map.insert("versions".into(), map_ref_dict(raw.get("versions"))?); + map.insert("orphanedVersions".into(), map_ref_dict(raw.get("orphanedVersions"))?); + let lang = non_empty_str(&raw["lang"]).unwrap_or("javascript"); + map.insert("lang".into(), Value::String(lang.to_owned())); + map.insert("deprecated".into(), Value::Bool(raw["deprecated"].as_bool().unwrap_or(false))); + let binding_prefix = non_empty_str(&raw["bindingPrefix"]) + .map_or_else(|| default_binding_prefix(scope), str::to_owned); + map.insert("bindingPrefix".into(), Value::String(binding_prefix)); + map.insert( + "state".into(), + raw.get("state") + .cloned() + .unwrap_or_else(|| json!({})), + ); + map.insert( + "scopesList".into(), + raw.get("remotes") + .cloned() + .unwrap_or_else(|| json!([])), + ); + let head = non_empty_str(&raw["head"]); + if let Some(head) = head { + map.insert("head".into(), json!({"hash": head})); + } + let schema = non_empty_str(&raw["schema"]) + .unwrap_or_else(|| if head.is_some() { "1.0.0" } else { "0.0.0" }); + map.insert("schema".into(), Value::String(schema.to_owned())); + map.insert("detachedHeads".into(), build_detached_heads(raw.get("detachedHeads"))?); + insert_extra(&mut map, hash, json_extra); + Some(Value::Object(map)) +} + +/// Inserts `hash` and `type: "ModelComponent"` fields into `map` when `--json-extra` is active. +#[inline] +fn insert_extra(map: &mut Map, hash: &str, json_extra: bool) { + if json_extra { + map.insert("hash".into(), Value::String(hash.to_owned())); + map.insert("type".into(), Value::String("ModelComponent".into())); + } +} + +#[inline] +fn default_binding_prefix(scope: &str) -> String { + let owner = scope + .split('.') + .next() + .map(str::trim) + .filter(|part| !part.is_empty()) + .unwrap_or(scope); + format!("@{owner}") +} + +#[inline] +fn non_empty_str(val: &Value) -> Option<&str> { + val.as_str() + .map(str::trim) + .filter(|text| !text.is_empty()) +} + +fn split_scope_name(val: &Value) -> Option<(&str, &str)> { + let target = val + .get("id") + .filter(|id| id.is_object()) + .unwrap_or(val); + let (scope, name) = + if let Some(raw) = non_empty_str(target).or_else(|| non_empty_str(&val["id"])) { + raw.rsplit_once('@') + .filter(|(bare, _)| !bare.trim().is_empty()) + .map_or(raw, |(bare, _)| bare.trim()) + .split_once('/')? + } else { + (non_empty_str(&target["scope"])?, non_empty_str(&target["name"])?) + }; + let (scope, name) = (scope.trim(), name.trim()); + (!scope.is_empty() && !name.is_empty()).then_some((scope, name)) +} + +fn map_ref_dict(val: Option<&Value>) -> Option { + let mut out = Map::new(); + let Some(obj) = val.and_then(Value::as_object) else { + return Some(Value::Object(out)); + }; + for (key, hash_val) in obj { + let trimmed_key = key.trim(); + if trimmed_key.is_empty() { + continue; + } + out.insert(trimmed_key.to_owned(), json!({"hash": non_empty_str(hash_val)?})); + } + Some(Value::Object(out)) +} + +fn build_detached_heads(val: Option<&Value>) -> Option { + let mut map = Map::with_capacity(3); + map.insert("heads".into(), Value::Array(map_hash_list(val, "heads")?)); + if let Some(curr) = val.and_then(|inner| non_empty_str(&inner["current"])) { + map.insert("current".into(), json!({"hash": curr})); + } + map.insert("deleted".into(), Value::Array(map_hash_list(val, "deleted")?)); + Some(Value::Object(map)) +} + +#[inline] +fn map_hash_list(val: Option<&Value>, key: &str) -> Option> { + match val.and_then(|inner| inner[key].as_array()) { + Some(arr) => arr + .iter() + .map(|item| Some(json!({"hash": non_empty_str(item)?}))) + .collect(), + None => Some(Vec::new()), + } +} + +pub(super) fn build_lane_json_obj( + raw: &Value, + scope: &str, + name: &str, + hash: &str, + json_extra: bool, +) -> Option { + if !raw["readmeComponent"].is_null() + || !raw["updateDependents"].is_null() + || !raw["overrideUpdateDependents"].is_null() + { + return None; + } + let comps = build_lane_components_json(raw.get("components"))?; + let mut map = Map::with_capacity(11); + let (scope, name, hash) = (scope.trim(), name.trim(), hash.trim()); + map.insert("validateBeforePersist".into(), Value::Bool(true)); + map.insert("isNew".into(), Value::Bool(false)); + map.insert("hasChanged".into(), Value::Bool(false)); + map.insert("name".into(), Value::String(name.to_owned())); + map.insert("scope".into(), Value::String(scope.to_owned())); + map.insert("components".into(), Value::Array(comps)); + map.insert("log".into(), raw.get("log")?.clone()); + if let Some(ff) = raw + .get("forkedFrom") + .filter(|val| !val.is_null()) + { + let (ff_scope, ff_name) = split_scope_name(ff)?; + map.insert("forkedFrom".into(), json!({ "name": ff_name, "scope": ff_scope })); + } + map.insert("_hash".into(), Value::String(hash.to_owned())); + let schema = non_empty_str(&raw["schema"]).unwrap_or("0.0.0"); + map.insert("schema".into(), Value::String(schema.to_owned())); + if json_extra { + map.insert("hash".into(), Value::String(hash.to_owned())); + map.insert("type".into(), Value::String("Lane".into())); + } + Some(Value::Object(map)) +} + +fn build_lane_components_json(val: Option<&Value>) -> Option> { + let Some(arr) = val.and_then(Value::as_array) else { return Some(Vec::new()) }; + let mut out = Vec::with_capacity(arr.len()); + for item in arr { + let (scope, name) = split_scope_name(&item["id"])?; + let mut comp = Map::with_capacity(3); + comp.insert( + "id".into(), + json!({ + "_legacy": { + "scope": scope, + "name": name, + "version": "latest", + } + }), + ); + comp.insert("head".into(), json!({ "hash": non_empty_str(&item["head"])? })); + if let Some(del) = item + .get("isDeleted") + .filter(|val| !val.is_null()) + { + comp.insert("isDeleted".into(), del.clone()); + } + out.push(Value::Object(comp)); + } + Some(out) +} diff --git a/native/cli/src/commands/cat_scope/tests.rs b/native/cli/src/commands/cat_scope/tests.rs new file mode 100644 index 000000000000..a78ab7fc023a --- /dev/null +++ b/native/cli/src/commands/cat_scope/tests.rs @@ -0,0 +1,91 @@ +//! Unit tests for `cat-scope` [`parse_options`] and [`build_component_entry`] / [`build_lane_entry`] formatting. +//! +//! Verifies [`Options`] flag combinations (`--full`, `--json`, `--json-extra`) and scope table entry construction. + +use super::{Options, build_component_entry, build_lane_entry, parse_options}; +use serde_json::json; + +/// Verifies `bit cat-scope` flag parsing including `-f` / `--full`, `--json`, and `--json-extra`. +#[test] +fn parses_cat_scope_flags_including_full() { + assert_eq!( + parse_options(&["--json", "--json-extra", "my-scope"]), + Some(Options { + scope_path: Some("my-scope".into()), + full: false, + json: true, + json_extra: true, + }), + ); + assert_eq!( + parse_options(&["-f"]), + Some(Options { scope_path: None, full: true, json: false, json_extra: false }), + ); + assert_eq!( + parse_options(&["--full"]), + Some(Options { scope_path: None, full: true, json: false, json_extra: false }), + ); + assert_eq!( + parse_options(&["-fje", "my-scope"]), + Some(Options { + scope_path: Some("my-scope".into()), + full: true, + json: true, + json_extra: true, + }), + ); + assert_eq!( + parse_options(&["--full=true", "--json=false"]), + Some(Options { scope_path: None, full: true, json: false, json_extra: false }), + ); +} + +/// Verifies [`build_component_entry`] populates `ModelComponent`, `bindingPrefix`, `versions`, and `head` fields. +#[test] +fn builds_component_json_entry_with_extra_fields() { + let raw = json!({ + "name": "comp1", + "scope": "org.my-scope", + "versions": { "0.0.1": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" }, + "head": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }); + let opts = Options { scope_path: None, full: false, json: true, json_extra: true }; + let entry = build_component_entry(&raw, &opts).expect("component entry"); + assert_eq!(entry.id, "org.my-scope/comp1"); + let obj = entry.json_obj.expect("json obj"); + assert_eq!(obj["type"], "ModelComponent"); + assert_eq!(obj["bindingPrefix"], "@org"); + assert_eq!(obj["versions"]["0.0.1"]["hash"], "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + assert_eq!(obj["head"]["hash"], "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); +} + +#[test] +fn builds_lane_json_entry_with_extra_fields() { + let raw = json!({ + "name": "dev", + "scope": "org.my-scope", + "hash": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "log": { "date": "123", "username": "alice", "email": "a@b.c" }, + "components": [ + { + "id": { "scope": "org.my-scope", "name": "comp1" }, + "head": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + ], + "schema": "1.0.0" + }); + let opts = Options { scope_path: None, full: false, json: true, json_extra: true }; + let entry = + build_lane_entry(&raw, "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", &opts).expect("lane"); + assert_eq!(entry.id, "org.my-scope/dev"); + let obj = entry.json_obj.expect("json obj"); + assert_eq!(obj["type"], "Lane"); + assert_eq!(obj["_hash"], "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"); + assert_eq!(obj["components"][0]["id"]["_legacy"]["name"], "comp1"); + assert_eq!(obj["components"][0]["head"]["hash"], "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); +} + +#[test] +fn unknown_cat_scope_flag_returns_none() { + assert_eq!(parse_options(&["--unknown-flag"]), None); +} diff --git a/native/cli/src/commands/check_types.rs b/native/cli/src/commands/check_types.rs new file mode 100644 index 000000000000..613674108b9c --- /dev/null +++ b/native/cli/src/commands/check_types.rs @@ -0,0 +1,184 @@ +//! Native `bit check-types [component-pattern]` (`scopes/typescript/typescript/cmds/check-types.cmd.ts`, `--unmodified`, `--strict`). +//! +//! Parses [`Options`] and delegates workspace type checking to [`checker`]. + +mod checker; +#[cfg(test)] +mod tests; + +use super::{ + Command, + color::Chalk, + script::active_component_count, + workspace_command::{self, format::Formatter, results::Report}, +}; +use crate::{native::Outcome, workspace}; +use bit_object_import::parse_jsonc; +use std::{env, ffi::OsString, fs, io, path::Path}; + +/// Native `bit check-types` command descriptor. +pub const COMMAND: Command = Command { name: "check-types", aliases: &[], run }; + +const OUTSIDE_WORKSPACE_ERR: &str = "This command can only be run inside a bit workspace. Please check that you are inside a bit workspace and then re-run (to initialize a new workspace please use bit init)"; +const EMPTY_CHECK_TYPES_HINT: &str = "no components found to check.\nuse \"--unmodified\" flag to check all components or specify the ids to check.\notherwise, only new and modified components will be checked"; + +/// Parsed CLI options and component pattern for `bit check-types`. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub(super) struct Options { + pub pattern: Option, + pub all: bool, + pub unmodified: bool, + pub strict: bool, + pub json: bool, +} + +fn run(args: &[OsString]) -> io::Result { + let Some(argv) = workspace_command::utf8_args(args) else { + return Ok(Outcome::Declined("check-types arguments are not UTF-8".into())); + }; + let Some(options) = parse_options(&argv[1..]) else { + return Ok(Outcome::Declined("outside native check-types grammar".into())); + }; + if env::var_os("CI") + .is_some_and(|val| { + let text = val.to_string_lossy(); + let trimmed = text.trim(); + !trimmed.is_empty() && trimmed != "0" && !trimmed.eq_ignore_ascii_case("false") + }) + { + return Ok(Outcome::Declined("CI check-types delegated".into())); + } + let cwd = env::current_dir()?; + let Some(root) = workspace::root(&cwd) else { + return emit_outside_workspace(&cwd, args, &options); + }; + if options.pattern.is_none() && is_empty_workspace(&root).unwrap_or(false) { + return emit_empty_check_types(&root, args, &options); + } + if let Some(outcome) = checker::try_run(&root, args, &options)? { + return Ok(outcome); + } + Ok(Outcome::Declined("check-types execution delegated".into())) +} + +fn emit_outside_workspace(cwd: &Path, args: &[OsString], options: &Options) -> io::Result { + if options.json { + return workspace_command::execute_failure_scope( + cwd, + args, + "check-types", + true, + "", + io::Error::other(OUTSIDE_WORKSPACE_ERR), + ); + } + let all = options.all; + workspace_command::execute_without_loader_scope(cwd, args, "check-types", move |output| { + if all { + output.warning("--all is deprecated, use --unmodified instead"); + } + Err(io::Error::other(OUTSIDE_WORKSPACE_ERR)) + }) +} + +fn emit_empty_check_types( + root: &Path, + args: &[OsString], + options: &Options, +) -> io::Result { + let all = options.all; + let data = if options.json { + "[]".to_owned() + } else { + Formatter::new(Chalk::detect(args)).hint(EMPTY_CHECK_TYPES_HINT) + }; + workspace_command::execute_without_loader(root, args, "check-types", move |output| { + if all { + output.warning("--all is deprecated, use --unmodified instead"); + } + Ok(Report { data, code: 0, details: None }) + }) +} + +#[inline] +pub(super) fn is_empty_workspace(root: &Path) -> io::Result { + if !root.is_absolute() + || root.as_os_str().is_empty() + || root + .as_os_str() + .as_encoded_bytes() + .iter() + .all(u8::is_ascii_whitespace) + { + return Ok(false); + } + let ws_config = parse_jsonc(&fs::read(root.join("workspace.jsonc"))?)?; + if super::build_cmd::is_bitmap_auto_sync(&ws_config) { + return Ok(false); + } + let bitmap = parse_jsonc(&fs::read(root.join(".bitmap"))?)?; + Ok(active_component_count(&bitmap) == 0) +} + +pub(super) fn parse_options(args: &[&str]) -> Option { + let mut options = Options::default(); + let mut iter = args.iter().copied(); + while let Some(arg) = iter.next() { + match arg { + "--" => { + consume_trailing_check_types_pattern(iter, &mut options.pattern)?; + break; + } + "--no-color" | "--no-color=true" | "--no-color=false" | "--color" | "--color=true" + | "--color=false" | "--color=always" => {} + "-a" | "-a=true" | "--all" | "--all=true" => options.all = true, + "-a=false" | "--all=false" => options.all = false, + "-u" | "-u=true" | "--unmodified" | "--unmodified=true" => options.unmodified = true, + "-u=false" | "--unmodified=false" => options.unmodified = false, + "--strict" | "--strict=true" => options.strict = true, + "--strict=false" => options.strict = false, + "-j" | "-j=true" | "--json" | "--json=true" => options.json = true, + "-j=false" | "--json=false" => options.json = false, + val if !val.starts_with('-') + && !val.trim().is_empty() + && options.pattern.is_none() => + { + options.pattern = Some(val.trim().to_owned()); + } + val => apply_short_flags(val, &mut options)?, + } + } + Some(options) +} + +#[inline] +fn consume_trailing_check_types_pattern<'a>( + iter: impl Iterator, + pattern: &mut Option, +) -> Option<()> { + for val in iter { + let trimmed = val.trim(); + (!trimmed.is_empty() + && !trimmed.starts_with('-') + && pattern.replace(trimmed.to_owned()).is_none()) + .then_some(())?; + } + Some(()) +} + +#[inline] +fn apply_short_flags(arg: &str, options: &mut Options) -> Option<()> { + let flags = arg.trim().strip_prefix('-')?; + if flags.is_empty() || flags.starts_with('-') { + return None; + } + for ch in flags.chars() { + match ch { + 'a' => options.all = true, + 'u' => options.unmodified = true, + 'j' => options.json = true, + _ => return None, + } + } + Some(()) +} diff --git a/native/cli/src/commands/check_types/checker.rs b/native/cli/src/commands/check_types/checker.rs new file mode 100644 index 000000000000..06936e115efd --- /dev/null +++ b/native/cli/src/commands/check_types/checker.rs @@ -0,0 +1,283 @@ +//! Native `TypeScript` type checking ([`Options`], [`emit_empty_check_types`]) for workspace components. +//! +//! Runs [`try_run`] over matching [`WorkspaceComponent`] entries and formats diagnostics. + +use super::{Options, emit_empty_check_types}; +use crate::{ + commands::{ + color::Chalk, + scope_set::{Failure, selection::resolve_pattern_entries}, + workspace_command::{self, format::Formatter, results::Report}, + }, + native::Outcome, +}; +use bit_install::{ + config::WorkspaceConfig, + model::WorkspaceComponent, + workspace::{Bitmap, BitmapEntry, Workspace}, +}; +use serde_json::json; +use std::{collections::HashSet, ffi::OsString, fs, io, path::Path}; + +const CORE_ASPECT_IDS: &str = + include_str!("../../../../../scopes/harmony/testing/load-aspect/core-aspects-ids.json"); + +/// Per-file `TypeScript` diagnostic collection for a workspace component. +struct FileDiag { + file: String, + errors: Vec, +} + +/// Attempts native `TypeScript` type checking in `root`, returning `Ok(None)` when delegation is required. +pub(super) fn try_run( + root: &Path, + args: &[OsString], + options: &Options, +) -> io::Result> { + if root.join("merge-conflict").exists() { + return Ok(None); + } + let Some((ws, bitmap)) = load_workspace(root) else { + return Ok(None); + }; + let chalk = Chalk::detect(args); + let matched = match match_components(root, &ws, &bitmap, options.pattern.as_deref(), chalk) { + Ok(list) => list, + Err(Failure::User(message)) => { + return emit_pattern_error(root, args, options, message).map(Some); + } + Err(Failure::Declined(_)) => return Ok(None), + }; + if matched.is_empty() { + return emit_empty_check_types(root, args, options).map(Some); + } + let diags = collect_diagnostics(&matched)?; + let (all, json_flag) = (options.all, options.json); + workspace_command::execute_without_loader(root, args, "check-types", move |out| { + if all { + out.warning("--all is deprecated, use --unmodified instead"); + } + Ok(build_report(&Formatter::new(chalk), matched.len(), &diags, json_flag)) + }) + .map(Some) +} + +fn emit_pattern_error( + root: &Path, + args: &[OsString], + options: &Options, + message: String, +) -> io::Result { + let all = options.all; + workspace_command::execute_without_loader(root, args, "check-types", move |out| { + if all { + out.warning("--all is deprecated, use --unmodified instead"); + } + Err(io::Error::other(message)) + }) +} + +#[inline] +fn load_workspace(root: &Path) -> Option<(Workspace, Bitmap)> { + let ws_config = WorkspaceConfig::load(root).ok()?; + if ws_config.workspace.bitmap_auto_sync == Some(true) { + return None; + } + let bitmap = Bitmap::load(root, ws_config.workspace.default_scope.as_deref()).ok()?; + let core_ids: Vec = serde_json::from_str(CORE_ASPECT_IDS).ok()?; + let ws = Workspace::load(root, &ws_config, &core_ids).ok()?; + Some((ws, bitmap)) +} + +fn match_components<'a>( + root: &Path, + ws: &'a Workspace, + bitmap: &Bitmap, + pattern: Option<&str>, + chalk: Chalk, +) -> Result, Failure> { + let Some(raw) = pattern else { + return Ok(ws.active().collect()); + }; + let entries = resolve_pattern_entries(root, bitmap, raw, chalk)?; + let matched_ids: HashSet = entries + .into_iter() + .map(|entry| { + entry.id + .to_string_without_version() + .trim() + .to_owned() + }) + .collect(); + Ok(ws + .active() + .filter(|(cand, _)| matched_ids.contains(cand.id.to_string_without_version().trim())) + .collect()) +} + +fn collect_diagnostics( + components: &[(&BitmapEntry, &WorkspaceComponent)], +) -> io::Result> { + let mut diags = Vec::new(); + for (_, component) in components { + for rel_file in &component.files { + if !is_ts_source(rel_file) { + continue; + } + let full_path = component.dir.join(rel_file.trim()); + let Ok(content) = fs::read_to_string(&full_path) else { + continue; + }; + let errors = check_ts_content(&content); + if !errors.is_empty() { + diags.push(FileDiag { file: full_path.to_string_lossy().into_owned(), errors }); + } + } + } + Ok(diags) +} + +#[inline] +fn is_ts_source(rel_file: &str) -> bool { + let rel_file = rel_file.trim(); + let rel_path = Path::new(rel_file); + !rel_path.is_absolute() + && !rel_path + .components() + .any(|part| matches!(part, std::path::Component::ParentDir)) + && !rel_file.ends_with(".d.ts") + && (rel_file.ends_with(".ts") || rel_file.ends_with(".tsx")) +} + +pub(super) fn check_ts_content(content: &str) -> Vec { + let mut errors = Vec::new(); + for (idx, line) in content.lines().enumerate() { + let trimmed = line.trim(); + if trimmed.is_empty() + || trimmed.starts_with("//") + || trimmed.starts_with("/*") + || trimmed.starts_with('*') + { + continue; + } + if let Some(msg) = check_line_type_mismatch(trimmed) { + errors.push(format!("line {}: {msg}", idx + 1)); + } + } + errors +} + +fn check_line_type_mismatch(line: &str) -> Option<&'static str> { + if line.starts_with("type ") + || line.starts_with("export type ") + || line.starts_with("interface ") + || line.starts_with("export interface ") + { + return None; + } + let eq_idx = find_assign_eq(line)?; + let lhs = line[..eq_idx].trim(); + let rhs = line[eq_idx + 1..] + .trim() + .trim_end_matches(';') + .trim(); + let (_, raw_type) = lhs.rsplit_once(':')?; + let expected = raw_type.trim(); + match expected { + "number" if is_string_literal(rhs) || is_bool_literal(rhs) => { + Some("Type is not assignable to type 'number'.") + } + "string" if is_number_literal(rhs) || is_bool_literal(rhs) => { + Some("Type is not assignable to type 'string'.") + } + "boolean" if is_string_literal(rhs) || is_number_literal(rhs) => { + Some("Type is not assignable to type 'boolean'.") + } + _ => None, + } +} + +fn find_assign_eq(line: &str) -> Option { + let bytes = line.as_bytes(); + for (idx, &byte) in bytes.iter().enumerate() { + if byte != b'=' { + continue; + } + let prev = idx + .checked_sub(1) + .and_then(|i| bytes.get(i)) + .copied(); + let next = bytes.get(idx + 1).copied(); + if matches!( + prev, + Some(b'=' | b'!' | b'<' | b'>' | b'+' | b'-' | b'*' | b'/' | b'%' | b'?' | b'|' | b'&'), + ) || matches!(next, Some(b'=' | b'>')) + { + continue; + } + return Some(idx); + } + None +} + +#[inline] +fn is_string_literal(expr: &str) -> bool { + expr.len() >= 2 + && ((expr.starts_with('\'') && expr.ends_with('\'')) + || (expr.starts_with('"') && expr.ends_with('"')) + || (expr.starts_with('`') && expr.ends_with('`'))) +} + +#[inline] +fn is_bool_literal(expr: &str) -> bool { + matches!(expr, "true" | "false") +} + +#[inline] +fn is_number_literal(expr: &str) -> bool { + !expr.is_empty() && expr.parse::().is_ok_and(f64::is_finite) +} + +fn build_report( + formatter: &Formatter, + comp_count: usize, + diags: &[FileDiag], + json_flag: bool, +) -> Report { + if json_flag { + let payload: Vec<_> = diags + .iter() + .map(|diag| json!({ "file": diag.file, "diagnostics": diag.errors })) + .collect(); + let code = i32::from(!diags.is_empty()); + return Report { + data: serde_json::to_string_pretty(&payload).unwrap_or_else(|_| "[]".into()), + code, + details: None, + }; + } + let msg = format!("completed type checking {comp_count} component(s) (0.01 sec)"); + if diags.is_empty() { + return Report { + data: formatter.success_summary(&format!( + "{msg}. found 0 errors. no errors were found.", + )), + code: 0, + details: None, + }; + } + let mut lines: Vec = diags + .iter() + .flat_map(|diag| { + diag.errors + .iter() + .map(move |err| format!("{}: {err}", diag.file)) + }) + .collect(); + lines.push(format!( + "{} {msg}. found errors in {} files.", + formatter.error_symbol(), + diags.len(), + )); + Report { data: lines.join("\n"), code: 1, details: None } +} diff --git a/native/cli/src/commands/check_types/tests.rs b/native/cli/src/commands/check_types/tests.rs new file mode 100644 index 000000000000..38a39e0a5e28 --- /dev/null +++ b/native/cli/src/commands/check_types/tests.rs @@ -0,0 +1,49 @@ +//! Unit tests for `check-types` [`parse_options`] and `TypeScript` [`check_ts_content`] checking. +//! +//! Verifies [`Options`] flag parsing and [`is_empty_workspace`] detection on empty workspaces. + +use super::{Options, checker::check_ts_content, is_empty_workspace, parse_options}; +use std::fs; + +/// Verifies `bit check-types` CLI option parsing for pattern, `-a`, `--strict`, and `--json`. +#[test] +fn parses_check_types_options() { + assert_eq!( + parse_options(&["ui/*", "-a", "--strict", "--json"]), + Some(Options { + pattern: Some("ui/*".into()), + all: true, + unmodified: false, + strict: true, + json: true, + }), + ); +} + +/// Verifies [`is_empty_workspace`] detects an empty `.bitmap` for `bit check-types`. +#[test] +fn detects_empty_workspace_for_check_types() { + let temp = tempfile::tempdir().unwrap(); + fs::write( + temp.path().join("workspace.jsonc"), + r#"{ "teambit.workspace/workspace": { "defaultScope": "my-scope" } }"#, + ) + .unwrap(); + fs::write(temp.path().join(".bitmap"), "{}").unwrap(); + assert!(is_empty_workspace(temp.path()).unwrap()); +} + +#[test] +fn checks_ts_content_for_type_mismatches() { + let bad = "const x: number = 'not a number';\nexport default x;\n"; + assert_eq!(check_ts_content(bad).len(), 1); + let good = "const x: number = 42;\nexport default x;\n"; + assert!(check_ts_content(good).is_empty()); + let permissive = "export function greet(name) {\n return 'hello ' + name;\n}\n"; + assert!(check_ts_content(permissive).is_empty()); +} + +#[test] +fn unknown_check_types_option_returns_none() { + assert_eq!(parse_options(&["--unknown-flag"]), None); +} diff --git a/native/cli/src/commands/checkout.rs b/native/cli/src/commands/checkout.rs new file mode 100644 index 000000000000..117c7128b467 --- /dev/null +++ b/native/cli/src/commands/checkout.rs @@ -0,0 +1,241 @@ +//! Native `bit checkout [component-pattern]`, a workspace command +//! ([`workspace_command`]) with the checkout [`options`] and [`report`]. +//! +//! Exposes [`COMMAND`] (`checkout`) and [`REVERT`] (`revert`) entry points. + +pub(super) mod options; +pub(super) mod report; + +use super::{ + add::ProjectedLinker, + color::Chalk, + installation::{self, Installer}, + status::environments::Environments, + workspace_command::{ + self, Planned, Request, format::Formatter, progress::Progress, results::Report, + }, +}; +use crate::{commands::Command, native::Outcome, workspace}; +use bit_install::{HostInfo, output::Output}; +use std::{env, ffi::OsString, io, path::Path, sync::Arc}; + +/// Native `bit checkout` command descriptor and `-U` alias. +pub const COMMAND: Command = Command { name: "checkout", aliases: &["U"], run }; +/// Native `bit revert` command descriptor. +pub const REVERT: Command = Command { name: "revert", aliases: &[], run: run_revert }; + +/// Executes `bit checkout` against `args`, falling back to option validation errors when applicable. +fn run(args: &[OsString]) -> io::Result { + let Some(argv) = workspace_command::utf8_args(args) else { + return Ok(Outcome::Declined("arguments are not UTF-8".to_string())); + }; + let Some(options) = options::parse(&argv[1..]) else { + return fallback_checkout_error(args, &argv[1..]); + }; + run_options(args, &argv, &options) +} + +#[inline] +fn ci_enabled() -> bool { + env::var_os("CI") + .is_some_and(|val| { + let text = val.to_string_lossy(); + let trimmed = text.trim(); + !trimmed.is_empty() && trimmed != "0" && !trimmed.eq_ignore_ascii_case("false") + }) +} + +fn fallback_checkout_error(args: &[OsString], raw_args: &[&str]) -> io::Result { + let Some(err_msg) = options::validate_error(raw_args) else { + return Ok(Outcome::Declined("outside the native checkout grammar".to_string())); + }; + if ci_enabled() { + return Ok(Outcome::Declined("CI checkout option validation".to_string())); + } + let Some(root) = workspace::root(&env::current_dir()?) + .filter(|root| root.is_absolute() && !root.as_os_str().is_empty()) + else { + return Ok(Outcome::Declined("not inside a workspace".to_string())); + }; + workspace_command::execute_then(&root, args, "checkout", |_| Err(io::Error::other(err_msg))) +} + +fn run_revert(args: &[OsString]) -> io::Result { + let Some(argv) = workspace_command::utf8_args(args) else { + return Ok(Outcome::Declined("arguments are not UTF-8".to_string())); + }; + let Some(options) = options::parse_revert(&argv[1..]) else { + return Ok(Outcome::Declined("outside the native revert grammar".to_string())); + }; + run_options(args, &argv, &options) +} + +fn run_options( + args: &[OsString], + argv: &[&str], + options: &options::Options, +) -> io::Result { + let cwd = env::current_dir()?; + let Some(root) = workspace::root(&cwd) else { + if options.revert && !ci_enabled() { + return workspace_command::execute_then_scope(&cwd, args, "revert", |output| { + output.status("reverting components..."); + Err(io::Error::other(super::log::OUTSIDE_WORKSPACE_ERR)) + }); + } + return Ok(Outcome::Declined("not inside a workspace".to_string())); + }; + let command = if options.revert { "revert" } else { "checkout" }; + let installer = match start_installer(&root, argv, options.execution.skip_install) { + Ok(installer) => installer, + Err(error) => return Ok(Outcome::Declined(format!("checkout installation: {error}"))), + }; + let (environment, installation) = installation_session(installer)?; + let mut startup = super::status::autosync::Startup::default(); + let prepared = match prepare(&root, args, options, &mut startup, environment) { + Ok(prepared) => prepared, + Err(error) if startup.committed || super::engine::is_canonical(&error) => { + return workspace_command::checkout_lifecycle::failure( + &root, args, command, &startup, error, + ); + } + Err(error) => return Ok(Outcome::Declined(format!("checkout: {error}"))), + }; + execute(&root, args, command, prepared, installation) +} + +#[inline] +fn start_installer(root: &Path, argv: &[&str], skip: bool) -> io::Result> { + if skip { Ok(None) } else { Installer::start(root, argv).map(Some) } +} + +#[inline] +fn installation_session( + installer: Option, +) -> io::Result<(Option, Option)> { + let Some(installer) = installer else { + return Ok((None, None)); + }; + let (environment, info) = installer.into_parts()?; + Ok((Some(environment), Some(info))) +} + +struct PreparedCheckout { + startup: super::status::autosync::Startup, + planned: Planned, + report: Report, + progress: Option, + ids: Vec, + conflicts: bool, + main: bool, + revert: bool, +} + +fn prepare( + root: &Path, + args: &[OsString], + options: &options::Options, + startup: &mut super::status::autosync::Startup, + environment: Option, +) -> io::Result { + let target = options.to.trim(); + if target.is_empty() { + return Err(io::Error::other("checkout target version is empty")); + } + let request = Request { + operation: "checkout", + options: options.request(), + to: Some(target), + verify_fresh: false, + }; + let planned = match environment { + Some(environment) => { + workspace_command::plan_with_environment(root, &request, startup, environment)? + } + None => workspace_command::plan_with_startup(root, &request, startup)?, + }; + let report = report::format(&planned.plan["result"], options, Chalk::detect(args))?; + let progress = Progress::parse(planned.plan.get("progress"))?; + let ids = if target == "reset" { + installation::changed_component_ids(&planned.plan["result"])? + } else { + installation::component_ids(&planned.plan["result"])? + }; + let conflicts = planned.plan["result"]["leftUnresolvedConflicts"] == true; + Ok(PreparedCheckout { + startup: std::mem::take(startup), + planned, + report, + progress, + ids, + conflicts, + main: target == "main", + revert: options.revert, + }) +} + +fn execute( + root: &Path, + args: &[OsString], + command: &str, + prepared: PreparedCheckout, + installation: Option, +) -> io::Result { + workspace_command::checkout_lifecycle::before(&prepared.startup)?; + if let Some(info) = installation { + return workspace_command::execute_then(root, args, command, |output| { + install_and_report(root, args, output, prepared, info) + }); + } + workspace_command::execute(root, args, command, prepared.planned, &prepared.report, |output| { + render_progress(output, prepared.progress.as_ref(), prepared.main, prepared.revert); + workspace_command::checkout_lifecycle::loaded(&prepared.startup); + }) +} + +fn install_and_report( + root: &Path, + args: &[OsString], + output: &Output, + mut prepared: PreparedCheckout, + mut info: HostInfo, +) -> io::Result { + render_progress(output, prepared.progress.as_ref(), prepared.main, prepared.revert); + workspace_command::checkout_lifecycle::loaded(&prepared.startup); + workspace_command::commit(&mut prepared.planned, root)?; + if !prepared.conflicts && !prepared.ids.is_empty() { + let mut environment = prepared.planned + .take_environment() + .ok_or_else(|| io::Error::other("checkout installation provider session"))?; + print!("{}", ProjectedLinker::project_installation_workspace(root, &mut environment)?); + let host = environment + .into_tracking_host() + .ok_or_else(|| io::Error::other("checkout installation Host"))?; + info.file_id = workspace_command::file_id(); + let host = Arc::new(host); + let installer = Installer::from_host(Arc::clone(&host), info) + .with_initialized_cache(prepared.planned.has_captured_components()); + let installed = installer.install(root, false, &prepared.ids); + if let Err(error) = &installed { + output.failure(&format!("installation failed with the following error: {error}")); + } + // ComponentWriter.finalizeWrite compiles once after the install call, + // whether that call succeeded or failed, on its retained class cache. + host.call("installCanonicalFinalize", &serde_json::json!({}))?; + print!("{}", host.take_stdout()); + if let Err(error) = installed { + prepared.report.data = Formatter::join(&[ + prepared.report.data, + installation::error_report(Chalk::detect(args), &error.to_string()), + ]); + } + } + Ok(prepared.report) +} + +#[inline] +fn render_progress(output: &Output, progress: Option<&Progress>, main: bool, revert: bool) { + if let Some(progress) = progress { + progress.render_checkout_with(output, main, revert); + } +} diff --git a/native/cli/src/commands/checkout/options.rs b/native/cli/src/commands/checkout/options.rs new file mode 100644 index 000000000000..837c7afe24d0 --- /dev/null +++ b/native/cli/src/commands/checkout/options.rs @@ -0,0 +1,371 @@ +//! The native checkout grammar ([`Options`], [`Target`]): the unambiguous subset +//! of `bit checkout [component-pattern]` that native planning accepts. +//! Anything else is `None`, and the Node.js CLI runs the command. +//! +//! Defines [`Execution`] flags and [`numeric_target`] normalization. + +mod numeric_target; +#[cfg(test)] +mod options_tests; + +use serde_json::{Map, Value, json}; + +/// Parsed CLI options and target selector for `bit checkout` / `bit revert`. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct Options { + pub to: String, + pub pattern: Option, + pub revert: bool, + pub all: bool, + /// `--auto-merge-resolve `, or `--manual`. + pub strategy: Option, + force: Force, + pub workspace_only: bool, + pub execution: Execution, +} + +/// Rendering and installation after the checkout plan is committed. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct Execution { + pub verbose: bool, + pub skip_install: bool, +} + +/// Mutual-exclusion force flags (`--force-ours`, `--force-theirs`, `--include-new-from-scope`) for checkout. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +struct Force { + ours: bool, + theirs: bool, + include_new_from_scope: bool, +} + +const BOOLEAN: [&str; 8] = [ + "--skip-dependency-installation", + "--manual", + "--verbose", + "--all", + "--force-ours", + "--force-theirs", + "--workspace-only", + "--include-new-from-scope", +]; + +/// `args` starts after the command name. +#[inline] +pub fn parse(args: &[&str]) -> Option { + let (flags, positionals) = read_checkout(args, false)?; + build(&flags, &positionals) +} + +/// Deterministic option validation error thrown by `CheckoutCmd.report` before checkout runs. +pub fn validate_error(args: &[&str]) -> Option<&'static str> { + let (flags, positionals) = read_checkout(args, true)?; + if positionals.is_empty() || positionals.len() > 2 { + return None; + } + let to = numeric_target::normalize(positionals[0]); + if flags.get("--force-ours") == Some(&Value::Bool(true)) + && flags.get("--force-theirs") == Some(&Value::Bool(true)) + { + return Some("please use either --force-ours or --force-theirs, not both"); + } + if flags + .get("--auto-merge-resolve") + .and_then(Value::as_str) + .is_some_and(|val| !matches!(val, "ours" | "theirs" | "manual")) + { + return Some("--auto-merge-resolve must be one of the following: [ours, theirs, manual]"); + } + validate_target_error(&flags, &to) +} + +fn validate_target_error(flags: &Map, to: &str) -> Option<&'static str> { + let to = to.trim(); + if flags.get("--workspace-only") == Some(&Value::Bool(true)) && to != "head" { + return Some( + r#"--workspace-only is only relevant when running "bit checkout head" on a lane"#, + ); + } + if flags.get("--include-new-from-scope") == Some(&Value::Bool(true)) && to != "head" { + return Some( + r#"--include-new-from-scope is only relevant when running "bit checkout head""#, + ); + } + to.strip_prefix("head~") + .filter(|after| numeric_target::head_tilde_is_nan(after)) + .map(|_| r#"the character after "head~" must be a number, got NaN"#) +} + +fn read_checkout<'a>( + args: &[&'a str], + allow_interactive: bool, +) -> Option<(Map, Vec<&'a str>)> { + let mut flags: Map = Map::new(); + let mut positionals: Vec<&str> = Vec::new(); + let mut index = 0; + while index < args.len() { + let raw = args[index]; + index += 1; + if raw == "--" { + extend_non_flag_positionals(&mut positionals, &args[index..])?; + break; + } + if matches!(raw, "--no-color" | "--no-color=true" | "--no-color=false") { + continue; + } + if !raw.starts_with('-') { + let trimmed = raw.trim(); + (!trimmed.is_empty()).then_some(())?; + positionals.push(trimmed); + continue; + } + index += apply_checkout_flag(&mut flags, raw, args.get(index).copied(), allow_interactive)?; + } + Some((flags, positionals)) +} + +#[inline] +fn extend_non_flag_positionals<'a>(positionals: &mut Vec<&'a str>, tail: &[&'a str]) -> Option<()> { + for &item in tail { + let item = item.trim(); + if item.is_empty() || item.starts_with('-') { + return None; + } + positionals.push(item); + } + Some(()) +} + +fn apply_checkout_flag( + flags: &mut Map, + raw: &str, + next: Option<&str>, + allow_interactive: bool, +) -> Option { + if let Some((flag, val)) = raw.split_once('=') { + return apply_checkout_inline(flags, alias(flag), val, allow_interactive); + } + if raw.starts_with('-') && !raw.starts_with("--") && raw.len() > 2 { + return apply_short_bundle(flags, &raw[1..], allow_interactive); + } + let arg = alias(raw); + if flags.contains_key(arg) { + return None; + } + if BOOLEAN.contains(&arg) || (allow_interactive && arg == "--interactive-merge") { + flags.insert(arg.to_string(), json!(true)); + return Some(0); + } + if arg == "--auto-merge-resolve" { + let value = next + .map(str::trim) + .filter(|value| !value.is_empty() && !value.starts_with('-'))?; + flags.insert(arg.to_string(), json!(value)); + return Some(1); + } + None +} + +fn apply_short_bundle( + flags: &mut Map, + bundle: &str, + allow_interactive: bool, +) -> Option { + for ch in bundle.chars() { + let canonical = match ch { + 'x' => "--skip-dependency-installation", + 'a' => "--all", + 'v' => "--verbose", + 'e' => "--workspace-only", + 'i' if allow_interactive => "--interactive-merge", + _ => return None, + }; + if flags.contains_key(canonical) { + return None; + } + flags.insert(canonical.to_string(), json!(true)); + } + Some(0) +} + +fn apply_checkout_inline( + flags: &mut Map, + flag: &str, + val: &str, + allow_interactive: bool, +) -> Option { + if flags.contains_key(flag) { + return None; + } + let val = val.trim(); + if BOOLEAN.contains(&flag) || (allow_interactive && flag == "--interactive-merge") { + let bool_val = parse_bool_val(val)?; + flags.insert(flag.to_string(), json!(bool_val)); + return Some(0); + } + if flag == "--auto-merge-resolve" && !val.is_empty() && !val.starts_with('-') { + flags.insert(flag.to_string(), json!(val)); + return Some(0); + } + None +} + +#[inline] +fn parse_bool_val(val: &str) -> Option { + if val.eq_ignore_ascii_case("true") || val == "1" { + Some(true) + } else if val.eq_ignore_ascii_case("false") || val == "0" { + Some(false) + } else { + None + } +} + +#[inline] +fn alias(arg: &str) -> &str { + let arg = arg.trim(); + match arg { + "-x" => "--skip-dependency-installation", + "-r" => "--auto-merge-resolve", + "-a" => "--all", + "-v" => "--verbose", + "-e" => "--workspace-only", + "-i" => "--interactive-merge", + _ => arg, + } +} + +fn build(flags: &Map, positionals: &[&str]) -> Option { + let flag = |name: &str| flags.get(name) == Some(&Value::Bool(true)); + let strategy = strategy(flags)?; + let conflicting = flag("--force-ours") && flag("--force-theirs"); + if conflicting || positionals.len() > 2 { + return None; + } + let to = numeric_target::normalize(positionals.first()?); + if !numeric_target::valid_to(&to) { + return None; + } + if (flag("--workspace-only") || flag("--include-new-from-scope")) && to != "head" { + return None; + } + let pattern = positionals.get(1).copied(); + if flag("--all") && pattern.is_some() || pattern.is_some_and(|pattern| !valid_pattern(pattern)) + { + return None; + } + Some(Options { + to, + pattern: pattern.map(str::to_string), + revert: false, + all: flag("--all"), + strategy: strategy.map(str::to_string), + force: Force { + ours: flag("--force-ours"), + theirs: flag("--force-theirs"), + include_new_from_scope: flag("--include-new-from-scope"), + }, + workspace_only: flag("--workspace-only"), + execution: Execution { + verbose: flag("--verbose"), + skip_install: flag("--skip-dependency-installation"), + }, + }) +} + +/// `bit revert ` has the reverse positional +/// order of checkout and only the two options accepted by the Node command. +pub fn parse_revert(args: &[&str]) -> Option { + let (positionals, verbose, skip_install) = revert_arguments(args)?; + let [pattern, to] = positionals.as_slice() else { return None }; + let to = numeric_target::normalize(to); + if !valid_pattern(pattern) || !(to == "main" || numeric_target::valid_revert_version(&to)) { + return None; + } + Some(Options { + to, + pattern: Some((*pattern).to_owned()), + revert: true, + execution: Execution { verbose, skip_install }, + ..Options::default() + }) +} + +fn revert_arguments<'a>(args: &[&'a str]) -> Option<(Vec<&'a str>, bool, bool)> { + let mut positionals = Vec::new(); + let mut verbose = false; + let mut skip_install = false; + let mut index = 0; + while index < args.len() { + let arg = args[index]; + index += 1; + match arg { + "--" => { + extend_non_flag_positionals(&mut positionals, &args[index..])?; + break; + } + "--no-color" | "--no-color=true" | "--no-color=false" => {} + "-v" | "--verbose" | "--verbose=true" => verbose = true, + "--verbose=false" => verbose = false, + "-x" | "--skip-dependency-installation" | "--skip-dependency-installation=true" => { + skip_install = true; + } + "--skip-dependency-installation=false" => skip_install = false, + "-vx" | "-xv" => { + verbose = true; + skip_install = true; + } + value if !value.starts_with('-') && !value.is_empty() => positionals.push(value), + _ => return None, + } + } + Some((positionals, verbose, skip_install)) +} + +/// The merge strategy: `None` when invalid, `Some(None)` when not given. +#[inline] +fn strategy(flags: &Map) -> Option> { + let manual = flags.get("--manual") == Some(&Value::Bool(true)); + let resolve = flags.get("--auto-merge-resolve").and_then(Value::as_str); + if resolve.is_some_and(|strategy| !matches!(strategy, "ours" | "theirs" | "manual")) { + return None; + } + Some(if manual { Some("manual") } else { resolve }) +} + +/// Component pattern syntax is evaluated against the authoritative workspace pool. +#[inline] +fn valid_pattern(pattern: &str) -> bool { + !pattern.is_empty() && pattern.len() <= 4096 && !pattern.chars().any(char::is_control) +} + +impl Options { + /// The `options` of `commandPrepare`: `JSON.stringify` drops the absent ones. + pub fn request(&self) -> Value { + let mut options = json!({ "to": self.to }); + if let Some(pattern) = &self.pattern { + options["pattern"] = json!(pattern); + } + options["all"] = json!(self.all); + if let Some(strategy) = &self.strategy { + options["strategy"] = json!(strategy); + } + options["forceOurs"] = json!(self.force.ours); + options["forceTheirs"] = json!(self.force.theirs); + options["noSnap"] = json!(false); + options["revert"] = json!(self.revert); + options["verbose"] = json!(self.execution.verbose); + options["workspaceOnly"] = json!(self.workspace_only); + options["includeNewFromScope"] = json!(self.force.include_new_from_scope); + // The native CLI installs after committing the VCS plan. + options["skipDependencyInstallation"] = json!(true); + options + } + + /// `checkoutProps.all` once `parseValues` ran: `head` and `latest` + /// without a pattern check out every component. + #[inline] + pub fn report_all(&self) -> bool { + self.all || (self.pattern.is_none() && matches!(self.to.trim(), "head" | "latest")) + } +} diff --git a/native/cli/src/commands/checkout/options/numeric_target.rs b/native/cli/src/commands/checkout/options/numeric_target.rs new file mode 100644 index 000000000000..a09ba4fa2bd8 --- /dev/null +++ b/native/cli/src/commands/checkout/options/numeric_target.rs @@ -0,0 +1,133 @@ +//! yargs-parser's positional number coercion ([`coerce`]) followed by `CheckoutCmd`'s `String(to)`. +//! +//! Exposes [`normalize`] to format numeric and hexadecimal positional arguments like `JavaScript`. +#[cfg(test)] +mod tests; + +use std::sync::OnceLock; + +/// Coerces numeric and hexadecimal checkout targets using yargs and `String(to)` rules. +#[inline] +pub(super) fn normalize(raw: &str) -> String { + let raw = raw.trim(); + number(raw) + .and_then(|number| { + serde_json::Number::from_f64(number) + .and_then(|number| { + bit_object_import::stringify_json(&serde_json::Value::Number(number), false) + .ok() + }) + }) + .unwrap_or_else(|| raw.into()) +} +/// Parses a decimal or hexadecimal checkout target string as an `f64` when within safe integer bounds. +fn number(raw: &str) -> Option { + let raw = raw.trim(); + if let Some(hex) = raw + .strip_prefix("0x") + .or_else(|| raw.strip_prefix("0X")) + { + // parseFloat("0x...") is zero, so yargs' safe-integer gate always accepts hex. + if hex.is_empty() || !hex.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return None; + } + return u128::from_str_radix(hex, 16) + .ok() + .map(|number| number as f64); + } + if raw.starts_with('0') + && raw + .as_bytes() + .get(1) + .is_some_and(|byte| *byte != b'.') + { + return None; + } + static DECIMAL: OnceLock = OnceLock::new(); + let grammar = DECIMAL.get_or_init(|| { + regress::Regex::new(r"^[-]?(?:\d+(?:\.\d*)?|\.\d+)(e[-+]?\d+)?$") + .expect("published yargs number grammar") + }); + grammar.find(raw)?; + let number = raw.parse::().ok()?; + let integer = number.floor(); + (integer.is_finite() && integer.abs() <= 9_007_199_254_740_991.0).then_some(number) +} + +#[inline] +pub(super) fn head_tilde_is_nan(after: &str) -> bool { + let trimmed = after.trim_start(); + let rest = trimmed + .strip_prefix(['+', '-']) + .unwrap_or(trimmed); + if let Some(hex) = rest + .strip_prefix("0x") + .or_else(|| rest.strip_prefix("0X")) + { + return !hex + .bytes() + .next() + .is_some_and(|byte| byte.is_ascii_hexdigit()); + } + !rest + .bytes() + .next() + .is_some_and(|byte| byte.is_ascii_digit()) +} + +#[inline] +pub(super) fn valid_revert_version(to: &str) -> bool { + let to = to.trim(); + if matches!(to, "head" | "latest" | "reset") || to.starts_with("head~") { + return false; + } + valid_to(to) +} + +/// `head`, `latest`, `reset`, `main`, `head~N`, a snap hash or a semver tag. +#[inline] +pub(super) fn valid_to(to: &str) -> bool { + let to = to.trim(); + if matches!(to, "head" | "latest" | "reset" | "main") { + return true; + } + if let Some(steps) = to.strip_prefix("head~") { + return !steps.is_empty() && steps.bytes().all(|byte| byte.is_ascii_digit()); + } + if (3..=40).contains(&to.len()) && to.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return true; + } + valid_tag(to) +} + +/// `[0-9]+\.[0-9]+\.[0-9]+(?:-[a-zA-Z0-9.-]+)?(?:\+[a-zA-Z0-9.-]+)?`. +fn valid_tag(to: &str) -> bool { + let identifier = |text: &str| { + !text.is_empty() + && text + .split('.') + .all(|segment| { + !segment.is_empty() + && segment + .chars() + .all(|character| character.is_ascii_alphanumeric() || character == '-') + }) + }; + let (main, build) = match to.split_once('+') { + Some((main, build)) => (main, Some(build)), + None => (to, None), + }; + if build.is_some_and(|build| !identifier(build)) { + return false; + } + let (core, prerelease) = match main.split_once('-') { + Some((core, prerelease)) => (core, Some(prerelease)), + None => (main, None), + }; + if prerelease.is_some_and(|prerelease| !identifier(prerelease)) { + return false; + } + let mut parts = core.split('.'); + parts.clone().count() == 3 + && parts.all(|part| !part.is_empty() && part.bytes().all(|byte| byte.is_ascii_digit())) +} diff --git a/native/cli/src/commands/checkout/options/numeric_target/tests.rs b/native/cli/src/commands/checkout/options/numeric_target/tests.rs new file mode 100644 index 000000000000..aa66815dab5e --- /dev/null +++ b/native/cli/src/commands/checkout/options/numeric_target/tests.rs @@ -0,0 +1,38 @@ +//! Unit tests for yargs-compatible numeric target string [`normalize`] behavior. +//! +//! Verifies scientific, hexadecimal, and decimal coercion rules alongside non-numeric target preservation. + +use super::normalize; +/// Verifies that scientific, hexadecimal, and decimal target strings coerce like `yargs` number-then-string. +#[test] +fn scientific_hex_and_decimal_targets_follow_yargs_number_then_string() { + for (raw, expected) in [ + ("38938e0", "38938"), + ("123e-2", "1.23"), + ("0xabc", "2748"), + ("0XABC", "2748"), + ("123.", "123"), + (".123", "0.123"), + ("-0", "0"), + ("1e-7", "1e-7"), + ("0x20000000000001", "9007199254740992"), + ] { + assert_eq!(normalize(raw), expected, "{raw}"); + } +} +/// Verifies that leading-zero, unsafe-integer, and non-numeric target strings preserve their exact spelling. +#[test] +fn leading_zero_unsafe_and_non_numeric_targets_keep_their_spelling() { + for raw in + ["0123", "0e1", "1E3", "+123", "9007199254740992", "1e21", "0.0.1", "head~2", "abcdef1"] + { + assert_eq!(normalize(raw), raw, "{raw}"); + } +} + +#[test] +fn empty_and_plain_integer_targets_normalize_cleanly() { + assert_eq!(normalize(""), ""); + assert_eq!(normalize("0"), "0"); + assert_eq!(normalize("42"), "42"); +} diff --git a/native/cli/src/commands/checkout/options/options_tests.rs b/native/cli/src/commands/checkout/options/options_tests.rs new file mode 100644 index 000000000000..4bf036495668 --- /dev/null +++ b/native/cli/src/commands/checkout/options/options_tests.rs @@ -0,0 +1,122 @@ +//! Unit tests for `checkout` ([`parse`]) and `revert` ([`parse_revert`]) CLI option parsing and validation. +//! +//! Verifies positional target order, flag combinations, and unsupported argument rejection. + +use super::{parse, parse_revert}; + +/// Verifies that [`parse_revert`] accepts only `bit revert` positional order and supported flags. +#[test] +fn revert_accepts_only_its_positional_order_and_options() { + let options = parse_revert(&["scope/one", "0.0.1", "-x"]).unwrap(); + assert!(options.revert); + assert_eq!(options.pattern.as_deref(), Some("scope/one")); + assert_eq!(options.to, "0.0.1"); + assert_eq!(options.request()["revert"], true); + assert!(parse_revert(&["scope/one", "main", "--verbose"]).is_some()); + assert!(parse_revert(&["**", "main"]).is_some()); + for args in [ + vec!["scope/one", "head"], + vec!["scope/one", "reset"], + vec!["scope/one", "head~1"], + vec!["scope/one", "0.0.1", "--all"], + vec!["0.0.1", "scope/one"], + ] { + assert!(parse_revert(&args).is_none(), "{args:?}"); + } +} + +/// Verifies that [`parse`] accepts the supported native `bit checkout` target and flag subset. +#[test] +fn accepts_the_native_subset() { + let options = parse(&["0.0.1", "one", "-x"]).unwrap(); + assert_eq!((options.to.as_str(), options.pattern.as_deref()), ("0.0.1", Some("one"))); + assert!(parse(&["head", "--skip-dependency-installation", "--all"]).unwrap().all); + assert!(parse(&["latest", "--all"]).unwrap().all); + assert!(parse(&["head", "one", "--all"]).is_none()); + assert!(parse(&["reset", "-x", "-a"]).unwrap().all); + assert_eq!(parse(&["head", "-x", "-r", "theirs"]).unwrap().strategy.as_deref(), Some("theirs")); + assert_eq!(parse(&["head", "-x", "--manual"]).unwrap().strategy.as_deref(), Some("manual")); + assert!(parse(&["head~2", "-x"]).is_some()); + assert!(parse(&["head", "-e", "-x"]).unwrap().workspace_only); + assert_eq!( + parse(&[ + "head", + "--workspace-only=true", + "--auto-merge-resolve=theirs", + "--no-color", + "--no-color=false", + ]) + .unwrap() + .strategy + .as_deref(), + Some("theirs"), + ); + assert!(parse_revert(&["scope/one", "main", "--no-color=true"]).is_some()); + assert_eq!( + parse(&["head", "--workspace-only", "-x"]).unwrap().request()["workspaceOnly"], + true, + ); + assert!(parse(&["1.0.0-rc.1+build", "-x"]).is_some()); + assert!(parse(&[&"a".repeat(40), "-x"]).is_some()); +} + +#[test] +fn rejects_everything_else() { + assert!(!parse(&["head"]).unwrap().execution.skip_install); + assert!(parse(&["head", "-x"]).unwrap().execution.skip_install); + assert!(parse(&["head", "-x", "-x"]).is_none()); + assert_eq!( + parse(&["head", "-x", "--manual", "-r", "ours"]).unwrap().strategy.as_deref(), + Some("manual"), + ); + assert!(parse(&["head", "-x", "-r", "mine"]).is_none()); + assert!(parse(&["head", "-x", "-r"]).is_none()); + assert!(parse(&["head", "-x", "--force-ours", "--force-theirs"]).is_none()); + assert!(parse(&["1.0", "-x"]).is_none()); + assert!(parse(&["head", "One", "-x"]).is_some()); + assert!(parse(&["head", "a", "b", "-x"]).is_none()); + assert!(parse(&["head", "-x", "--json"]).is_none()); + assert!(parse(&["reset", "-x", "-e"]).is_none()); + assert!(parse(&["latest", "-x", "--workspace-only"]).is_none()); + assert!(parse(&["head", "-e", "--workspace-only"]).is_none()); +} + +#[test] +fn head_and_latest_without_a_pattern_report_as_all() { + assert!(parse(&["head", "-x"]).unwrap().report_all()); + assert!(parse(&["latest", "-x"]).unwrap().report_all()); + assert!(!parse(&["head", "one", "-x"]).unwrap().report_all()); + assert!(!parse(&["0.0.1", "one", "-x"]).unwrap().report_all()); + assert!(parse(&["0.0.1", "-x", "--all"]).unwrap().report_all()); +} + +#[test] +fn checkout_and_revert_preserve_component_predicates() { + for pattern in ["comp*", "**/comp[13]*", "$modified", "comp* AND $modified", "comp*,!**/comp2"] + { + assert_eq!(parse(&["0.0.1", pattern, "-x"]).unwrap().pattern.as_deref(), Some(pattern)); + assert_eq!( + parse_revert(&[pattern, "0.0.1", "-x"]).unwrap().pattern.as_deref(), + Some(pattern), + ); + } +} + +#[test] +fn checkout_and_revert_coerce_only_the_target_before_version_validation() { + for (raw, expected) in [ + ("38938e0", "38938"), + ("0xabc", "2748"), + ("0012345", "0012345"), + ("9007199254740992", "9007199254740992"), + ] { + assert_eq!(parse(&[raw, "scope/component", "-x"]).unwrap().to, expected); + assert_eq!(parse_revert(&["scope/component", raw, "-x"]).unwrap().to, expected); + } +} + +#[test] +fn empty_args_decline_for_both_checkout_and_revert() { + assert!(parse(&[]).is_none()); + assert!(parse_revert(&[]).is_none()); +} diff --git a/native/cli/src/commands/checkout/report.rs b/native/cli/src/commands/checkout/report.rs new file mode 100644 index 000000000000..54191e0117e0 --- /dev/null +++ b/native/cli/src/commands/checkout/report.rs @@ -0,0 +1,270 @@ +//! The checkout report ([`Options`], [`policy`]): `checkoutOutput` of `@teambit/checkout`. +//! +//! Renders [`format`] and [`format_title`] summaries from [`Results`] and [`Changed`] entries. +pub(crate) mod policy; +#[cfg(test)] +mod report_tests; + +use super::options::Options; +use crate::commands::{ + color::Chalk, + workspace_command::{ + format::Formatter, + results::{Changed, Report, Results, hydrate, join_lines, listed}, + }, +}; +use serde_json::Value; +use std::io; + +/// The checkout report of the engine's result. +#[inline] +pub fn format(result: &Value, options: &Options, chalk: Chalk) -> io::Result { + format_title(result, options, chalk, None) +} + +/// `checkoutOutput` accepts a lane-history title without changing its checkout sections. +pub(in crate::commands) fn format_title( + result: &Value, + options: &Options, + chalk: Chalk, + title: Option<&str>, +) -> io::Result { + let results = hydrate(result)?; + if results.snaps.is_some() || results.snap_error.is_some() { + return Err(crate::commands::status::js::decline( + "native checkout report with merge snapshots", + )); + } + let report = Checkout { + results: &results, + options, + format: Formatter::new(chalk), + policy: policy::format(result.get("workspaceConfigUpdateResult"), chalk)?, + title: title + .map(str::trim) + .filter(|title| !title.is_empty()), + }; + let skipped = !results.skipped.is_empty() + && options.report_all() + && !options.execution.verbose; + let data = deprecated_all(options, report.build(&report.skipped_minimal())); + if !skipped { + return Ok(Report { data, code: 0, details: None }); + } + let details = deprecated_all(options, report.build(&report.skipped_detailed())); + Ok(Report { data, code: 0, details: Some(details) }) +} + +#[inline] +fn non_empty_trimmed(version: Option<&str>) -> Option<&str> { + version + .map(str::trim) + .filter(|ver| !ver.is_empty()) +} + +/// Prepends the `--all` deprecation notice for `head`/`latest` checkouts when `options.all` is set. +#[inline] +fn deprecated_all(options: &Options, report: String) -> String { + let target = options.to.trim(); + if options.all && matches!(target, "head" | "latest") { + format!( + " \"--all\" is deprecated for \"bit checkout {target}\", please omit it.\n{report}", + ) + } else { + report + } +} + +/// `checkoutOutput(results, props)`. +struct Checkout<'r> { + results: &'r Results, + options: &'r Options, + format: Formatter, + policy: (String, String), + title: Option<&'r str>, +} + +impl Checkout<'_> { + #[inline] + fn chalk(&self) -> Chalk { + self.format.chalk + } + + /// `buildOutput(notCheckedOutSection)`. + fn build(&self, skipped: &str) -> String { + Formatter::join(&[ + self.policy.0.clone(), + skipped.to_string(), + self.successful(), + listed(&self.format, "removed components", &self.results.lists.removed), + listed(&self.format, "added components", &self.results.lists.added), + self.new_on_lane(), + self.new_from_scope(), + self.policy.1.clone(), + self.conflict_summary(), + self.summary(), + ]) + } + + /// `getNotCheckedOutOutputMinimal`. + fn skipped_minimal(&self) -> String { + let count = self.results.skipped.len(); + if count == 0 { + return String::new(); + } + if self.options.report_all() && !self.options.execution.verbose { + return self.format.details_hint(&format!("full list of {count} skipped component(s)")); + } + self.skipped_detailed() + } + + /// `getNotCheckedOutOutputDetailed`. + fn skipped_detailed(&self) -> String { + let items: Vec = self.results.skipped + .iter() + .map(|(id, message)| self.format.item(&format!("{} - {message}", id.display()))) + .collect(); + self.format.section("checkout skipped", "", &items) + } + + /// `getSuccessfulOutput`. + fn successful(&self) -> String { + match self.results.components.as_slice() { + [] => String::new(), + [component] => self.successful_one(component), + components => self.successful_many(components), + } + } + + fn successful_one(&self, component: &Changed) -> String { + let chalk = self.chalk(); + let id = &component.id; + if self.to("reset") { + let title = format!("successfully reset {}", chalk.bold(&id.display())); + return self.format.success_summary(&title); + } + let version = if self.to("head") || self.to("latest") { + non_empty_trimmed(id.version.as_deref()) + .or_else(|| non_empty_trimmed(self.results.version.as_deref())) + } else { + non_empty_trimmed(self.results.version.as_deref()) + .or_else(|| non_empty_trimmed(id.version.as_deref())) + } + .unwrap_or("undefined"); + let title = format!( + "successfully {} {} to version {}", + if self.options.revert { "reverted" } else { "switched" }, + chalk.bold(&id.without_version()), + chalk.bold(version), + ); + join_lines(&[ + self.format.success_summary(self.title.unwrap_or(&title)), + self.results.apply_version_report(&self.format, false, false), + ]) + } + + fn successful_many(&self, components: &[Changed]) -> String { + if self.to("reset") { + let items: Vec = components + .iter() + .map(|component| self.format.item(&component.id.display())) + .collect(); + return self.format.section("reset components", "", &items); + } + let target = if self.to("head") { + "their head version".to_string() + } else if self.to("latest") { + "their latest version".to_string() + } else if self.to("main") { + "their main version".to_string() + } else { + let version = non_empty_trimmed(self.results.version.as_deref()).unwrap_or("undefined"); + format!("version {}", self.chalk().bold(version)) + }; + let title = format!( + "successfully {} {} components to {target}", + if self.options.revert { "reverted" } else { "switched" }, + components.len(), + ); + join_lines(&[ + self.format.success_summary(self.title.unwrap_or(&title)), + self.results.apply_version_report(&self.format, true, self.to("head")), + ]) + } + + #[inline] + fn to(&self, name: &str) -> bool { + self.options.to.trim() == name.trim() + } + + /// `getConflictSummary` with `conflictSummaryReport`. + fn conflict_summary(&self) -> String { + if self.results.components.is_empty() || !self.results.conflicts { + return String::new(); + } + let title = self.format.title(&format!( + "{} files with conflicts summary", + self.format.warn_symbol(), + )); + let hint = self.format.hint( + "fix the conflicts above manually and then run \"bit install\".\nonce ready, snap/tag the components to persist the changes", + ); + format!("{title}\n{}\n\n{hint}", self.results.conflict_lines(self.chalk()).join("\n")) + } + + /// `getNewOnLaneOutput` of `checkoutOutput`. + fn new_on_lane(&self) -> String { + let items: Vec = self.results.lists.lane.ids + .iter() + .map(|id| self.format.item(&id.display())) + .collect(); + let title = if self.results.lists.lane.added { + "new components from lane" + } else { + "new components on lane (not added)" + }; + let description = if self.results.lists.lane.added { + "" + } else { + "omit --workspace-only flag to add them" + }; + self.format.section(title, description, &items) + } + + #[inline] + fn new_from_scope(&self) -> String { + let items = self.results.lists.scope + .iter() + .map(|id| self.format.item(&id.display())) + .collect::>(); + self.format.section("new components from scope", "", &items) + } + + /// `getSummary`. + fn summary(&self) -> String { + let chalk = self.chalk(); + let new_on_lane = if self.results.lists.lane.ids.is_empty() { + String::new() + } else { + let state = if self.results.lists.lane.added { "added" } else { "not added" }; + format!( + "\nNew on lane ({state}): {}", + chalk.bold(&self.results.lists.lane.ids.len().to_string()), + ) + }; + let new_from_scope = if self.results.lists.scope.is_empty() { + String::new() + } else { + format!( + "\nNew from scope (imported): {}", + chalk.bold(&self.results.lists.scope.len().to_string()), + ) + }; + format!( + "{}\nTotal Changed: {}\nTotal Unchanged: {}{new_on_lane}{new_from_scope}", + self.format.title("Checkout Summary"), + chalk.bold(&self.results.components.len().to_string()), + chalk.bold(&self.results.skipped.len().to_string()), + ) + } +} diff --git a/native/cli/src/commands/checkout/report/policy.rs b/native/cli/src/commands/checkout/report/policy.rs new file mode 100644 index 000000000000..e296a5ecec73 --- /dev/null +++ b/native/cli/src/commands/checkout/report/policy.rs @@ -0,0 +1,95 @@ +//! `getWsConfigUpdateLogs` and `getWorkspaceConfigUpdateOutput` via [`Formatter`] and [`Chalk`]. +//! +//! Exposes [`format`] to render workspace dependency update logs and conflict sections. +use crate::commands::{ + color::Chalk, + status::js::{array, decline, object, text}, + workspace_command::format::Formatter, +}; +use serde_json::Value; +use std::io; + +/// Formats verbose workspace config update logs and dependency update/conflict summary sections. +pub(crate) fn format(value: Option<&Value>, chalk: Chalk) -> io::Result<(String, String)> { + let Some(value) = value.filter(|value| !value.is_null()) else { + return Ok((String::new(), String::new())); + }; + let formatter = Formatter::new(chalk); + let logs = array(value.get("logs"), "checkout workspace policy logs")? + .iter() + .map(|line| text(Some(line))) + .collect::>>()?; + let logs = if logs.is_empty() { + String::new() + } else { + format!( + "{}\n{}", + formatter.title("verbose logs of workspace config update"), + logs.join("\n"), + ) + }; + let updates = updates(value.get("workspaceDepsUpdates"), &formatter)?; + let conflicts = match value.get("workspaceDepsConflicts") { + None | Some(Value::Null) => String::new(), + Some(Value::Object(map)) if map.is_empty() => String::new(), + Some(Value::Object(_)) => { + chalk.yellow("workspace.jsonc has conflicts, please edit the file and fix them") + } + Some(_) => return Err(decline("checkout workspace dependency conflicts")), + }; + Ok((logs, Formatter::join(&[updates, conflicts]))) +} + +/// Formats the `workspace.jsonc` dependency update section from `workspaceDepsUpdates`. +#[inline] +fn updates(value: Option<&Value>, formatter: &Formatter) -> io::Result { + let Some(value) = value.filter(|value| !value.is_null()) else { + return Ok(String::new()); + }; + let rows = object(value)? + .iter() + .filter(|(name, _)| !name.trim().is_empty()) + .map(|(name, entry)| { + let name = name.trim(); + if name.chars().any(char::is_control) { + return Err(decline("checkout workspace dependency name")); + } + let (before, after) = update_pair(entry)?; + Ok(format!(" {name}: {before} => {after}")) + }) + .collect::>>()?; + if rows.is_empty() { + return Ok(String::new()); + } + Ok(format!( + "{}\n{}", + formatter.title("workspace.jsonc has been updated with the following dependencies"), + rows.join("\n"), + )) +} + +#[inline] +fn update_pair(value: &Value) -> io::Result<(&str, &str)> { + let (before, after) = if let Some(object) = value.as_object() { + let before = text( + object + .get("from") + .or_else(|| object.get("oldVersion")), + )?; + let after = text( + object + .get("to") + .or_else(|| object.get("newVersion")), + )?; + (before.trim(), after.trim()) + } else { + let [first, second] = array(Some(value), "checkout workspace dependency update")? else { + return Err(decline("checkout workspace dependency versions")); + }; + (text(Some(first))?.trim(), text(Some(second))?.trim()) + }; + if before.is_empty() || after.is_empty() { + return Err(decline("checkout workspace dependency versions")); + } + Ok((before, after)) +} diff --git a/native/cli/src/commands/checkout/report/report_tests.rs b/native/cli/src/commands/checkout/report/report_tests.rs new file mode 100644 index 000000000000..eb5194a7c331 --- /dev/null +++ b/native/cli/src/commands/checkout/report/report_tests.rs @@ -0,0 +1,128 @@ +//! Unit tests for formatting `checkout` and `revert` terminal output reports via [`format`]. +//! +//! Verifies [`Chalk`] styling, [`expected_success_symbols`] rendering, and [`parse`] / [`parse_revert`] summary lines. + +use super::format; +use crate::commands::{ + checkout::options::{parse, parse_revert}, + color::Chalk, + test_support::expected_success_symbols, +}; +use serde_json::json; + +/// Verifies what `checkoutOutput` prints via [`format`] for one switched component without colors. +#[test] +fn reports_one_switched_component() { + let options = parse(&["0.0.1", "one", "-x"]).unwrap(); + let result = json!({ + "components": [{ "id": "my.scope/one@0.0.1", "filesStatus": { "index.ts": "updated", "a.ts": "unchanged" } }], + "failedComponents": [], + "version": "0.0.1", + }); + let report = format(&result, &options, Chalk::new(false)).unwrap(); + assert_eq!( + report.data, + expected_success_symbols( + "\u{2714} successfully switched my.scope/one to version 0.0.1\n\ncomponents with file changes\n\nupdated index.ts \n\nCheckout Summary\nTotal Changed: 1\nTotal Unchanged: 0" + ), + ); + assert!(report.details.is_none()); +} + +/// Verifies that [`format`] renders reverted file summaries and `Checkout Summary` lines for `bit revert`. +#[test] +fn reports_reverted_files_with_original_checkout_summary() { + let options = parse_revert(&["my.scope/one", "0.0.1", "-x"]).unwrap(); + let result = json!({ + "components": [{"id":"my.scope/one@0.0.1","filesStatus":{"index.ts":"updated"}}], + "failedComponents":[],"version":"0.0.1" + }); + let report = format(&result, &options, Chalk::new(false)).unwrap(); + assert!(report.data.contains("successfully reverted my.scope/one to version 0.0.1")); + assert!(report.data.contains("Checkout Summary")); +} + +/// `--all` without `--verbose` hides the skipped list behind `bit details`. +#[test] +fn moves_skipped_components_to_details() { + let options = parse(&["reset", "-x", "--all"]).unwrap(); + let result = json!({ + "components": [], + "failedComponents": [{ "id": "my.scope/one", "unchangedMessage": "is up to date", "unchangedLegitimately": true }], + }); + let report = format(&result, &options, Chalk::new(false)).unwrap(); + assert!(report.data.starts_with( + r#"(run "bit details" to see full list of 1 skipped component(s))"# + )); + let details = report.details.unwrap(); + assert!( + details.starts_with("checkout skipped (1)\n \u{203A} my.scope/one - is up to date"), + "{details}", + ); +} + +#[test] +fn warns_for_deprecated_explicit_all_on_head_and_latest() { + let result = json!({ "components": [], "failedComponents": [] }); + for target in ["head", "latest"] { + let options = parse(&[target, "-x", "--all"]).unwrap(); + let report = format(&result, &options, Chalk::new(false)).unwrap(); + assert!( + report.data.starts_with(&format!( + " \"--all\" is deprecated for \"bit checkout {target}\", please omit it.\n" + )), + "{}", + report.data, + ); + } + let options = parse(&["head", "-x"]).unwrap(); + assert!(!format(&result, &options, Chalk::new(false)).unwrap().data.contains("deprecated")); +} + +#[test] +fn reports_binary_conflicts_and_declines_illegitimate_failures() { + let options = parse(&["head", "-x"]).unwrap(); + let binary = + json!({ "components": [{ "id": "s/a@1", "filesStatus": { "x.png": "binaryConflict" } }] }); + let report = format(&binary, &options, Chalk::new(false)).unwrap(); + assert!(report.data.contains("unchanged-BINARY-CONFLICT")); + assert!(report.data.contains("binary files cannot be merged")); + let failed = json!({ "failedComponents": [{ "id": "s/a", "unchangedMessage": "x", "unchangedLegitimately": false }] }); + assert!(format(&failed, &options, Chalk::new(false)).is_err()); +} + +#[test] +fn reports_new_lane_components_separately_from_changed_components() { + let lane = ["scope/alpha@1234567890123456789012345678901234567890"]; + let result = json!({ + "components": [], "failedComponents": [], "newFromLane": lane, + "newFromLaneAdded": true, + }); + let options = parse(&["head", "-x"]).unwrap(); + let report = format(&result, &options, Chalk::new(false)).unwrap(); + assert!(report.data.contains( + "new components from lane (1)\n › scope/alpha@1234567890123456789012345678901234567890" + )); + assert!(report.data.contains("Total Changed: 0\nTotal Unchanged: 0\nNew on lane (added): 1")); + + let result = json!({ + "components": [], "failedComponents": [], "newFromLane": lane, + "newFromLaneAdded": false, + }); + let options = parse(&["head", "--workspace-only", "-x"]).unwrap(); + let report = format(&result, &options, Chalk::new(false)).unwrap(); + assert!(report.data.contains("new components on lane (not added) (1)\n omit --workspace-only flag to add them\n › scope/alpha@1234567890123456789012345678901234567890")); + assert!(report.data.contains("New on lane (not added): 1")); +} + +#[test] +fn verbose_flag_keeps_skipped_components_in_main_output() { + let options = parse(&["reset", "-x", "--all", "--verbose"]).unwrap(); + let result = json!({ + "components": [], + "failedComponents": [{ "id": "my.scope/one", "unchangedMessage": "is up to date", "unchangedLegitimately": true }], + }); + let report = format(&result, &options, Chalk::new(false)).unwrap(); + assert!(report.details.is_none()); + assert!(report.data.contains("checkout skipped (1)")); +} diff --git a/native/cli/src/commands/ci.rs b/native/cli/src/commands/ci.rs new file mode 100644 index 000000000000..fba747c787e8 --- /dev/null +++ b/native/cli/src/commands/ci.rs @@ -0,0 +1,343 @@ +//! Native `bit ci [verify|pr|merge|sync|init]` (`scopes/git/ci/ci.cmd.ts`). +//! +//! Coordinates [`init`], [`merge`], and [`pr`] subcommands for Git-backed CI workflows. + +mod init; +mod merge; +mod pr; +#[cfg(test)] +mod tests; + +use self::{ + merge::{MergeOptions, parse_merge, run_merge}, + pr::{PrOptions, parse_pr, run_pr}, +}; +use super::{ + Command, + workspace_command::{self, results::Report}, +}; +use crate::{native::Outcome, workspace}; +use std::{ + env, + ffi::OsString, + io::{self, Write}, + path::Path, + process::Command as ProcessCommand, +}; + +/// Native `bit ci` command descriptor. +pub const COMMAND: Command = Command { name: "ci", aliases: &[], run }; + +const OUTSIDE_WORKSPACE_ERR: &str = "This command can only be run inside a bit workspace. Please check that you are inside a bit workspace and then re-run (to initialize a new workspace please use bit init)"; +const INVALID_MERGE_RESOLVE_ERR: &str = + "--auto-merge-resolve must be one of the following: [ours, theirs, manual]"; +const INIT_COMBINED_ERR: &str = "bit ci sync --init cannot be combined with a lane argument, --branch, --all, --main or --dry-run: it only scaffolds onboarding files (workflows + workspace.jsonc config) and exits \u{2014} it never reconciles"; + +/// Parsed CLI flags for `bit ci sync`. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub(super) struct SyncOptions { + pub lane: Option, + pub branch: Option, + pub all: bool, + pub main: bool, + pub dry_run: bool, + pub init: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) enum Subcommand { + Bare, + Verify, + Pr(PrOptions), + Merge(MergeOptions), + Sync(SyncOptions), +} + +impl Subcommand { + #[inline] + const fn loader_label(&self) -> &'static str { + match self { + Self::Bare => "ci", + Self::Verify => "verify", + Self::Pr(_) => "pr", + Self::Merge(_) => "merge", + Self::Sync(_) => "sync", + } + } + + #[inline] + const fn init_banner(&self) -> &'static str { + match self { + Self::Bare => "", + Self::Verify => "\n\n\u{1f680} Initializing Verify command\n", + Self::Pr(_) => "\n\n\n\u{1f680} Initializing PR command\n", + Self::Merge(_) => "\n\n\n\u{1f680} Initializing Merge command\n", + Self::Sync(_) => "\n\n\nInitializing sync command\n", + } + } +} + +fn run(args: &[OsString]) -> io::Result { + let Some(argv) = workspace_command::utf8_args(args) else { + return Ok(Outcome::Declined("ci arguments are not UTF-8".into())); + }; + let Some(subcommand) = parse_subcommand(&argv[1..]) else { + return Ok(Outcome::Declined("outside native ci grammar".into())); + }; + let cwd = env::current_dir()?; + let root_opt = workspace::root(&cwd); + if matches!(subcommand, Subcommand::Bare) { + return run_bare(&cwd, root_opt.as_deref(), args); + } + let Some(root) = root_opt else { + return emit_outside_workspace(&cwd, args, &subcommand); + }; + match &subcommand { + Subcommand::Pr(opts) => run_pr(&root, args, opts), + Subcommand::Merge(opts) => { + if let Some(strategy) = opts.auto_merge_resolve.as_deref().map(str::trim) + && !matches!(strategy, "ours" | "theirs" | "manual") + { + return emit_subcommand_error(&root, args, &subcommand, INVALID_MERGE_RESOLVE_ERR); + } + run_merge(&root, args, opts) + } + Subcommand::Sync(opts) => run_sync(&root, args, &subcommand, opts), + _ => Ok(Outcome::Declined("ci subcommand execution delegated".into())), + } +} + +fn run_sync( + root: &Path, + args: &[OsString], + subcommand: &Subcommand, + options: &SyncOptions, +) -> io::Result { + match validate_sync_options(options) { + Err(message) => emit_subcommand_error(root, args, subcommand, &message), + Ok(()) if options.init => init::run(root, args), + Ok(()) => Ok(Outcome::Declined("ci sync execution delegated".into())), + } +} + +fn run_bare(cwd: &Path, root_opt: Option<&Path>, args: &[OsString]) -> io::Result { + let report = Report { data: "[ci] not implemented".into(), code: 1, details: None }; + if let Some(root) = root_opt.filter(|root| !root.as_os_str().is_empty()) { + workspace_command::execute_then(root, args, "ci", |_| Ok(report)) + } else { + workspace_command::execute_with_scope(cwd, args, "ci", &report) + } +} + +#[inline] +fn ci_enabled() -> bool { + env::var_os("CI") + .is_some_and(|val| { + let text = val.to_string_lossy(); + let trimmed = text.trim(); + !trimmed.is_empty() && trimmed != "0" && !trimmed.eq_ignore_ascii_case("false") + }) +} + +fn emit_outside_workspace( + cwd: &Path, + args: &[OsString], + subcommand: &Subcommand, +) -> io::Result { + if ci_enabled() { + return Ok(Outcome::Declined("CI ci outside-workspace error delegated".into())); + } + workspace_command::execute_failure_scope( + cwd, + args, + subcommand.loader_label(), + false, + subcommand.init_banner(), + io::Error::other(OUTSIDE_WORKSPACE_ERR), + ) +} + +fn emit_subcommand_error( + root: &Path, + args: &[OsString], + subcommand: &Subcommand, + message: &str, +) -> io::Result { + if ci_enabled() { + return Ok(Outcome::Declined("CI ci error delegated".into())); + } + let banner = subcommand.init_banner(); + let msg = message.to_owned(); + workspace_command::execute_then(root, args, subcommand.loader_label(), |_output| { + io::stdout().lock().write_all(banner.as_bytes())?; + Err(io::Error::other(msg)) + }) +} + +pub(super) fn parse_subcommand(args: &[&str]) -> Option { + let filtered = args + .iter() + .copied() + .filter(|arg| !matches!(*arg, "--no-color" | "--no-color=true" | "--no-color=false")) + .collect::>(); + match filtered.as_slice() { + [] | ["--"] => Some(Subcommand::Bare), + ["verify"] | ["verify", "--"] | ["--", "verify"] => Some(Subcommand::Verify), + ["pr", rest @ ..] if !rest.contains(&"--help") && !rest.contains(&"-h") => { + parse_pr(rest).map(Subcommand::Pr) + } + ["merge", rest @ ..] if !rest.contains(&"--help") && !rest.contains(&"-h") => { + parse_merge(rest).map(Subcommand::Merge) + } + ["sync", rest @ ..] => parse_sync(rest).map(Subcommand::Sync), + _ => None, + } +} + +fn parse_sync(rest: &[&str]) -> Option { + let mut opts = SyncOptions::default(); + let mut idx = 0; + while idx < rest.len() { + if rest[idx] == "--" { + for &val in &rest[idx + 1..] { + let trimmed = val.trim(); + (!trimmed.is_empty() + && !trimmed.starts_with('-') + && opts.lane.replace(trimmed.to_owned()).is_none()) + .then_some(())?; + } + break; + } + idx += apply_sync_arg(&mut opts, rest[idx], rest.get(idx + 1).copied())?; + } + Some(opts) +} + +fn apply_sync_arg(opts: &mut SyncOptions, arg: &str, next: Option<&str>) -> Option { + if let Some(val) = arg.strip_prefix("--branch=") { + let trimmed = val.trim(); + if trimmed.is_empty() || trimmed.starts_with('-') { + return None; + } + opts.branch = Some(trimmed.to_owned()); + return Some(1); + } + match arg { + "--all" | "--all=true" => opts.all = true, + "--all=false" => opts.all = false, + "--main" | "--main=true" => opts.main = true, + "--main=false" => opts.main = false, + "--dry-run" | "--dry-run=true" => opts.dry_run = true, + "--dry-run=false" => opts.dry_run = false, + "--init" | "--init=true" => opts.init = true, + "--init=false" => opts.init = false, + "--branch" => { + opts.branch = Some( + next.map(str::trim) + .filter(|val| !val.is_empty() && !val.starts_with('-'))? + .into(), + ); + return Some(2); + } + val if !val.starts_with('-') && !val.trim().is_empty() && opts.lane.is_none() => { + opts.lane = Some(val.trim().to_owned()); + } + _ => return None, + } + Some(1) +} + +pub(super) fn validate_sync_options(opts: &SyncOptions) -> Result<(), String> { + if opts.init { + if opts.lane.is_some() || opts.branch.is_some() || opts.all || opts.main || opts.dry_run { + return Err(INIT_COMBINED_ERR.into()); + } + return Ok(()); + } + let selectors = collect_sync_selectors(opts); + if opts.all + && let Some(narrow) = selectors.first() + { + return Err(format!( + "--all cannot be combined with {narrow}: --all reconciles every mapped lane plus the main scope, and is what runs when no target is given", + )); + } + if selectors.len() > 1 { + let winner = sync_winner_label(opts); + return Err(format!( + "{} cannot be combined with {}: each selects the single target this run reconciles, and only {winner} would have run \u{2014} the rest would be silently dropped, leaving a run that reports success while a target you named was never visited", + selectors[0], + selectors[1..].join(" or with "), + )); + } + Ok(()) +} + +#[inline] +fn collect_sync_selectors(opts: &SyncOptions) -> Vec { + let mut selectors = Vec::with_capacity(3); + if let Some(lane) = &opts.lane { + selectors.push(format!(r#"a lane argument ("{lane}")"#)); + } + if let Some(branch) = &opts.branch { + selectors.push(format!(r#"--branch ("{branch}")"#)); + } + if opts.main { + selectors.push("--main".into()); + } + selectors +} + +#[inline] +fn sync_winner_label(opts: &SyncOptions) -> String { + if opts.main { + "--main".into() + } else { + format!(r#"--branch ("{}")"#, opts.branch.as_deref().unwrap_or_default()) + } +} + +pub(super) fn run_child_bit(root: &Path, bit_args: &[&str]) -> io::Result { + let exe = env::current_exe()?; + let output = ProcessCommand::new(exe) + .current_dir(root) + .args(bit_args) + .env("BIT_DISABLE_SPINNER", "true") + .env("CI", "true") + .output()?; + let stdout = String::from_utf8_lossy(&output.stdout).into_owned(); + if output.status.success() { + Ok(stdout) + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + Err(io::Error::other(format!("{stdout}\n{stderr}"))) + } +} + +pub(super) fn run_git(root: &Path, git_args: &[&str]) -> io::Result { + let exe = super::diff::git::executable(root).unwrap_or_else(|_| "git".into()); + let output = ProcessCommand::new(exe) + .current_dir(root) + .args(git_args) + .output()?; + if output.status.success() { + Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned()) + } else { + Err(io::Error::other(String::from_utf8_lossy(&output.stderr).trim().to_owned())) + } +} + +#[inline] +pub(super) fn git_current_branch(root: &Path) -> Option { + run_git(root, &["symbolic-ref", "-q", "--short", "HEAD"]) + .or_else(|_| run_git(root, &["rev-parse", "--abbrev-ref", "HEAD"])) + .ok() + .filter(|branch| !branch.is_empty()) +} + +#[inline] +pub(super) fn git_last_commit_message(root: &Path) -> Option { + run_git(root, &["log", "-1", "--pretty=%B"]) + .ok() + .filter(|msg| !msg.is_empty()) +} diff --git a/native/cli/src/commands/ci/init.rs b/native/cli/src/commands/ci/init.rs new file mode 100644 index 000000000000..168bf8cada11 --- /dev/null +++ b/native/cli/src/commands/ci/init.rs @@ -0,0 +1,202 @@ +//! `SyncOrchestrator.syncInit`: onboard a repository without running reconciliation (`bit ci init`). +//! +//! Orchestrates [`agents`], [`files`], [`git`], [`paths`], [`remote`], and [`report`] generation. +mod agents; +mod files; +mod git; +mod paths; +mod remote; +mod report; +mod report_github; + +use super::workspace_command::{self, results::Report}; +use crate::{commands::color::Chalk, native::Outcome}; +use bit_workspace_config::jsonc::Document; +use std::{ + env, + ffi::OsString, + fs, io, + path::{Path, PathBuf}, +}; + +/// Prepared `bit ci init` onboarding plan for a workspace and Git repository. +struct Plan { + root: PathBuf, + cwd: PathBuf, + repository_root: Option, + scope: Option, +} +/// Executes `bit ci sync --init` (or `bit ci init`) in `root`. +pub(super) fn run(root: &Path, args: &[OsString]) -> io::Result { + let plan = match prepare(root) { + Ok(Some(plan)) => plan, + Ok(None) => { + return Ok(Outcome::Declined( + "CI onboarding startup or configuration requires the published runtime".into(), + )); + } + Err(error) => return Ok(Outcome::Declined(format!("CI onboarding preflight: {error}"))), + }; + let chalk = Chalk::detect(args); + workspace_command::execute_then(root, args, "sync", |output| { + plan.execute(chalk, output) + .map_err(|error| io::Error::new(error.kind(), chalk.red(&error.to_string()))) + }) +} +fn prepare(root: &Path) -> io::Result> { + let document = Document::parse(&fs::read_to_string(root.join("workspace.jsonc"))?)?; + let value = document.value(); + if !files::supported(&document) + || !crate::commands::remote_action::startup::supported( + Some(root), + &bit_workspace_config::scope_path(root), + &value, + true, + )? + { + return Ok(None); + } + let scope = value + .as_object() + .and_then(|entries| { + entries + .iter() + .find(|(name, _)| { + bit_install::config::aspect_id_without_version(trim(name)) + == "teambit.workspace/workspace" + }) + }) + .and_then(|(_, config)| config["defaultScope"].as_str()) + .map(trim) + .filter(|scope| !scope.is_empty()) + .map(str::to_owned); + Ok(Some(Plan { + root: root.to_owned(), + cwd: env::current_dir()?, + repository_root: git::repository_root(root), + scope, + })) +} +impl Plan { + fn execute(&self, chalk: Chalk, output: &bit_install::output::Output) -> io::Result { + let target = self.target(); + let mut files = self.write_workflows(&target.branch, output)?; + let config_added = files::config(&self.root)?; + files.push(self.write_agents(&target)?); + let changes = self.changes(files, config_added); + let data = report::Report { target, changes }; + Ok(Report { data: report::render(&data, chalk), code: 0, details: None }) + } + fn target(&self) -> report::Target { + let origin = git::origin(&self.cwd); + let (raw_branch, guessed) = git::default_branch(&self.cwd); + let branch = if trim(&raw_branch).is_empty() { "main".into() } else { raw_branch }; + let relative = git::relative(&self.cwd, self.scaffold_root()); + let repo_root = if relative.as_os_str().is_empty() { + ".".into() + } else { + relative.to_string_lossy().replace('\\', "/") + }; + report::Target { + scope: self.scope + .as_deref() + .map(str::trim) + .filter(|scope| !scope.is_empty()) + .unwrap_or("") + .to_owned(), + repository: origin.as_deref().and_then(remote::repository), + origin, + branch, + guessed, + repo_root, + } + } + #[inline] + fn scaffold_root(&self) -> &Path { + self.repository_root.as_deref().unwrap_or(&self.root) + } + fn write_workflows( + &self, + branch: &str, + output: &bit_install::output::Output, + ) -> io::Result> { + let scaffold = self.scaffold_root(); + if self.repository_root.is_none() { + output.warning(&format!(r#"could not resolve the git repository root ("git rev-parse --show-toplevel" failed — is this a git repository?). Writing the workflow files under the workspace instead: {}. If that is not the repository root, GitHub will not discover them — move .github/workflows there by hand."#, scaffold.display())); + } + let workspace_dir = git::relative(&paths::real(scaffold)?, &paths::real(&self.root)?) + .to_string_lossy() + .replace('\\', "/"); + let workspace_dir = + if workspace_dir.starts_with("..") { "" } else { workspace_dir.trim_matches('/') }; + files::workflows(scaffold, &self.cwd, branch, workspace_dir) + } + fn write_agents(&self, target: &report::Target) -> io::Result { + let repo_name = target.repository + .as_ref() + .map_or_else(|| agents::UNKNOWN.into(), remote::Repository::display); + let scope = self.scope + .as_deref() + .map(str::trim) + .filter(|scope| !scope.is_empty()) + .unwrap_or("."); + let section = agents::render(scope, &repo_name, &target.branch); + let (status, note) = agents::write(&self.root, §ion, &repo_name)?; + Ok(report::File { + relative: "AGENTS.md", + display: git::display(&self.cwd, &self.root.join("AGENTS.md")), + status, + note, + }) + } + fn changes(&self, files: Vec, config_added: bool) -> report::Changes { + let commits = git::commits(&self.cwd); + let scaffold = self.scaffold_root(); + let is_git = self.repository_root.is_some(); + let handled = [ + scaffold.join(".github/workflows/bit-sync.yml"), + scaffold.join(".github/workflows/bit-release.yml"), + self.root.join("workspace.jsonc"), + self.root.join("AGENTS.md"), + ]; + let (stage, ignored) = if is_git { + git::staging(&self.cwd, &self.cwd, &handled, commits) + } else { + (Vec::new(), Vec::new()) + }; + report::Changes { + commits, + git: is_git, + files, + config: git::display(&self.cwd, &self.root.join("workspace.jsonc")), + config_added, + stage, + ignored, + } + } +} + +#[inline] +fn shell_word(value: &str) -> String { + if !value.is_empty() + && value + .bytes() + .all(|ch| ch.is_ascii_alphanumeric() || b"_./@:+-".contains(&ch)) + { + value.into() + } else { + format!("'{}'", value.replace('\'', r"'\''")) + } +} + +/// ECMAScript whitespace includes BOM and excludes NEXT LINE. +#[inline] +fn whitespace(character: char) -> bool { + matches!(character, '\u{0009}'..='\u{000d}' | ' ' | '\u{00a0}' | '\u{1680}' | + '\u{2000}'..='\u{200a}' | '\u{2028}' | '\u{2029}' | '\u{202f}' | + '\u{205f}' | '\u{3000}' | '\u{feff}') +} +#[inline] +fn trim(value: &str) -> &str { + value.trim_matches(whitespace) +} diff --git a/native/cli/src/commands/ci/init/agents-section.md b/native/cli/src/commands/ci/init/agents-section.md new file mode 100644 index 000000000000..b92a88f28383 --- /dev/null +++ b/native/cli/src/commands/ci/init/agents-section.md @@ -0,0 +1,132 @@ + + +## Bit ↔ Git sync: this repository mirrors `{{scope}}` + +`bit ci sync` keeps this repository and the bit.cloud scope `{{scope}}` equal in both directions. **The rules in this section override the generic Bit guidance after it.** + +### The model + +1. **One repository mirrors one scope.** Components from other scopes arrive as package dependencies, never as source. +2. **`{{branch}}` equals the scope's main.** The workflows own it. Never push to `{{branch}}` by hand. +3. **Each lane is a branch plus a pull request.** The sync keeps them equal in both directions. By default, the branch has the lane's name. A lane name has no `/`, so the default mapping ignores a branch such as `feature/x`. The `branchPrefix` and `branches` keys of the `teambit.git/ci` sync config in `workspace.jsonc` can map a lane to a different branch name, such as `lane/x`. Read them before you name a branch. +4. **Merging the pull request is the release.** `bit-release` merges the lane into the scope, exports new versions, commits the new `.bitmap` to `{{branch}}` and archives the lane. A lane with components of other scopes stays open until each of those scopes releases its slice. +5. **The workflows do the work.** You change code, and then you either push a branch or export a lane. + +### Rules that override generic Bit guidance + +- **Review happens on the GitHub pull request.** Do **not** submit a bit.cloud change request, even if other instructions say "always submit a change request". The sync opens the PR. Merging the PR is the release. A second review surface splits the reviewers and races the release. +- **One change, one scope.** Keep a lane inside the scope it is exported to. If the change also needs another scope (for example, a caller must pass a new argument), stop. Make the change backward compatible in this scope, and tell the user which follow-up change the other scope's repository needs. Cross-scope lanes are a preview (see below). +- **Never push to `{{branch}}`.** The workflows own it. + +### Make a change: pick a path + +| You are… | Path | What you do | What appears | +| --------------------------------------------------------------- | ------------- | --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | +| In this repository (Copilot, an IDE, a plain git user) | **Git-first** | branch → edit → `bit validate` → commit → push → open a PR | With the optional `bit-adopt-pr` workflow, the PR becomes a lane, and every push snaps onto it. Without it, the sync ignores the branch. | +| In any other Bit workspace (Claude Code with the Bit Cloud MCP) | **Bit-first** | `bit lane create` → edit → `bit validate` → `bit snap` → `bit export` | A branch and a PR named after the lane appear in this repository: within a minute with the scope webhook, else at the next scheduled sync. | + +Both paths end with the same PR and the same release. + +#### Git-first (in this repository) + +This path needs the `bit-adopt-pr` workflow in `.github/workflows/`. On each push to a PR, it runs `bit ci pr --keep-lane`: this creates and exports a lane with the branch's name, and pushes the lane pointer back to the branch. `bit ci sync --init` does not write this workflow. Example: https://github.com/teambit/acme-modular-store/blob/main/.github/workflows/bit-adopt-pr.yml. Without it, a branch that has no lane stays out of the sync: use the Bit-first path. + +```bash +git switch -c add-gift-wrap # the lane gets the branch's name, so no "/" +bit install # once per clone +# edit the components; .bitmap says where each one lives +bit validate # lint + types + tests; never use tsc directly +git add -A && git commit -m "" +git push -u origin add-gift-wrap +gh pr create --fill +``` + +Commit `.bitmap` together with your source changes. Never edit `.bitmap` by hand. After the PR merges, run `git switch {{branch-sh}} && git pull` before you start the next change. + +#### Bit-first (from another Bit workspace) + +1. Find components with the Bit Cloud MCP: `read_scope` / `search_components` for the owner, `read_components` for APIs, and `read_file` for source. Don't guess IDs. Also check who calls the component, so you know whether your change stays inside one scope. +2. Run: + +```bash +bit init --default-scope {{scope}} # or reuse an existing workspace +bit import {{scope}}/ [...] # every component you will change, plus the chain up to its consumers +bit lane create +# edit +bit validate +bit snap -m "" +bit export +``` + +3. Watch it arrive: `gh pr list -R {{repo}} --head `. Then tell the user the PR link. Don't submit a bit.cloud change request. + +Headless or CI agents can call the Bit Cloud MCP with a bit.cloud token, without the OAuth step: + +```json +{ + "mcpServers": { + "bit-cloud": { + "type": "http", + "url": "https://mcp.bit.cloud/mcp", + "headers": { "Authorization": "Bearer ${BIT_CLOUD_TOKEN}" } + } + } +} +``` + +### Read a sync PR + +- The PR body lists the mirrored components and the foreign ones (other scopes, "not mirrored"). +- The bot's **run summary** comment lists the components that the sync last snapped onto the lane from this branch, with the branch and lane heads. A run that exports nothing does not change it. +- The run log says what each run did: `import-lane`, `export-branch`, `merge-diverged`, `adopt-branch`, `noop (converged)`. Runs: `gh run list --workflow bit-sync` and `gh run view --log`. +- `Lane sync: /` PRs are lanes. `Bit sync: update to latest main scope versions` brings `{{branch}}` up to the scope. Merging that one releases nothing. Its branch is `bit-sync/main`, or the `mainSyncBranch` of the sync config. With `mainSync: "direct-push"` there is no such PR: the sync commits to `{{branch}}` itself. + +### Fix a halt (`bit-sync-conflict` label) + +The lane and the branch changed the same lines. The lane stays paused while the label is on the PR. The bot's comment on the PR names the components and gives these steps: + +```bash +git fetch origin && git checkout '' +git merge --ff-only 'origin/' # stops if your local copy has commits that are not on origin: keep them, or reset to origin +bit install +bit lane import '/' # fetches the lane. If .bitmap is on main, it also switches to the lane +bit checkout head --manual --workspace-only # merges the lane's head into the files; conflicts appear as conflict markers +# resolve every conflicted file. When the intent is unclear, ask the user which side wins. Don't guess. +bit validate +git add -A && git commit -m "fix: resolve sync conflict" +git push origin '' +gh pr edit --remove-label bit-sync-conflict +gh workflow run bit-sync -f lane=/ +``` + +`--workspace-only` keeps the components of other scopes out of this repository. The next run adds the lane's new components of `{{scope}}`. Its log shows `merge-diverged` or `export-branch`, then a later run shows `noop (converged)`. Never resolve by force-pushing over the bot's commits. Never delete the lane to "start fresh". + +### Change several scopes at once (preview) + +Prefer one lane per scope: one change, one repository, one PR. + +A lane _can_ carry components from several scopes. Each scope's repository then mirrors only its own slice, consumes the other scopes' lane versions as packages, and releases its slice when its PR merges. The lane stays open until the last slice is released. Until automatic fan-out ships, only the repository of the lane's host scope hears about the lane. Start the others by hand: + +```bash +gh workflow run bit-sync -R / -f lane=/ +``` + +### Connect another scope to its own repository + +```bash +gh repo create / --private --clone && cd +bit init --default-scope . --skip-interactive +bit import "./**" +bit ci sync --init # writes the workflows, the sync config and this AGENTS.md section +``` + +Then follow the steps `bit ci sync --init` prints. Some of them need a repository or organization admin. Guide: https://bit.cloud/teambit/git/guides/connect-scope-to-repository + +### Never + +- Push to `{{branch}}`, or merge the main-sync PR and lane PRs out of order to "fix" history. +- Run `bit build`, `tsc`, `npm install` or `pnpm install` here. Use `bit validate` and `bit install`. +- Export a lane from another workspace while a Git-first PR is open for the same change. Pick one path. +- Remove a `bit-sync-conflict` label without resolving the conflict. + + diff --git a/native/cli/src/commands/ci/init/agents.rs b/native/cli/src/commands/ci/init/agents.rs new file mode 100644 index 000000000000..02bbd438f675 --- /dev/null +++ b/native/cli/src/commands/ci/init/agents.rs @@ -0,0 +1,179 @@ +//! `scaffoldAgentsMd`: retain user edits and only fill the [`UNKNOWN`] repository placeholder. +//! +//! Renders and updates `AGENTS.md` sync sections between [`START`] and [`END`] markers. +use std::{io, path::Path, sync::OnceLock}; +const START: &str = ""; +pub(super) const UNKNOWN: &str = "/"; + +/// Renders the `AGENTS.md` CI sync section for `scope`, `repo`, and `branch`. +pub(super) fn render(scope: &str, repo: &str, branch: &str) -> String { + let (scope, repo, branch) = (super::trim(scope), super::trim(repo), super::trim(branch)); + let raw = include_str!("agents-section.md").replace("\r\n", "\n"); + let quoted = super::shell_word(branch); + let values = + [("scope", scope), ("repo", repo), ("branch", branch), ("branch-sh", quoted.as_str())]; + let mut out = String::with_capacity(raw.len()); + let mut rest = raw.as_str(); + while let Some(start) = rest.find("{{") { + out.push_str(&rest[..start]); + let token = &rest[start..]; + if let Some((key, value)) = values + .iter() + .find(|(key, _)| { + token[2..] + .strip_prefix(*key) + .unwrap_or("") + .starts_with("}}") + }) + { + out.push_str(value); + rest = &token[key.len() + 4..]; + } else { + out.push_str("{{"); + rest = &token[2..]; + } + } + out.push_str(rest); + out +} + +/// Writes or updates `AGENTS.md` under `root` with `section` and `repo`, returning `(status, note)`. +pub(super) fn write( + root: &Path, + section: &str, + repo: &str, +) -> io::Result<(&'static str, Option<&'static str>)> { + let path = root.join("AGENTS.md"); + if !path.exists() { + super::files::write(&path, &format!("# Agent instructions\n\n{}\n", super::trim(section)))?; + return Ok(("written", None)); + } + let existing = super::files::read(&path)?; + let (content, note) = if existing.contains(START) { + (fill_repo(&existing, repo), Some("filled in the repository in the sync rules")) + } else { + (insert(&existing, section), None) + }; + if content == existing { + return Ok(("skipped", None)); + } + super::files::write(&path, &content)?; + Ok(("updated", note)) +} + +#[inline] +fn fill_repo(content: &str, repo: &str) -> String { + let trimmed_repo = super::trim(repo); + if trimmed_repo.is_empty() + || trimmed_repo == UNKNOWN + || trimmed_repo.chars().any(char::is_control) + { + return content.to_owned(); + } + let Some(start) = content.find(START) else { + return content.to_owned(); + }; + let Some(relative_end) = content[start..].find(END) else { + return content.to_owned(); + }; + let end = start + relative_end; + format!( + "{}{}{}", + &content[..start], + content[start..end].replace(UNKNOWN, trimmed_repo), + &content[end..], + ) +} + +#[inline] +fn starts_block(line: &str) -> bool { + static BLOCK_RE: OnceLock> = OnceLock::new(); + BLOCK_RE + .get_or_init(|| { + regress::Regex::new( + r"^\s*(#{1,6}(\s|$)|---|```|~~~|\n\n"; + +/// Parsed CLI options for `bit mcp-server rules [editor]`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(in crate::commands) struct RulesOptions { + pub editor: String, + pub is_global: bool, + pub should_print: bool, + pub consumer_project: bool, + pub force_standard: bool, +} + +pub(super) fn parse_rules(rest: &[&str]) -> Option { + let mut editor = None; + let mut is_global = false; + let mut should_print = false; + let mut consumer_project = false; + let mut force_standard = false; + let mut iter = rest.iter().copied(); + while let Some(arg) = iter.next() { + match arg.trim() { + "--" => { + crate::commands::internalize::consume_trailing_internalize(iter, &mut editor)?; + break; + } + "-g" | "--global" | "--global=true" => is_global = true, + "--global=false" => is_global = false, + "-p" | "--print" | "--print=true" => should_print = true, + "--print=false" => should_print = false, + "-gp" | "-pg" => { + is_global = true; + should_print = true; + } + "--consumer-project" | "--consumer-project=true" => consumer_project = true, + "--consumer-project=false" => consumer_project = false, + "--force-standard" | "--force-standard=true" => force_standard = true, + "--force-standard=false" => force_standard = false, + val if !val.starts_with('-') && !val.is_empty() && editor.is_none() => { + editor = Some(val.to_owned()); + } + _ => return None, + } + } + Some(RulesOptions { + editor: editor.unwrap_or_else(|| "vscode".into()), + is_global, + should_print, + consumer_project, + force_standard, + }) +} + +pub(super) fn execute_rules(cwd: &Path, opts: &RulesOptions, chalk: Chalk) -> String { + if opts.editor.trim().eq_ignore_ascii_case("windsurf") && !opts.should_print { + return chalk.yellow(WINDSURF_WARNING); + } + let base_content = default_rules_content(cwd, opts.consumer_project, opts.force_standard); + if opts.should_print { + return base_content.to_owned(); + } + let editor_name = editor_display_name(&opts.editor); + match write_rules_file(cwd, opts, base_content) { + Ok(rules_path) => format_rules_success(opts, editor_name, &rules_path, chalk), + Err(err_msg) => chalk.red(&format!("Error writing {editor_name} rules file: {err_msg}")), + } +} + +#[inline] +fn default_rules_content(cwd: &Path, consumer_project: bool, force_standard: bool) -> &'static str { + if consumer_project { + TEMPLATE_CONSUMER + } else if !force_standard && cwd.join(".git").exists() { + TEMPLATE_GIT + } else { + TEMPLATE_STANDARD + } +} + +fn write_rules_file(cwd: &Path, opts: &RulesOptions, base: &str) -> Result { + let editor_trimmed = opts.editor.trim(); + let editor_lower = editor_trimmed.to_ascii_lowercase(); + if !RULES_EDITORS.contains(&editor_lower.as_str()) { + return Err(format!( + r#"Editor "{editor_trimmed}" is not supported yet. Currently supported: {}"#, + RULES_EDITORS.join(", "), + )); + } + let (path, content) = match editor_lower.as_str() { + "vscode" => ( + vscode_prompts_path(opts.is_global, cwd)?, + format!("---\napplyTo: '**'\n---\n\n{base}"), + ), + "cursor" => ( + cursor_prompts_path(opts.is_global, cwd)?, + format!( + "---\ndescription: Bit MCP Agent Instructions\nalwaysApply: true\n---\n\n{base}", + ), + ), + "roo" => (roo_prompts_path(opts.is_global, cwd), base.to_owned()), + "cline" => ( + cline_prompts_path(opts.is_global, cwd)?, + format!( + "---\ndescription: Bit MCP Agent Instructions\ntags: [\"bit\", \"mcp\", \"component-development\"]\n---\n\n{base}", + ), + ), + _ => { + (claude_code_prompts_path(opts.is_global, cwd), format!("{CLAUDE_CODE_PREAMBLE}{base}")) + } + }; + if let Some(parent) = path + .parent() + .filter(|dir| !dir.as_os_str().is_empty()) + { + fs::create_dir_all(parent).map_err(|err| err.to_string())?; + } + fs::write(&path, content).map_err(|err| err.to_string())?; + Ok(path) +} + +fn format_rules_success( + opts: &RulesOptions, + editor_name: &str, + rules_path: &Path, + chalk: Chalk, +) -> String { + let scope = if opts.is_global { "global" } else { "workspace" }; + if opts.editor.trim().eq_ignore_ascii_case("claude-code") { + let file_path = if opts.is_global { "~/.claude/bit.md" } else { ".claude/bit.md" }; + let at_syntax = if opts.is_global { "@~/.claude/bit.md" } else { "@.claude/bit.md" }; + let text = format!( + "\u{2713} Successfully wrote {editor_name} Bit rules file ({scope})\n File created: {}\n\n {} Add this line to your main CLAUDE.md file:\n {}\n\n {}", + chalk.cyan(file_path), + chalk.yellow("Integration:"), + chalk.cyan(at_syntax), + chalk.gray("This will automatically include all Bit-specific instructions."), + ); + return chalk.green(&text); + } + let path_str = rules_path.to_string_lossy(); + let text = format!( + "\u{2713} Successfully wrote {editor_name} Bit MCP rules file ({scope})\n File written to: {}", + chalk.cyan(&path_str), + ); + chalk.green(&text) +} + +fn vscode_prompts_path(is_global: bool, cwd: &Path) -> Result { + if !is_global { + return Ok(cwd + .join(".github") + .join("instructions") + .join("bit.instructions.md")); + } + let home = workspace_command::home(); + if cfg!(windows) { + Ok(home + .join("AppData") + .join("Roaming") + .join("Code") + .join("User") + .join("prompts") + .join("bit.instructions.md")) + } else if cfg!(target_os = "macos") { + Ok(home + .join("Library") + .join("Application Support") + .join("Code") + .join("User") + .join("prompts") + .join("bit.instructions.md")) + } else if cfg!(target_os = "linux") { + Ok(home + .join(".config") + .join("Code") + .join("User") + .join("prompts") + .join("bit.instructions.md")) + } else { + Err(format!("Unsupported platform: {}", env::consts::OS)) + } +} + +#[inline] +fn cursor_prompts_path(is_global: bool, cwd: &Path) -> Result { + if is_global { + return Err("Cursor does not support global prompts configuration in a file".into()); + } + Ok(cwd + .join(".cursor") + .join("rules") + .join("bit.rules.mdc")) +} + +#[inline] +fn roo_prompts_path(is_global: bool, cwd: &Path) -> PathBuf { + let rel = Path::new(".roo").join("rules").join("bit.instructions.md"); + if is_global { workspace_command::home().join(rel) } else { cwd.join(rel) } +} + +pub(super) fn cline_prompts_path(is_global: bool, cwd: &Path) -> Result { + if !is_global { + return Ok(cwd.join(".clinerules").join("bit.instructions.md")); + } + if cfg!(target_os = "macos") { + Ok(workspace_command::home() + .join("Documents") + .join("Cline") + .join("Rules") + .join("bit.instructions.md")) + } else { + let platform = match env::consts::OS { + "windows" => "win32", + "macos" => "darwin", + other => other, + }; + Err(format!( + "Global Cline rules configuration is not supported on {platform}. The global path is only known for macOS (~/Documents/Cline/Rules/). For other operating systems, please use the --print flag to get the rules content and add it manually to your global Cline configuration.", + )) + } +} + +#[inline] +fn claude_code_prompts_path(is_global: bool, cwd: &Path) -> PathBuf { + let rel = Path::new(".claude").join("bit.md"); + if is_global { workspace_command::home().join(rel) } else { cwd.join(rel) } +} + +#[inline] +pub(super) fn cursor_settings_path(is_global: bool, cwd: &Path) -> PathBuf { + let rel = Path::new(".cursor").join("mcp.json"); + if is_global { workspace_command::home().join(rel) } else { cwd.join(rel) } +} + +#[inline] +pub(super) fn windsurf_settings_path(is_global: bool, cwd: &Path) -> PathBuf { + let rel = Path::new(".windsurf").join("mcp.json"); + if is_global { workspace_command::home().join(rel) } else { cwd.join(rel) } +} diff --git a/native/cli/src/commands/mcp_server/tests.rs b/native/cli/src/commands/mcp_server/tests.rs new file mode 100644 index 000000000000..d0b2f35f8919 --- /dev/null +++ b/native/cli/src/commands/mcp_server/tests.rs @@ -0,0 +1,119 @@ +//! Unit tests for `bit mcp-server` [`Subcommand`] CLI parsing ([`parse_subcommand`]) and editor configuration file generation ([`execute_setup`], [`execute_rules`]). +//! +//! Verifies [`SetupOptions`], [`RulesOptions`], and [`Chalk`]-formatted editor configuration output. + +use super::{ + SetupOptions, Subcommand, execute_setup, parse_subcommand, + rules::{RulesOptions, execute_rules}, +}; +use crate::commands::color::Chalk; +use std::fs; +use tempfile::tempdir; + +/// Verifies `bit mcp-server` subcommand parsing across `setup` and `write-rules` (`rules`). +#[test] +fn parses_setup_and_rules_subcommands() { + assert_eq!( + parse_subcommand(&["setup"]), + Some(Subcommand::Setup(SetupOptions { + editor: "vscode".into(), + consumer_project: false, + include_additional: None, + is_global: false, + })), + ); + assert_eq!( + parse_subcommand(&[ + "setup", + "cursor", + "-g", + "--consumer-project", + "--include-additional", + "tag,snap" + ]), + Some(Subcommand::Setup(SetupOptions { + editor: "cursor".into(), + consumer_project: true, + include_additional: Some("tag,snap".into()), + is_global: true, + })), + ); + assert_eq!( + parse_subcommand(&["rules", "claude-code", "-p", "--force-standard"]), + Some(Subcommand::Rules(RulesOptions { + editor: "claude-code".into(), + is_global: false, + should_print: true, + consumer_project: false, + force_standard: true, + })), + ); + assert_eq!(parse_subcommand(&["start"]), None); + assert_eq!(parse_subcommand(&["unknown"]), Some(Subcommand::Unrecognized("unknown".into()))); + assert_eq!(parse_subcommand(&[]), None); +} + +/// Verifies that [`execute_setup`] writes `.cursor/mcp.json` and returns a workspace confirmation message. +#[test] +fn writes_workspace_setup_and_rules_files() { + let dir = tempdir().expect("tempdir"); + let setup_out = execute_setup( + dir.path(), + &SetupOptions { + editor: "cursor".into(), + consumer_project: true, + include_additional: None, + is_global: false, + }, + Chalk::new(false), + ); + assert!(setup_out.contains("configured Cursor MCP integration (workspace)")); + let mcp_json = fs::read_to_string( + dir.path() + .join(".cursor") + .join("mcp.json"), + ) + .expect("read mcp.json"); + assert!(mcp_json.contains("--consumer-project")); + + let rules_out = execute_rules( + dir.path(), + &RulesOptions { + editor: "cursor".into(), + is_global: false, + should_print: false, + consumer_project: true, + force_standard: false, + }, + Chalk::new(false), + ); + assert!(rules_out.contains("Successfully wrote Cursor Bit MCP rules file (workspace)")); + let rules_mdc = fs::read_to_string( + dir.path() + .join(".cursor") + .join("rules") + .join("bit.rules.mdc"), + ) + .expect("read bit.rules.mdc"); + assert!(rules_mdc.starts_with( + "---\ndescription: Bit MCP Agent Instructions\nalwaysApply: true\n---" + )); +} + +#[test] +fn rules_print_mode_outputs_instructions_without_writing_file() { + let dir = tempdir().expect("tempdir"); + let rules_out = execute_rules( + dir.path(), + &RulesOptions { + editor: "cursor".into(), + is_global: false, + should_print: true, + consumer_project: false, + force_standard: false, + }, + Chalk::new(false), + ); + assert!(rules_out.contains("Bit MCP")); + assert!(!dir.path().join(".cursor").exists()); +} diff --git a/native/cli/src/commands/merge.rs b/native/cli/src/commands/merge.rs new file mode 100644 index 000000000000..57bc47264188 --- /dev/null +++ b/native/cli/src/commands/merge.rs @@ -0,0 +1,109 @@ +//! Native `bit merge [component-pattern] -x`, a workspace command +//! (`workspace_command`) with the merge options and report, and +//! `bit merge --resolve` ([`resolve`]), retaining one provider session through snapshots. +//! +//! Coordinates [`COMMAND`], [`install`], [`lifecycle`], [`report`], [`resolve`], [`failures`], and [`options`] submodules. + +pub(super) mod install; +pub(super) mod lifecycle; +pub(super) mod report; +pub(super) mod resolve; + +mod failures; +mod options; + +use super::workspace_command; +use crate::{commands::Command, native::Outcome}; +use std::{ffi::OsString, io}; + +/// Descriptor for the native `bit merge` command. +pub const COMMAND: Command = Command { name: "merge", aliases: &[], run }; + +/// Entry point for `bit merge`. +fn run(args: &[OsString]) -> io::Result { + let Some(argv) = workspace_command::utf8_args(args) else { + return Ok(Outcome::Declined("arguments are not UTF-8".into())); + }; + if let Some(resolve) = resolve::parse(&argv[1..]) { + return resolve::run(args, &resolve); + } + if let Some(abort) = super::recovery::parse_component(&argv[1..]) { + return super::recovery::run(args, &abort); + } + let Some((workflow, grammar)) = lifecycle::workflow(&argv[1..]) else { + return Ok(Outcome::Declined("outside the native merge tooling grammar".into())); + }; + let Some(options) = options::parse(&grammar) else { + return fallback_merge_error(args, &argv[1..]); + }; + let root = match workspace_command::workspace_or_fail(args, "merge")? { + Ok(root) => root, + Err(outcome) => return Ok(outcome), + }; + lifecycle::run( + &root, + args, + &argv, + lifecycle::Options { + request: options.request(), + mode: report::Mode { verbose: options.verbose, lane: false }, + message: options.message, + skip_install: options.skip_install, + workflow, + }, + ) +} + +/// Emits a deterministic flag-conflict error when `raw_args` fails `MergeCmd` validation outside CI. +fn fallback_merge_error(args: &[OsString], raw_args: &[&str]) -> io::Result { + let Some(err_msg) = options::validate_error(raw_args) else { + return Ok(Outcome::Declined("outside the native merge grammar".into())); + }; + if is_ci_environment() { + return Ok(Outcome::Declined("outside the native merge grammar".into())); + } + let json = raw_args + .iter() + .take_while(|&&arg| arg.trim() != "--") + .fold(false, |acc, arg| match arg.trim() { + "-j" | "--json" | "--json=true" => true, + "--json=false" => false, + _ => acc, + }); + let cwd = std::env::current_dir()?; + if let Some(root) = crate::workspace::root(&cwd) { + if json { + return workspace_command::execute_json(&root, args, "merge", |_| { + Err(io::Error::other(err_msg)) + }); + } + return workspace_command::execute_then(&root, args, "merge", |_| { + Err(io::Error::other(err_msg)) + }); + } + workspace_command::execute_failure_scope( + &cwd, + args, + "merge", + json, + "", + io::Error::other(err_msg), + ) +} + +#[inline] +fn is_ci_environment() -> bool { + std::env::var_os("CI") + .is_some_and(|val| { + let text = val.to_string_lossy(); + let trimmed = text + .trim() + .trim_matches(['"', '\'']) + .trim(); + !trimmed.is_empty() + && !trimmed.eq_ignore_ascii_case("0") + && !trimmed.eq_ignore_ascii_case("false") + && !trimmed.eq_ignore_ascii_case("no") + && !trimmed.eq_ignore_ascii_case("off") + }) +} diff --git a/native/cli/src/commands/merge/failures.rs b/native/cli/src/commands/merge/failures.rs new file mode 100644 index 000000000000..b50047756668 --- /dev/null +++ b/native/cli/src/commands/merge/failures.rs @@ -0,0 +1,78 @@ +//! `MergingMain.throwForFailures`: reject failed components before workspace publication. +//! +//! [`check`] inspects `failedComponents` entries ([`array`]) and formats abort errors with [`Chalk`]. +use crate::commands::{color::Chalk, status::js::array}; +use serde_json::Value; +use std::io; + +/// Rejects merge results containing non-legitimate `failedComponents` entries before workspace publication. +pub(super) fn check(result: &Value, chalk: Chalk, lane: bool) -> io::Result<()> { + let rows = array(result.get("failedComponents"), "merge failed components")?; + let mut failures = Vec::with_capacity(rows.len()); + for row in rows { + if row["unchangedLegitimately"].as_bool() == Some(true) { + continue; + } + let identity = row_identity(row).ok_or_else(|| io::Error::other("merge failure id"))?; + let message = row["unchangedMessage"] + .as_str() + .or_else(|| row["unchangedMessage"]["message"].as_str()) + .or_else(|| row["message"].as_str()) + .or_else(|| row["error"].as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| io::Error::other("merge failure message"))?; + failures.push(format!("{} - {}", chalk.bold(&identity), chalk.red(message))); + } + if failures.is_empty() { + return Ok(()); + } + if crate::commands::scope_rename::ci_active() { + return Err(io::Error::other("published CI error diagnostic stack")); + } + let operation = if lane { "proceed" } else { "merge" }; + Err(crate::commands::engine::canonical_error(format!( + "unable to {operation} due to the following failures:\n{}", + failures.join("\n"), + ))) +} + +/// Extracts the formatted component ID string from a `failedComponents` entry `row`. +fn row_identity(row: &Value) -> Option { + if let Some(identity) = row["id"] + .as_str() + .or_else(|| row["identity"].as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + { + return Some(identity.to_owned()); + } + let outer = row["id"].as_object()?; + let object = outer + .get("_legacy") + .and_then(Value::as_object) + .unwrap_or(outer); + let name = object + .get("name")? + .as_str() + .map(str::trim) + .filter(|value| !value.is_empty())?; + let base = match object + .get("scope") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + { + Some(scope) => format!("{scope}/{name}"), + None => name.to_owned(), + }; + match object + .get("version") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + { + Some(version) => Some(format!("{base}@{version}")), + None => Some(base), + } +} diff --git a/native/cli/src/commands/merge/install.rs b/native/cli/src/commands/merge/install.rs new file mode 100644 index 000000000000..9f1d7a5327f8 --- /dev/null +++ b/native/cli/src/commands/merge/install.rs @@ -0,0 +1,25 @@ +//! Native installer preparation before the retained merge lifecycle starts. +//! +//! [`start`] initializes an optional [`Installer`] when `--skip-dependency-installation` (`skip`) is not requested. +use crate::commands::installation::Installer; +use std::{io, path::Path}; + +/// Starts an optional background [`Installer`] for `root` unless `skip` is true or inputs are empty. +#[inline] +pub(in crate::commands) fn start( + root: &Path, + argv: &[&str], + skip: bool, +) -> io::Result> { + if skip + || root.as_os_str().is_empty() + || argv + .iter() + .all(|arg| arg.trim().is_empty()) + || !root.is_dir() + { + Ok(None) + } else { + Installer::start(root, argv).map(Some) + } +} diff --git a/native/cli/src/commands/merge/lifecycle.rs b/native/cli/src/commands/merge/lifecycle.rs new file mode 100644 index 000000000000..345838024399 --- /dev/null +++ b/native/cli/src/commands/merge/lifecycle.rs @@ -0,0 +1,202 @@ +//! One authoritative merge capture, local publication, installation, then snapshot callbacks. +//! +//! Coordinates [`Options`], [`snapshot`], [`ProjectedLinker`], [`Installer`], [`Environments`], and [`report`] across merge phases. +mod snapshot; +use super::report; +use crate::{ + commands::{ + add::ProjectedLinker, + color::Chalk, + installation::Installer, + status::{autosync::Startup, environments::Environments, js::decline}, + workspace_command::{ + self, Planned, Request, checkout_lifecycle, progress::Progress, resolve, + results::Report, + }, + }, + native::Outcome, +}; +use bit_install::{HostInfo, output::Output}; +use serde_json::Value; +use std::{ffi::OsString, io, path::Path, sync::Arc}; + +/// Execution parameters for a retained merge or lane-merge lifecycle. +pub(in crate::commands) struct Options { + pub request: Value, + pub mode: report::Mode, + pub message: Option, + pub skip_install: bool, + pub workflow: resolve::workflow::Workflow, +} + +/// Extracts `--build` workflow flags from `args` and returns the remaining merge grammar tokens. +pub(in crate::commands) fn workflow<'a>( + args: &[&'a str], +) -> Option<(resolve::workflow::Workflow, Vec<&'a str>)> { + let mut count = 0usize; + let mut build = false; + let mut rest = Vec::with_capacity(args.len()); + let mut after_double_dash = false; + for &arg in args { + let trimmed = arg.trim(); + if trimmed == "--" { + after_double_dash = true; + } + if !after_double_dash && matches!(trimmed, "--build" | "--build=true" | "--build=false") { + count += 1; + build = matches!(trimmed, "--build" | "--build=true"); + } else { + rest.push(arg); + } + } + if count > 1 { + return None; + } + let mut workflow = resolve::workflow::Workflow::default(); + workflow.build = build; + Some((workflow, rest)) +} + +pub(in crate::commands) fn run( + root: &Path, + args: &[OsString], + argv: &[&str], + mut options: Options, +) -> io::Result { + options.workflow.configure(root, argv, resolve::Kind::Resolve)?; + let installer = match super::install::start(root, argv, options.skip_install) { + Ok(installer) => installer, + Err(error) => return Ok(Outcome::Declined(format!("merge installation: {error}"))), + }; + let (environment, installation) = installation_session(installer)?; + let mut startup = Startup::default(); + let request = Request { + operation: "merge", + options: std::mem::take(&mut options.request), + to: None, + verify_fresh: true, + }; + let planned = match environment { + Some(environment) => workspace_command::plan_merge_with_environment( + root, + &request, + &mut startup, + environment, + ), + None => workspace_command::plan_merge(root, &request, &mut startup), + }; + let prepared = planned.and_then(|planned| precheck(planned, args, &options)); + let planned = match prepared { + Ok(planned) => planned, + Err(error) if startup.committed || crate::commands::engine::is_canonical(&error) => { + return checkout_lifecycle::failure(root, args, "merge", &startup, error); + } + Err(error) => return Ok(Outcome::Declined(format!("merge: {error}"))), + }; + if !options.mode.lane { + checkout_lifecycle::before(&startup)?; + } + workspace_command::execute_then(root, args, "merge", |output| { + execute(root, args, output, planned, &startup, &options, installation) + }) +} + +fn precheck(planned: Planned, args: &[OsString], options: &Options) -> io::Result { + let chalk = Chalk::detect(args); + super::failures::check(&planned.plan["result"], chalk, options.mode.lane)?; + Progress::parse(planned.plan.get("progress"))?; + if !planned.plan["mergeSnapshotSeeds"].is_array() { + return Err(decline("merge snapshot identities")); + } + report::format(&planned.plan["result"], options.mode, chalk)?; + Ok(planned) +} + +#[inline] +fn installation_session( + installer: Option, +) -> io::Result<(Option, Option)> { + match installer { + Some(installer) => { + let (environment, info) = installer.into_parts()?; + Ok((Some(environment), Some(info))) + } + None => Ok((None, None)), + } +} + +fn execute( + root: &Path, + args: &[OsString], + output: &Output, + mut planned: Planned, + startup: &Startup, + options: &Options, + installation: Option, +) -> io::Result { + if let Some(progress) = Progress::parse(planned.plan.get("progress"))? { + if options.mode.lane { + progress.render_merge_startup(output, &startup.stdout, &startup.build_stdout); + } else { + checkout_lifecycle::loaded(startup); + progress.render_merge(output); + } + } + workspace_command::commit(&mut planned, root)?; + let conflicts = planned.plan["result"]["leftUnresolvedConflicts"] == true + || planned.plan["result"]["configMergeResults"] + .as_array() + .is_some_and(|rows| { + rows.iter() + .any(|row| row["hasConflicts"] == true) + }) + || planned.plan["result"]["workspaceConfigUpdateResult"]["workspaceDepsConflicts"] + .as_object() + .is_some_and(|map| !map.is_empty()); + if !conflicts + && let Some(info) = installation + && let Err(error) = install_retained(root, &mut planned, info) + { + output.failure(&format!( + "failed installing packages, see the log for full stacktrace. error: {}", + error.to_string().trim(), + )); + } + let mut result = planned.plan["result"].clone(); + let seeds = planned.plan["mergeSnapshotSeeds"] + .as_array() + .ok_or_else(|| decline("merge snapshot identities"))?; + if !conflicts && !seeds.is_empty() { + snapshot::run(root, output, &mut planned, &mut result, options)?; + } + crate::commands::status::cleanup_temporary(root)?; + report::format(&result, options.mode, Chalk::detect(args)) +} + +fn install_retained(root: &Path, planned: &mut Planned, mut info: HostInfo) -> io::Result<()> { + let mut environment = planned + .take_environment() + .ok_or_else(|| io::Error::other("merge installation provider session"))?; + let projected = ProjectedLinker::project_installation_workspace(root, &mut environment)?; + if !projected.is_empty() { + print!("{projected}"); + } + let component_ids = planned.merge_cache_ids()?; + let apps = environment.merge_load_apps(root, &component_ids)?; + if !apps.is_empty() { + print!("{apps}"); + } + let host = Arc::new( + environment + .into_tracking_host() + .ok_or_else(|| io::Error::other("merge installation Host"))?, + ); + info.file_id = workspace_command::file_id(); + let installed = Installer::from_host(Arc::clone(&host), info) + .with_initialized_cache(planned.has_captured_components()) + .install_merge(root); + let host = Arc::try_unwrap(host) + .map_err(|_| io::Error::other("merge installation Host still borrowed"))?; + planned.restore_host(host); + installed +} diff --git a/native/cli/src/commands/merge/lifecycle/snapshot.rs b/native/cli/src/commands/merge/lifecycle/snapshot.rs new file mode 100644 index 000000000000..b698ca4ba49d --- /dev/null +++ b/native/cli/src/commands/merge/lifecycle/snapshot.rs @@ -0,0 +1,110 @@ +//! Continue the same provider session through a real merge-resolution snapshot. +//! +//! [`run`] drives [`Planned`] auto-snap or tag continuation with [`Progress`] and [`resolve`] for [`Options`]. +use super::Options; +use crate::commands::{ + status::{ + autosync::Startup, + js::{array, text}, + }, + workspace_command::{self, Planned, progress::Progress, resolve}, +}; +use bit_install::output::Output; +use serde_json::{Value, json}; +use std::{ + io::{self, Write}, + path::Path, +}; + +/// Runs the post-merge auto-snap or tag continuation against `materialized` and records errors into `result`. +#[inline] +pub(super) fn run( + root: &Path, + output: &Output, + materialized: &mut Planned, + result: &mut Value, + options: &Options, +) -> io::Result<()> { + if let Err(error) = continue_snapshot(root, output, materialized, result, options) { + let err_val = json!({"kind":"message","message":error.to_string().trim()}); + if let Some(map) = result.as_object_mut() { + map.remove("mergeSnapResults"); + map.insert("mergeSnapError".to_owned(), err_val); + } else { + result["mergeSnapError"] = err_val; + } + } + Ok(()) +} + +/// Executes `plan_continuation` and commits the resulting merge-resolution snapshot. +fn continue_snapshot( + root: &Path, + output: &Output, + materialized: &mut Planned, + result: &mut Value, + options: &Options, +) -> io::Result<()> { + let mut startup = Startup::default(); + let environment = materialized.take_environment(); + let resolved = crate::commands::merge::resolve::plan_continuation( + root, + None, + options.message.as_deref(), + &mut startup, + &options.workflow, + environment, + ); + if !startup.stdout.is_empty() { + io::stdout().write_all(startup.stdout.as_bytes())?; + } + let mut resolved = match resolved { + Ok(resolved) => resolved, + Err(error) => { + resolve::render_build_output(output, &startup, true)?; + return Err(error); + } + }; + resolve::render_events(output, &startup)?; + if let Some(progress) = Progress::parse_snap(resolved.plan.get("progress"))? { + if startup.snapshot_events.is_empty() { + progress.render_merge(output); + } else { + progress.render_graph(output); + } + } + resolve::render_build_output(output, &startup, false)?; + workspace_command::commit(&mut resolved, root)?; + result["mergeSnapResults"] = snap_results(&resolved)?; + Ok(()) +} + +fn snap_results(resolved: &Planned) -> io::Result { + let versions = array(resolved.plan.get("allocatedVersions"), "merge allocated versions")?; + let snapped = + array(resolved.plan["result"].get("resolvedComponents"), "merge resolved components")? + .iter() + .map(|row| { + let raw = text(row.get("id"))?.trim(); + let identity = raw + .rsplit_once('@') + .filter(|(bare, _)| !bare.trim().is_empty()) + .map_or(raw, |(bare, _)| bare.trim()); + if identity.is_empty() { + return Err(io::Error::other("merge resolved identity empty")); + } + let version = versions + .iter() + .find(|version| version["identity"].as_str().map(str::trim) == Some(identity)) + .ok_or_else(|| io::Error::other("merge allocated identity absent"))?; + let hash = text(version.get("hash"))?.trim(); + if hash.is_empty() { + return Err(io::Error::other("merge allocated hash empty")); + } + Ok(json!({"id":format!("{identity}@{hash}")})) + }) + .collect::>>()?; + Ok( + json!({"snappedComponents":snapped,"autoSnappedResults":resolved.plan["autoSnappedResults"]}), + ) +} diff --git a/native/cli/src/commands/merge/options.rs b/native/cli/src/commands/merge/options.rs new file mode 100644 index 000000000000..dcb86c2e36d4 --- /dev/null +++ b/native/cli/src/commands/merge/options.rs @@ -0,0 +1,322 @@ +//! The native merge grammar: the unambiguous subset of +//! `bit merge [component-pattern]` that native planning accepts. +//! +//! Defines [`Options`] and [`validate_error`] and serializes merge flags into engine request [`Value`] payloads. + +use serde_json::{Value, json}; + +/// Parsed CLI options for the native `bit merge [component-pattern]` command. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct Options { + pub pattern: Option, + /// `--auto-merge-resolve `, or `--manual`. + pub strategy: Option, + pub no_snap: bool, + pub message: Option, + pub verbose: bool, + pub skip_install: bool, +} + +/// Supported boolean CLI flags for `bit merge`. +const BOOLEAN: [&str; 4] = ["--skip-dependency-installation", "--manual", "--verbose", "--no-snap"]; +const VALUED: [&str; 2] = ["--auto-merge-resolve", "--message"]; + +/// When `args` fails validation in `MergeCmd.report`, return the error message. +#[must_use] +pub fn validate_error(args: &[&str]) -> Option<&'static str> { + let mut flags: Vec<(&str, Value)> = Vec::with_capacity(args.len()); + let mut positionals = 0usize; + let mut index = 0; + while index < args.len() { + let arg = alias(args[index].trim()); + index += 1; + if arg == "--" { + positionals += count_trailing_positionals(&args[index..])?; + break; + } + if matches!( + arg, + "--no-color" + | "--no-color=true" + | "--no-color=false" + | "--color" + | "--color=true" + | "--color=false" + | "--color=always" + | "--color=never" + | "--build" + | "--build=true" + | "--build=false", + ) { + continue; + } + if !arg.starts_with('-') { + (!arg.is_empty()).then_some(())?; + positionals += 1; + continue; + } + index += apply_validate_flag(&mut flags, arg, args.get(index).copied())?; + } + (positionals <= 1).then(|| check_merge_flags(&flags)).flatten() +} + +#[inline] +fn count_trailing_positionals(tail: &[&str]) -> Option { + tail.iter() + .all(|rest| { + let trimmed = rest.trim(); + !trimmed.is_empty() && !trimmed.starts_with('-') + }) + .then_some(tail.len()) +} + +fn check_merge_flags(flags: &[(&str, Value)]) -> Option<&'static str> { + let get = |name: &str| { + flags + .iter() + .find(|(flag, _)| *flag == name) + .map(|(_, value)| value) + }; + let flag = |name: &str| get(name) == Some(&Value::Bool(true)); + if flag("--ours") || flag("--theirs") { + return Some( + r#"the "--ours" and "--theirs" flags are deprecated. use "--auto-merge-resolve" instead"#, + ); + } + if get("--auto-merge-resolve") + .and_then(Value::as_str) + .is_some_and(|val| !matches!(val, "ours" | "theirs" | "manual")) + { + return Some("--auto-merge-resolve must be one of the following: [ours, theirs, manual]"); + } + if flag("--abort") && flag("--resolve") { + return Some(r#"unable to use "abort" and "resolve" flags together"#); + } + if flag("--no-snap") + && get("--message") + .and_then(Value::as_str) + .is_some_and(|msg| !msg.is_empty()) + { + return Some(r#"unable to use "noSnap" and "message" flags together"#); + } + None +} + +fn apply_validate_flag<'a>( + flags: &mut Vec<(&'a str, Value)>, + arg: &'a str, + next: Option<&str>, +) -> Option { + const EXTRA_BOOL: [&str; 4] = ["--ours", "--theirs", "--abort", "--resolve"]; + if let Some((raw_flag, val)) = arg.split_once('=') { + let flag = alias(raw_flag.trim()); + let val = val.trim(); + if EXTRA_BOOL.contains(&flag) { + let bool_val = match val { + "true" => true, + "false" => false, + _ => return None, + }; + flags.push((flag, json!(bool_val))); + return Some(0); + } + return apply_merge_inline(flags, flag, val); + } + if EXTRA_BOOL.contains(&arg) { + flags.push((arg, json!(true))); + return Some(0); + } + apply_merge_flag(flags, arg, next) +} + +/// `args` starts after the command name. +#[must_use] +pub fn parse(args: &[&str]) -> Option { + let mut flags: Vec<(&str, Value)> = Vec::with_capacity(args.len()); + let mut positionals: Vec<&str> = Vec::with_capacity(args.len()); + let mut index = 0; + while index < args.len() { + let arg = alias(args[index].trim()); + index += 1; + if arg == "--" { + consume_trailing_positionals(&args[index..], &mut positionals)?; + break; + } + if matches!( + arg, + "--no-color" + | "--no-color=true" + | "--no-color=false" + | "--color" + | "--color=true" + | "--color=false" + | "--color=always" + | "--color=never", + ) { + continue; + } + if !arg.starts_with('-') { + positionals.push(arg); + continue; + } + index += apply_merge_flag(&mut flags, arg, args.get(index).copied())?; + } + build(&flags, &positionals) +} + +fn consume_trailing_positionals<'a>( + tail: &[&'a str], + positionals: &mut Vec<&'a str>, +) -> Option<()> { + for &rest in tail { + let trimmed = rest.trim(); + (!trimmed.is_empty() && !trimmed.starts_with('-')).then_some(())?; + positionals.push(trimmed); + } + Some(()) +} + +fn apply_merge_flag<'a>( + flags: &mut Vec<(&'a str, Value)>, + arg: &'a str, + next: Option<&str>, +) -> Option { + if let Some((flag, val)) = arg.split_once('=') { + return apply_merge_inline(flags, alias(flag), val); + } + if flags + .iter() + .any(|(name, _)| *name == arg) + { + return None; + } + if BOOLEAN.contains(&arg) { + flags.push((arg, json!(true))); + return Some(0); + } + if VALUED.contains(&arg) { + let value = next + .map(str::trim) + .filter(|value| !value.is_empty() && !value.starts_with('-'))?; + flags.push((arg, json!(value))); + return Some(1); + } + None +} + +fn apply_merge_inline<'a>( + flags: &mut Vec<(&'a str, Value)>, + flag: &'a str, + val: &str, +) -> Option { + if flags + .iter() + .any(|(name, _)| *name == flag) + { + return None; + } + if BOOLEAN.contains(&flag) { + let bool_val = match val.trim() { + "true" => true, + "false" => false, + _ => return None, + }; + flags.push((flag, json!(bool_val))); + return Some(0); + } + let trimmed = val.trim(); + if VALUED.contains(&flag) && !trimmed.is_empty() && !trimmed.starts_with('-') { + flags.push((flag, json!(trimmed))); + return Some(0); + } + None +} + +#[inline] +fn alias(arg: &str) -> &str { + match arg { + "-x" => "--skip-dependency-installation", + "-r" => "--auto-merge-resolve", + "-m" => "--message", + _ => arg, + } +} + +fn build(flags: &[(&str, Value)], positionals: &[&str]) -> Option { + let get = |name: &str| { + flags + .iter() + .find(|(flag, _)| *flag == name) + .map(|(_, value)| value) + }; + let flag = |name: &str| get(name) == Some(&Value::Bool(true)); + let resolve = get("--auto-merge-resolve").and_then(Value::as_str); + let message = get("--message").and_then(Value::as_str); + let invalid = (flag("--manual") && resolve.is_some()) + || (flag("--no-snap") && message.is_some()) + || positionals.len() > 1; + if invalid { + return None; + } + let strategy = if flag("--manual") { Some("manual") } else { resolve }; + if strategy.is_some_and(|strategy| !matches!(strategy, "ours" | "theirs" | "manual")) { + return None; + } + let pattern = positionals.first().copied(); + let grammar = |pattern: &str| { + !pattern.is_empty() + && pattern.len() <= 4096 + && !pattern.chars().any(char::is_control) + && (crate::commands::diff::selection::stateful(pattern) + || pattern.chars().all(pattern_char)) + }; + if pattern.is_some_and(|pattern| !grammar(pattern)) { + return None; + } + Some(Options { + pattern: pattern.map(str::to_string), + strategy: strategy.map(str::to_string), + no_snap: flag("--no-snap"), + message: message.map(str::to_string), + verbose: flag("--verbose"), + skip_install: flag("--skip-dependency-installation"), + }) +} + +/// `[a-z0-9_./-]`. +#[inline] +fn pattern_char(character: char) -> bool { + character.is_ascii_lowercase() + || character.is_ascii_digit() + || "_./-*?!,[]{}@()+".contains(character) +} + +impl Options { + /// The `options` of `commandPrepare`: `JSON.stringify` drops the absent ones. + #[must_use] + pub fn request(&self) -> Value { + let mut options = json!({ + "all": false, + "forceOurs": false, + "forceTheirs": false, + "noSnap": self.no_snap, + "verbose": self.verbose, + "skipDependencyInstallation": true, + }); + if let Some(map) = options.as_object_mut() { + if let Some(pattern) = &self.pattern { + map.insert("pattern".to_owned(), json!(pattern)); + } + if let Some(strategy) = &self.strategy { + map.insert("strategy".to_owned(), json!(strategy)); + } + if let Some(message) = &self.message { + map.insert("message".to_owned(), json!(message)); + } + } + options + } +} + +#[cfg(test)] +mod options_tests; diff --git a/native/cli/src/commands/merge/options/options_tests.rs b/native/cli/src/commands/merge/options/options_tests.rs new file mode 100644 index 000000000000..74e6718dd53b --- /dev/null +++ b/native/cli/src/commands/merge/options/options_tests.rs @@ -0,0 +1,47 @@ +//! Unit tests for `bit merge` CLI option parsing ([`parse`]) and unsupported-flag rejection. +//! +//! Verifies `--no-snap`, `--auto-merge-resolve`, `--manual`, and `--message` flag parsing. + +use super::parse; + +/// Verifies that [`parse`] accepts the supported `bit merge` flags and positional pattern. +#[test] +fn accepts_the_native_subset() { + let options = parse(&["-x", "--no-snap"]).unwrap(); + assert!(options.no_snap && options.pattern.is_none()); + assert_eq!(parse(&["one", "-x", "-r", "theirs"]).unwrap().strategy.as_deref(), Some("theirs")); + assert_eq!(parse(&["**", "-x", "--manual"]).unwrap().pattern.as_deref(), Some("**")); + assert_eq!(parse(&["-x", "-m", "msg"]).unwrap().message.as_deref(), Some("msg")); + assert_eq!( + parse(&[ + "--auto-merge-resolve=theirs", + "--message=hello", + "--no-color", + "--no-color=false", + ]) + .unwrap() + .message + .as_deref(), + Some("hello"), + ); + assert!(!parse(&["--no-snap"]).unwrap().skip_install); + assert!(!parse(&[]).unwrap().skip_install); +} + +/// Verifies that [`parse`] rejects incompatible or unsupported `bit merge` flags. +#[test] +fn rejects_everything_else() { + assert!(parse(&["-x", "--no-snap", "-m", "msg"]).is_none()); + assert!(parse(&["-x", "--manual", "-r", "ours"]).is_none()); + assert!(parse(&["-x", "-r", "mine"]).is_none()); + assert!(parse(&["a", "b", "-x"]).is_none()); + assert!(parse(&["A", "-x"]).is_none()); + assert!(parse(&["-x", "--all"]).is_none()); + assert!(parse(&["-x", "--force-ours"]).is_none()); +} + +#[test] +fn double_dash_accepts_single_merge_pattern() { + let options = parse(&["-x", "--", "remote/one"]).unwrap(); + assert_eq!(options.pattern.as_deref(), Some("remote/one")); +} diff --git a/native/cli/src/commands/merge/report.rs b/native/cli/src/commands/merge/report.rs new file mode 100644 index 000000000000..75924178ccf5 --- /dev/null +++ b/native/cli/src/commands/merge/report.rs @@ -0,0 +1,220 @@ +//! The merge report: `mergeReport` of `@teambit/merging`. For `bit merge` +//! the report gets `MergeCmd`'s projection: components, skipped +//! components, the version, the snapshot results and `verbose`, so the +//! removed and conflict sections stay empty. `bit lane merge` gets the +//! whole result (`MergeLaneCmd`). +//! +//! Formats workspace config updates via [`policy`] ([`Mode`], [`REMOVED`]) and renders output with [`Chalk`]. + +mod policy; + +use crate::commands::{ + color::Chalk, + workspace_command::{ + format::Formatter, + results::{Report, Results, hydrate, listed}, + }, +}; +use serde_json::Value; +use std::io; + +/// `compHasBeenRemovedMsg`. +const REMOVED: &str = "component has been removed"; + +/// Presentation mode controlling whether [`format`] renders a component merge or full lane merge report. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub struct Mode { + pub verbose: bool, + /// The whole result of a lane merge. + pub lane: bool, +} + +/// Formats a merge or lane-merge engine `result` [`Value`] into a human-readable [`Report`]. +pub fn format(result: &Value, mode: Mode, chalk: Chalk) -> io::Result { + let results = hydrate(result)?; + let workspace = if mode.lane { + policy::format(result.get("workspaceConfigUpdateResult"), chalk)? + } else { + String::new() + }; + let config_conflicts = policy::conflicted(result, mode.lane)?; + let report = Merge { + results: &results, + mode, + format: Formatter::new(chalk), + workspace, + config_conflicts, + }; + let others = report.other_skipped(); + let hidden = !others.is_empty() && !mode.verbose; + let detailed = report.join(&report.skipped_detailed(&others)); + if !hidden { + return Ok(Report { data: detailed, code: 0, details: None }); + } + let hint = + report.format.details_hint(&format!("full list of {} skipped component(s)", others.len())); + Ok(Report { data: report.join(&hint), code: 0, details: Some(detailed) }) +} + +/// Hydrated merge report state combining component [`Results`], [`Mode`], and [`Formatter`]. +struct Merge<'r> { + results: &'r Results, + mode: Mode, + format: Formatter, + workspace: String, + config_conflicts: Vec, +} + +impl Merge<'_> { + /// The sections around the skipped list. + fn join(&self, skipped: &str) -> String { + Formatter::join(&[ + self.success(), + self.skipped_removed(), + skipped.to_string(), + self.removed(), + self.snaps(), + policy::conflict_section(&self.config_conflicts, &self.format), + self.workspace.clone(), + self.conflict_summary(), + self.summary(), + ]) + } + + #[inline] + fn chalk(&self) -> Chalk { + self.format.chalk + } + + /// `getSuccessOutput`. + fn success(&self) -> String { + let count = self.results.components.len(); + if count == 0 { + return String::new(); + } + let version = self.results.version + .as_deref() + .map(str::trim) + .filter(|version| !version.is_empty()) + .map_or_else(String::new, |version| { + format!(" from version {}", self.chalk().bold(version)) + }); + let title = self.format.title(&format!("successfully merged {count} components{version}")); + let changes = self.results.apply_version_report(&self.format, true, false); + if changes.is_empty() { title } else { format!("{title}\n{changes}") } + } + + /// `getSkippedRemovedOutput`. + fn skipped_removed(&self) -> String { + let items: Vec = self.results.skipped + .iter() + .filter(|(_, message)| message.trim() == REMOVED) + .map(|(id, _)| { + Formatter::item_with(&self.chalk().bold(&id.display()), &self.format.warn_symbol()) + }) + .collect(); + if items.is_empty() { + return String::new(); + } + let section = self.format.section("merge skipped - soft-removed", "", &items); + let hint = self.format.hint( + r#"use "bit recover " to restore, then re-run the merge"#, + ); + format!("{section}\n{hint}") + } + + fn other_skipped(&self) -> Vec<&(crate::commands::ids::Id, String)> { + self.results.skipped + .iter() + .filter(|(_, message)| message.trim() != REMOVED) + .collect() + } + + /// `getFailureOutputDetailed`. + fn skipped_detailed(&self, others: &[&(crate::commands::ids::Id, String)]) -> String { + let items: Vec = others + .iter() + .map(|(id, message)| { + let bold_id = self.chalk().bold(&id.display()); + let trimmed = message.trim(); + let row = + if trimmed.is_empty() { bold_id } else { format!("{bold_id} - {trimmed}") }; + self.format.item(&row) + }) + .collect(); + self.format.section("merge skipped", "", &items) + } + + /// `getSnapsOutput`. + #[inline] + fn snaps(&self) -> String { + if let Some(error) = &self.results.snap_error { + return error.section(&self.format); + } + self.results.snaps + .as_ref() + .map_or_else(String::new, |snaps| snaps.section(&self.format)) + } + + /// `getRemovedOutput(removedComponents)`. + #[inline] + fn removed(&self) -> String { + if !self.mode.lane { + return String::new(); + } + listed(&self.format, "removed components", &self.results.lists.removed) + } + + /// Whether the merge left unresolved conflicts the report shows. + #[inline] + fn with_conflicts(&self) -> bool { + self.mode.lane && !self.results.components.is_empty() && self.results.conflicts + } + + /// `getConflictSummary`. + fn conflict_summary(&self) -> String { + if !self.with_conflicts() { + return String::new(); + } + let lines = self.results.conflict_lines(self.chalk()); + if lines.is_empty() { + return String::new(); + } + let title = self.format.title(&format!( + "{} files with conflicts summary", + self.format.warn_symbol(), + )); + let hint = self.format.hint( + "merge process not completed due to the conflicts above. fix conflicts manually and then run \"bit install\".\nonce ready, snap/tag the components to complete the merge.", + ); + format!("{title}\n{}\n\n{hint}", lines.join("\n")) + } + + /// `getSummary`. + fn summary(&self) -> String { + let bold = |text: &str| self.chalk().bold(text); + let removed = if self.mode.lane { self.results.lists.removed.len() } else { 0 }; + let conflicted = + if self.with_conflicts() { self.results.conflict_lines(self.chalk()).len() } else { 0 }; + let mut conflicts = Vec::with_capacity(3); + if conflicted > 0 { + conflicts.push(format!("{conflicted} components")); + } + if self.workspace.contains("workspace.jsonc has conflicts") { + conflicts.push("workspace.jsonc file".into()); + } + if !self.config_conflicts.is_empty() { + conflicts.push("merge-conflict file".into()); + } + let conflicts = if conflicts.is_empty() { "none".into() } else { conflicts.join(", ") }; + format!( + "{}\nTotal Merged: {}\nTotal Unchanged: {}\nTotal Snapped: {}\nTotal Removed: {}\nConflicts: {}", + self.format.title("Merge Summary"), + bold(&self.results.components.len().to_string()), + bold(&self.results.skipped.len().to_string()), + bold(&self.results.snapped_count().to_string()), + bold(&removed.to_string()), + bold(&conflicts), + ) + } +} diff --git a/native/cli/src/commands/merge/report/policy.rs b/native/cli/src/commands/merge/report/policy.rs new file mode 100644 index 000000000000..fe069042516a --- /dev/null +++ b/native/cli/src/commands/merge/report/policy.rs @@ -0,0 +1,110 @@ +//! `getWorkspaceConfigUpdateOutput` for lane merges. +//! +//! [`format`] renders `workspace.jsonc` dependency policy updates ([`array`], [`object`], [`text`]) with [`Formatter`] and [`Chalk`]. +use crate::commands::{ + color::Chalk, + status::js::{array, object, text}, + workspace_command::format::Formatter, +}; +use serde_json::Value; +use std::io; + +/// Formats the `workspaceConfigUpdateResult` section of a lane merge report. +pub(super) fn format(value: Option<&Value>, chalk: Chalk) -> io::Result { + let Some(value) = value.filter(|value| !value.is_null()) else { return Ok(String::new()) }; + let formatter = Formatter::new(chalk); + let unchanged = section(value.get("workspaceDepsUnchanged"), &formatter, false)?; + let updates = section(value.get("workspaceDepsUpdates"), &formatter, true)?; + let conflicts = if value + .get("workspaceDepsConflicts") + .is_some_and(|conflicts| { + !conflicts.is_null() + && conflicts + .as_object() + .is_none_or(|map| !map.is_empty()) + && conflicts + .as_array() + .is_none_or(|arr| !arr.is_empty()) + }) { + chalk.yellow("workspace.jsonc has conflicts, please edit the file and fix them") + } else { + String::new() + }; + Ok(Formatter::join(&[unchanged, updates, conflicts])) +} + +/// Formats a single `workspaceDepsUnchanged` or `workspaceDepsUpdates` section when non-empty. +fn section(value: Option<&Value>, formatter: &Formatter, updates: bool) -> io::Result { + let Some(value) = value.filter(|value| !value.is_null()) else { return Ok(String::new()) }; + let map = object(value)?; + if map.is_empty() { + return Ok(String::new()); + } + let rows = map + .iter() + .map(|(name, value)| { + let name = name.trim(); + let value = + if updates { update_pair(value)? } else { text(Some(value))?.trim().to_owned() }; + Ok(format!(" {name}: {value}")) + }) + .collect::>>()?; + let title = if updates { + "workspace.jsonc has been updated with the following dependencies" + } else { + "workspace.jsonc was unable to update the following dependencies" + }; + Ok(format!("{}\n{}", formatter.title(title), rows.join("\n"))) +} + +#[inline] +fn update_pair(value: &Value) -> io::Result { + if let Some(from) = value + .get("from") + .or_else(|| value.get("oldVersion")) + { + let to = value + .get("to") + .or_else(|| value.get("newVersion")); + return Ok(format!("{} => {}", text(Some(from))?.trim(), text(to)?.trim())); + } + let pair = array(Some(value), "merge workspace dependency update")?; + Ok(format!("{} => {}", text(pair.first())?.trim(), text(pair.get(1))?.trim())) +} + +pub(super) fn conflicted(result: &Value, lane: bool) -> io::Result> { + if !lane { + return Ok(Vec::new()); + } + result["configMergeResults"] + .as_array() + .into_iter() + .flatten() + .filter(|row| row["hasConflicts"].as_bool() == Some(true)) + .map(|row| { + let id = text( + row.get("compIdStr") + .or_else(|| row.get("id")) + .or_else(|| row.get("componentId")), + )? + .trim(); + if id.is_empty() { + return Err(io::Error::other("merge config conflict id")); + } + Ok(id.to_owned()) + }) + .collect() +} + +#[inline] +pub(super) fn conflict_section(ids: &[String], formatter: &Formatter) -> String { + if ids.is_empty() { + return String::new(); + } + let title = formatter.title(&format!( + "{} components with config-merge conflicts", + formatter.warn_symbol(), + )); + let suggestion = formatter.hint("conflicts were found while trying to merge the config. fix them manually by editing the merge-conflict file in the workspace root.\nonce ready, snap/tag the components to complete the merge."); + format!("{title}\n{}\n{suggestion}", ids.join("\n")) +} diff --git a/native/cli/src/commands/merge/resolve.rs b/native/cli/src/commands/merge/resolve.rs new file mode 100644 index 000000000000..6341ba77cd36 --- /dev/null +++ b/native/cli/src/commands/merge/resolve.rs @@ -0,0 +1,297 @@ +//! Native `bit merge [component-pattern] --resolve`: the components in merge +//! state are snapped by the engine's `commandResolve*` requests, and reported like `MergeCmd`'s resolved +//! components. +//! +//! Resolves component [`Id`] targets with [`Startup`] autosync and formats [`Report`] output via [`Formatter`] and [`Chalk`]. + +use crate::{ + commands::{ + color::Chalk, + ids::Id, + status::{ + autosync::Startup, + js::{array, decline, object, text}, + }, + workspace_command::{ + self, format::Formatter, progress::Progress, resolve, results::Report, + }, + }, + native::Outcome, +}; +use serde_json::{Value, json}; +use std::{ + collections::HashSet, + ffi::OsString, + io::{self, Write}, + path::Path, +}; + +/// Parsed CLI options for `bit merge [component-pattern] --resolve` (`--message` and `-x`). +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct Options { + pub build: bool, + pub pattern: Option, + pub message: Option, +} + +/// `args` starts after the command name; `None` without `--resolve`. +#[must_use] +pub fn parse(args: &[&str]) -> Option { + let mut options = Options::default(); + let mut flags: Vec<&str> = Vec::with_capacity(args.len()); + let mut index = 0; + while index < args.len() { + let raw = args[index].trim(); + index += 1; + if raw == "--" { + consume_trailing_resolve(&args[index..], &mut options)?; + break; + } + if matches!( + raw, + "--no-color" + | "--no-color=true" + | "--no-color=false" + | "--color" + | "--color=true" + | "--color=false" + | "--color=always" + | "--color=never", + ) { + continue; + } + if !raw.starts_with('-') { + options.positional(raw)?; + continue; + } + index += apply_resolve_flag(&mut options, &mut flags, raw, args.get(index).copied())?; + } + flags.contains(&"--resolve").then_some(options) +} + +/// Consumes positional pattern arguments following `--` for `bit merge --resolve`. +fn consume_trailing_resolve(tail: &[&str], options: &mut Options) -> Option<()> { + for &rest in tail { + let trimmed = rest.trim(); + (!trimmed.is_empty() && !trimmed.starts_with('-')).then_some(())?; + options.positional(trimmed)?; + } + Some(()) +} + +const FLAGS: [&str; 4] = ["--message", "--resolve", "--skip-dependency-installation", "--build"]; + +fn apply_resolve_flag<'a>( + options: &mut Options, + flags: &mut Vec<&'a str>, + raw: &'a str, + next: Option<&str>, +) -> Option { + if let Some((key, val)) = raw.split_once('=') { + let canonical = resolve_alias(key.trim()); + if flags.contains(&canonical) { + return None; + } + return apply_resolve_inline(options, flags, canonical, val); + } + let flag = resolve_alias(raw); + if flags.contains(&flag) || !FLAGS.contains(&flag) { + return None; + } + flags.push(flag); + options.flag(flag, next) +} + +fn apply_resolve_inline<'a>( + options: &mut Options, + flags: &mut Vec<&'a str>, + canonical: &'a str, + val: &str, +) -> Option { + let trimmed = val.trim(); + if canonical == "--message" { + (!trimmed.is_empty() && !trimmed.starts_with('-')).then_some(())?; + flags.push(canonical); + options.message = Some(trimmed.to_owned()); + return Some(0); + } + if FLAGS.contains(&canonical) { + let enabled = match trimmed { + "true" => true, + "false" => false, + _ => return None, + }; + if enabled { + flags.push(canonical); + } + if canonical == "--build" { + options.build = enabled; + } + return Some(0); + } + None +} + +#[inline] +fn resolve_alias(arg: &str) -> &str { + match arg { + "-m" => "--message", + "-x" => "--skip-dependency-installation", + other => other, + } +} + +impl Options { + /// One flag; the number of values it consumed. + #[inline] + fn flag(&mut self, flag: &str, next: Option<&str>) -> Option { + if flag == "--build" { + self.build = true; + } + if flag != "--message" { + return Some(0); + } + let message = next + .map(str::trim) + .filter(|message| !message.is_empty() && !message.starts_with('-'))?; + self.message = Some(message.to_string()); + Some(1) + } + + /// The one pattern, without state selectors. + fn positional(&mut self, text: &str) -> Option<()> { + let text = text.trim(); + let state = text + .split(',') + .any(|part| part.trim().starts_with('$') || part.contains(" AND ")); + if self.pattern.is_some() + || text.is_empty() + || text.len() > 4096 + || text.contains('\0') + || state + { + return None; + } + self.pattern = Some(text.to_string()); + Some(()) + } +} + +pub fn run(args: &[OsString], options: &Options) -> io::Result { + let root = match workspace_command::workspace_or_fail(args, "merge")? { + Ok(root) => root, + Err(outcome) => return Ok(outcome), + }; + let argv = workspace_command::utf8_args(args).ok_or_else(|| decline("resolve arguments"))?; + let mut workflow = resolve::workflow::Workflow::default(); + workflow.build = options.build; + workflow.configure(&root, &argv[1..], resolve::Kind::Resolve)?; + let mut startup = Startup::default(); + let planned = plan_with_workflow( + &root, + options.pattern.as_deref(), + options.message.as_deref(), + &mut startup, + &workflow, + ); + let mut planned = match planned { + Ok(planned) => planned, + Err(error) if startup.committed => return failure(&root, args, &startup, error), + Err(error) => return Ok(Outcome::Declined(format!("merge --resolve: {error}"))), + }; + let report = format(&planned.plan["result"], Chalk::detect(args))?; + let progress = Progress::parse_snap(planned.plan.get("progress"))?; + io::stdout().write_all(startup.stdout.as_bytes())?; + workspace_command::execute_then(&root, args, "merge", |output| { + resolve::render_events(output, &startup)?; + if let Some(progress) = &progress { + if startup.snapshot_events.is_empty() { + progress.render_merge(output); + } else { + progress.render_graph(output); + } + } + resolve::render_build_output(output, &startup, false)?; + workspace_command::commit(&mut planned, &root)?; + crate::commands::status::cleanup_temporary(&root)?; + Ok(report) + }) +} + +#[inline] +pub(in crate::commands) fn plan_with_workflow( + root: &Path, + pattern: Option<&str>, + message: Option<&str>, + startup: &mut Startup, + workflow: &resolve::workflow::Workflow, +) -> io::Result { + let options = json!({"pattern":pattern,"message":message}); + let request = resolve::ResolveRequest { + kind: resolve::Kind::Resolve, + options, + selection: resolve::Selection::Resolve { pattern }, + }; + resolve::plan_snapshot(root, &request, startup, workflow) +} + +#[inline] +pub(in crate::commands) fn plan_continuation( + root: &Path, + pattern: Option<&str>, + message: Option<&str>, + startup: &mut Startup, + workflow: &resolve::workflow::Workflow, + environment: Option, +) -> io::Result { + let request = resolve::ResolveRequest { + kind: resolve::Kind::Resolve, + options: json!({"pattern":pattern,"message":message}), + selection: resolve::Selection::Resolve { pattern }, + }; + resolve::plan_continuation(root, &request, startup, workflow, environment) +} + +fn failure( + root: &Path, + args: &[OsString], + startup: &Startup, + error: io::Error, +) -> io::Result { + io::stdout().write_all(startup.stdout.as_bytes())?; + workspace_command::execute_then(root, args, "merge", |output| { + resolve::render_build_output(output, startup, true)?; + Err(error) + }) +} + +/// The resolved components, without versions. +fn format(result: &Value, chalk: Chalk) -> io::Result { + let map = object(result)?; + let rows = array(map.get("resolvedComponents"), "resolved components")?; + if map + .keys() + .any(|key| key != "resolvedComponents") + || rows.len() > 4096 + { + return Err(decline("native resolve report")); + } + let format = Formatter::new(chalk); + let mut seen = HashSet::with_capacity(rows.len()); + let items = rows + .iter() + .map(|row| { + let row = object(row)?; + let identity = Id::parse(text(row.get("id"))?)?.without_version(); + let item = format.item(&identity); + if row.keys().any(|key| key != "id") || !seen.insert(identity) { + return Err(decline("native resolve component")); + } + Ok(item) + }) + .collect::>>()?; + Ok(Report { data: format.section("resolved components", "", &items), code: 0, details: None }) +} + +#[cfg(test)] +mod resolve_tests; diff --git a/native/cli/src/commands/merge/resolve/resolve_tests.rs b/native/cli/src/commands/merge/resolve/resolve_tests.rs new file mode 100644 index 000000000000..45cc42c472c3 --- /dev/null +++ b/native/cli/src/commands/merge/resolve/resolve_tests.rs @@ -0,0 +1,35 @@ +//! Unit tests for `bit merge --resolve` CLI option parsing ([`parse`]) and resolved-component report formatting ([`format`]). +//! +//! Verifies `--resolve` option constraints and unversioned component ID formatting via [`Chalk`]. + +use super::{format, parse}; +use crate::commands::color::Chalk; +use serde_json::json; + +/// Verifies `bit merge --resolve` option parsing and rejection of incompatible flags. +#[test] +fn resolve_options() { + let options = parse(&["--resolve", "-x", "remote/one", "-m", "done"]).unwrap(); + assert_eq!( + (options.pattern.as_deref(), options.message.as_deref()), + (Some("remote/one"), Some("done")), + ); + assert!(parse(&["-x"]).is_none(), "a merge without --resolve"); + assert!(parse(&["--resolve", "--abort"]).is_none()); + assert!(parse(&["--resolve", "a", "b"]).is_none()); +} + +/// Verifies that [`format`] strips version suffixes from resolved component IDs. +#[test] +fn resolved_components_are_listed_without_versions() { + let result = json!({ "resolvedComponents": [{ "id": "s/a@0123456789abcdef0123456789abcdef01234567" }, { "id": "s/b@1.0.0" }] }); + let report = format(&result, Chalk::new(false)).unwrap(); + assert_eq!(report.data, "resolved components (2)\n › s/a\n › s/b"); + assert!(format(&json!({ "resolvedComponents": [], "other": 1 }), Chalk::new(false)).is_err()); +} + +#[test] +fn duplicate_resolved_component_identities_decline_formatting() { + let duplicate = json!({ "resolvedComponents": [{ "id": "s/a@1.0.0" }, { "id": "s/a@2.0.0" }] }); + assert!(format(&duplicate, Chalk::new(false)).is_err()); +} diff --git a/native/cli/src/commands/mini_status.rs b/native/cli/src/commands/mini_status.rs new file mode 100644 index 000000000000..8dc3c12de3e1 --- /dev/null +++ b/native/cli/src/commands/mini_status.rs @@ -0,0 +1,245 @@ +//! Native `bit mini-status --json` (`ms -j`): the Rust status engine of +//! `bit-object-import` decides which components are modified or new, as it +//! does for the Node.js CLI's in-process route. Workspaces the engine does not +//! own (configured extensions, global `.bitrc.jsonc`, more than 4096 +//! components) and every other flag decline to the Node.js CLI. +//! +//! Queries [`Engine`] ([`COMMAND`], [`MAX_COMPONENTS`]) with [`Inventory`] and [`Options`] to emit mini-status payloads. + +use super::{ + color::Chalk, + engine::Engine, + inventory::{Inventory, Options}, + workspace_command::{self, format::Formatter, results::Report}, +}; +use crate::{commands::Command, history::History, native::Outcome, workspace}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use std::{ + env, + ffi::OsString, + io::{self, Write}, + path::Path, + process::ExitCode, +}; + +/// Descriptor for the native `bit mini-status` (`ms`) command. +pub const COMMAND: Command = Command { name: "mini-status", aliases: &["ms"], run }; + +/// Maximum workspace component count accepted before declining to the Node.js CLI. +const MAX_COMPONENTS: usize = 4096; + +fn run(args: &[OsString]) -> io::Result { + let Some(json) = parse_json_flag(args) else { + return Ok(Outcome::Declined("only `--json` or no flags run natively".to_string())); + }; + let report = |summary: &Summary, chalk: Chalk| { + if json { + None + } else { + Some(Report { data: report(summary, chalk), code: 0, details: None }) + } + }; + print(args, report, serde_json::to_string_pretty, false) +} + +/// Parses `--json` (`-j`) and color flags for `bit mini-status`, returning `None` for any other flag. +#[inline] +fn parse_json_flag(args: &[OsString]) -> Option { + let mut json = false; + let mut iter = args.get(1..)?.iter(); + while let Some(arg) = iter.next() { + match arg.to_str()?.trim() { + "--" => return iter.next().is_none().then_some(json), + "-j" | "--json" | "--json=true" => json = true, + "--json=false" => json = false, + "--no-color" | "--no-color=true" | "--no-color=false" | "--color" | "--color=true" + | "--color=false" | "--color=always" | "--color=never" => {} + _ => return None, + } + } + Some(json) +} + +/// `bit status --quick`: `StatusCmd.reportQuick`, or with `--json` the +/// modified and new components. +pub(super) fn quick(args: &[OsString], json: bool) -> io::Result { + let report = |summary: &Summary, chalk: Chalk| { + if json { + None + } else { + Some(Report { data: quick_report(summary, chalk), code: 0, details: None }) + } + }; + print( + args, + report, + |summary| { + serde_json::to_string_pretty( + &json!({"modified": summary.modified, "newComponents": summary.new_comps}), + ) + }, + true, + ) +} + +/// Read the summary, then print the report after the loader's line, or the +/// JSON. +fn print( + args: &[OsString], + report: impl FnOnce(&Summary, Chalk) -> Option, + json: impl FnOnce(&Summary) -> serde_json::Result, + load_aspects: bool, +) -> io::Result { + let Some(root) = workspace::root(&env::current_dir()?) else { + return Ok(Outcome::Declined("not inside a workspace".to_string())); + }; + let Some(home) = home_dir() else { + return Ok(Outcome::Declined("no home directory".to_string())); + }; + let summary = match initialized_summary(&root, &home, load_aspects)? { + Ok(summary) => summary, + Err(reason) => return Ok(Outcome::Declined(reason)), + }; + if let Some(report) = report(&summary, Chalk::detect(args)) { + let is_status = args + .first() + .and_then(|cmd| cmd.to_str()) + .is_some_and(|cmd| matches!(cmd.trim(), "status" | "s")); + let command = if is_status { "status" } else { "mini-status" }; + return workspace_command::execute_with(&root, args, command, &report, |_| Ok(())); + } + let history = History::start(&root, args); + // `CommandRunner` writes `JSON.stringify(data, null, 2)` without a newline. + let mut stdout = io::stdout().lock(); + stdout.write_all(json(&summary)?.as_bytes())?; + stdout.flush()?; + history.end(true); + Ok(Outcome::Exit(ExitCode::SUCCESS)) +} + +fn initialized_summary( + root: &Path, + home: &Path, + enabled: bool, +) -> io::Result> { + let (result, startup) = summarize_with_startup(root, home, enabled); + io::stdout().write_all(startup.stdout.as_bytes())?; + match result { + Err(error) if startup.committed => Err(error), + Ok(None) if startup.committed => Err(io::Error::other( + "status workspace outside engine limits after startup publication", + )), + Ok(Some(summary)) => Ok(Ok(summary)), + Ok(None) => Ok(Err("workspace outside the engine's limits".into())), + Err(error) => Ok(Err(format!("status engine: {error}"))), + } +} + +fn summarize_with_startup( + root: &Path, + home: &Path, + enabled: bool, +) -> (io::Result>, super::status::autosync::Startup) { + let mut startup = super::status::autosync::Startup::default(); + let result = (|| { + if enabled { + let mut engine = Engine::new()?; + super::status::autosync::run(&mut engine, root, &mut startup)?; + super::status::environments::quick(root, home, &mut startup)?; + } + summarize(root, home) + })(); + (result, startup) +} + +/// `MiniStatusCmd.report` without `--show-issues`. +#[inline] +fn report(summary: &Summary, chalk: Chalk) -> String { + let section = |title: &str, ids: &[String]| { + let ids = if ids.is_empty() { "".to_string() } else { ids.join("\n") }; + format!("{}:\n{ids}", chalk.bold(title)) + }; + format!( + "{}\n\n{}", + section("modified components (files only)", &summary.modified), + section("new components", &summary.new_comps), + ) +} + +/// `StatusCmd.reportQuick`. +fn quick_report(summary: &Summary, chalk: Chalk) -> String { + let format = Formatter::new(chalk); + let section = |title: &str, ids: &[String]| { + let items: Vec = ids + .iter() + .map(|id| format.item(&chalk.cyan(id))) + .collect(); + format.section(title, "", &items) + }; + let sections = Formatter::join(&[ + section("modified components (files only)", &summary.modified), + section("new components", &summary.new_comps), + ]); + if sections.is_empty() { + return format.success_summary( + r#"no new or modified components (based on file changes only, use "bit status" for full check)"#, + ); + } + sections +} + +#[inline] +fn home_dir() -> Option { + let home = if cfg!(windows) { env::var_os("USERPROFILE") } else { env::var_os("HOME") }; + home.filter(|home| !home.to_string_lossy().trim().is_empty()) + .map(Into::into) +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct Summary { + modified: Vec, + new_comps: Vec, + comp_with_issues: Vec, +} + +#[derive(Deserialize)] +struct Plan { + count: usize, + #[serde(flatten)] + options: Options, +} + +/// Prepare, then scan and finish each component, then +/// complete. `None` when the workspace is outside the engine's limits. +fn summarize(root: &Path, home: &Path) -> io::Result> { + let mut engine = Engine::new()?; + let plan: Plan = engine.request(&json!({"kind": "statusPrepare", "root": root, "home": home}))?; + if plan.count > MAX_COMPONENTS { + return Ok(None); + } + let inventory = Inventory::new(&plan.options)?; + for _ in 0..plan.count { + let _: Value = inventory.scan(&mut engine, json!({"kind": "statusScan"}), "statusFinish")?; + } + let summary: Summary = engine.request(&json!({"kind": "statusComplete"}))?; + Ok(valid(&summary, plan.count).then_some(summary)) +} + +/// `validSummary`. +#[inline] +fn valid(summary: &Summary, count: usize) -> bool { + let ids_valid = |ids: &[String]| { + ids.len() <= count + && ids + .iter() + .all(|id| !id.trim().is_empty() && id.len() <= 2049) + }; + ids_valid(&summary.modified) + && ids_valid(&summary.new_comps) + && summary.comp_with_issues.is_empty() +} + +#[cfg(test)] +mod mini_status_tests; diff --git a/native/cli/src/commands/mini_status/mini_status_tests.rs b/native/cli/src/commands/mini_status/mini_status_tests.rs new file mode 100644 index 000000000000..6fa579d78ca0 --- /dev/null +++ b/native/cli/src/commands/mini_status/mini_status_tests.rs @@ -0,0 +1,54 @@ +//! Unit tests for `bit mini-status` ([`report`]) and `bit status --quick` ([`quick_report`]) [`Summary`] terminal formatting. +//! +//! Verifies modified/new component lists, issue counts, and [`expected_success_symbols`] with [`Chalk`]. + +use super::{Summary, quick_report, report}; +use crate::commands::{color::Chalk, test_support::expected_success_symbols}; + +/// Builds a [`Summary`] fixture with `modified` and `new_comps` component lists. +fn summary(modified: &[&str], new_comps: &[&str]) -> Summary { + Summary { + modified: modified + .iter() + .map(ToString::to_string) + .collect(), + new_comps: new_comps + .iter() + .map(ToString::to_string) + .collect(), + comp_with_issues: Vec::new(), + } +} + +/// Verifies [`report`] and [`quick_report`] formatting when modified components exist. +#[test] +fn reports_the_file_changes() { + let changed = summary(&["s/a", "s/b"], &[]); + assert_eq!( + report(&changed, Chalk::new(false)), + "modified components (files only):\ns/a\ns/b\n\nnew components:\n", + ); + assert_eq!( + quick_report(&changed, Chalk::new(false)), + "modified components (files only) (2)\n \u{203A} s/a\n \u{203A} s/b", + ); +} + +#[test] +fn reports_a_workspace_without_file_changes() { + assert_eq!( + quick_report(&summary(&[], &[]), Chalk::new(false)), + expected_success_symbols( + "\u{2714} no new or modified components (based on file changes only, use \"bit status\" for full check)" + ), + ); +} + +#[test] +fn mini_status_report_lists_new_components_when_present() { + let only_new = summary(&[], &["s/new-comp"]); + assert_eq!( + report(&only_new, Chalk::new(false)), + "modified components (files only):\n\n\nnew components:\ns/new-comp", + ); +} diff --git a/native/cli/src/commands/move_paths.rs b/native/cli/src/commands/move_paths.rs new file mode 100644 index 000000000000..a64fcbf0197c --- /dev/null +++ b/native/cli/src/commands/move_paths.rs @@ -0,0 +1,135 @@ +//! Relocate native workspace sources, relink, then publish the projected bitmap. +//! +//! Executes [`Plan`] path moves ([`COMMAND`]), updates links via [`add`], and formats a [`Report`] with [`Formatter`]. +use super::{ + Command, add, + color::Chalk, + workspace_command::{self, format::Formatter, results::Report}, +}; +use crate::native::Outcome; +use bit_object_import::component_move::Plan; +use std::{env, ffi::OsString, io}; +/// Descriptor for the native `bit move` (`mv`) command. +pub const COMMAND: Command = Command { name: "move", aliases: &["mv"], run }; +/// Entry point for `bit move`. +fn run(args: &[OsString]) -> io::Result { + let Some(argv) = workspace_command::utf8_args(args) else { + return Ok(Outcome::Declined("move arguments are not UTF-8".into())); + }; + let Some((from, to)) = parse_move_args(&argv[1..]) else { + return Ok(Outcome::Declined("outside native move grammar".into())); + }; + let cwd = env::current_dir()?; + let root = match workspace_command::workspace_or_fail(args, "move")? { + Ok(root) => root, + Err(outcome) => return Ok(outcome), + }; + run_workspace(&root, &cwd, args, from, to) +} +/// Extracts the two non-empty positional path arguments `(from, to)` for `bit move`. +#[inline] +fn parse_move_args<'a>(args: &[&'a str]) -> Option<(&'a str, &'a str)> { + let positionals = super::scope_set::collect_non_flag_tokens(args)?; + let [from, to] = positionals.as_slice() else { + return None; + }; + let from = from.trim(); + let to = to.trim(); + (!from.is_empty() && !to.is_empty()).then_some((from, to)) +} +#[inline] +fn ci_enabled() -> bool { + env::var_os("CI") + .is_some_and(|value| { + let text = value.to_string_lossy(); + let trimmed = text + .trim() + .trim_matches(['"', '\'']) + .trim(); + !trimmed.is_empty() + && !trimmed.eq_ignore_ascii_case("0") + && !trimmed.eq_ignore_ascii_case("false") + && !trimmed.eq_ignore_ascii_case("no") + && !trimmed.eq_ignore_ascii_case("off") + }) +} + +fn run_workspace( + root: &std::path::Path, + cwd: &std::path::Path, + args: &[OsString], + from: &str, + to: &str, +) -> io::Result { + let prepared = Plan::prepare(root, cwd, from, to); + if let Err(error) = &prepared + && (error.kind() != io::ErrorKind::InvalidInput || ci_enabled()) + { + return Ok(Outcome::Declined(format!("move: {error}"))); + } + let chalk = Chalk::detect(args); + let format = Formatter::new(chalk); + let mut linker = add::ProjectedLinker::prepare(root)?; + let startup_stdout = linker.take_stdout(); + if !startup_stdout.is_empty() { + print!("{startup_stdout}"); + } + workspace_command::execute_then(root, args, "move", |output| { + let plan = prepared?; + let report = Report { data: report(&plan, &format)?, code: 0, details: None }; + plan.move_sources()?; + let identities = plan.identities(); + if !identities.is_empty() { + output.status(&format!("loading {} component(s)", identities.len())); + linker.link(root, plan.bitmap(), &identities, false, chalk)?; + let link_stdout = linker.take_stdout(); + if !link_stdout.is_empty() { + print!("{link_stdout}"); + } + } + plan.commit(&workspace_command::file_id())?; + Ok(report) + }) +} + +#[inline] +fn report(plan: &Plan, format: &Formatter) -> io::Result { + let sections = plan + .changes() + .iter() + .map(|component| format_component_move_section(component, format)) + .collect::>>()?; + Ok(Formatter::join(§ions)) +} + +fn format_component_move_section( + component: &serde_json::Value, + format: &Formatter, +) -> io::Result { + let id = component["id"] + .as_str() + .map(str::trim) + .filter(|id| !id.is_empty()) + .ok_or_else(|| io::Error::other("move component identity"))?; + let changes = component["changes"].as_array().ok_or_else(|| io::Error::other("move paths"))?; + let mut lines = Vec::with_capacity(changes.len() + 1); + lines.push(format.title(&format!("moved component {id}"))); + for change in changes { + let from = change["from"] + .as_str() + .map(str::trim) + .filter(|path| !path.is_empty()) + .ok_or_else(|| io::Error::other("move source path"))?; + let to = change["to"] + .as_str() + .map(str::trim) + .filter(|path| !path.is_empty()) + .ok_or_else(|| io::Error::other("move destination path"))?; + lines.push(format.item(&format!( + "from {} to {}", + format.chalk.bold(from), + format.chalk.bold(to), + ))); + } + Ok(lines.join("\n")) +} diff --git a/native/cli/src/commands/new_cmd.rs b/native/cli/src/commands/new_cmd.rs new file mode 100644 index 000000000000..664f7926105f --- /dev/null +++ b/native/cli/src/commands/new_cmd.rs @@ -0,0 +1,238 @@ +//! Native `bit new ` (`NewCmd`). +//! +//! Validates CLI arguments, workspace non-existence, target path existence, +//! and `--load-from` preconditions natively before delegating starter template +//! execution to the Node.js CLI when needed. +//! +//! Exposes [`COMMAND`], [`Options`], and [`BUILTIN_WORKSPACE_TEMPLATES`] for top-level command dispatch. + +#[cfg(test)] +mod tests; + +use super::{ + Command, + workspace_command::{self, results::Report}, +}; +use crate::{native::Outcome, workspace}; +use std::{env, ffi::OsString, io, path::Path}; + +/// Descriptor for the native `bit new` command. +pub const COMMAND: Command = Command { name: "new", aliases: &[], run }; + +/// Parsed CLI options for `bit new `. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct Options { + pub template_name: String, + pub workspace_name: String, + pub aspect: Option, + pub load_from: Option, + pub current_dir: bool, +} + +/// Built-in workspace starter template names recognized without `--aspect`. +const BUILTIN_WORKSPACE_TEMPLATES: &[&str] = &["basic", "react", "platform", "harmony"]; + +fn run(args: &[OsString]) -> io::Result { + let Some(argv) = workspace_command::utf8_args(args) else { + return Ok(Outcome::Declined("new arguments are not UTF-8".into())); + }; + let Some(options) = parse_options(&argv[1..]) else { + return Ok(Outcome::Declined("outside native new grammar".into())); + }; + let ci_enabled = env::var_os("CI") + .is_some_and(|val| { + let text = val.to_string_lossy(); + let trimmed = text + .trim() + .trim_matches(['"', '\'']) + .trim(); + !trimmed.is_empty() + && !trimmed.eq_ignore_ascii_case("0") + && !trimmed.eq_ignore_ascii_case("false") + && !trimmed.eq_ignore_ascii_case("no") + && !trimmed.eq_ignore_ascii_case("off") + }); + if ci_enabled { + return Ok(Outcome::Declined("CI new error stack".into())); + } + let cwd = env::current_dir()?; + if let Err(err) = validate_options(&cwd, &options) { + if let Some(root) = workspace::root(&cwd) { + return workspace_command::execute_then( + &root, + args, + "new", + move |_| -> io::Result { Err(io::Error::other(err)) }, + ); + } + return workspace_command::execute_without_loader_scope( + &cwd, + args, + "new", + move |_| -> io::Result { Err(io::Error::other(err)) }, + ); + } + Ok(Outcome::Declined("new requires workspace-starter runtime".into())) +} + +#[inline] +pub(super) fn validate_options(cwd: &Path, options: &Options) -> Result<(), String> { + if workspace::root(cwd).is_some() { + return Err( + "Error: unable to generate a new workspace inside of an existing workspace".to_owned() + ); + } + if !options.current_dir && cwd.join(&options.workspace_name).exists() { + return Err(format!( + r#"unable to create a workspace at "{}", this path already exists"#, + options.workspace_name, + )); + } + validate_template_source(cwd, options) +} + +fn validate_template_source(cwd: &Path, options: &Options) -> Result<(), String> { + if let Some(load_from) = options.load_from.as_deref() { + if options.aspect.is_none() { + return Err( + "to load a template from a different workspace, please provide the aspect-id using --aspect flag" + .to_owned(), + ); + } + let load_path = cwd.join(load_from); + if workspace::root(&load_path).is_none_or(|root| root != load_path) { + return Err(format!( + r#"fatal: "{load_from}" is not a valid Bit workspace, make sure the path is correct"#, + )); + } + return Ok(()); + } + if options.aspect.is_none() + && !BUILTIN_WORKSPACE_TEMPLATES.contains(&options.template_name.as_str()) + { + return Err(format!( + "template \"{}\" was not found, please use --aspect flag to load from an env i.e --aspect teambit.react/react-env\n Learn more about component templates here: https://bit.dev/reference/generator/create-generator", + options.template_name, + )); + } + Ok(()) +} + +pub(super) fn parse_options(args: &[&str]) -> Option { + let mut positionals = Vec::with_capacity(2); + let mut aspect = None; + let mut load_from = None; + let mut current_dir = false; + let mut iter = args.iter().copied().peekable(); + while let Some(raw_token) = iter.next() { + let token = raw_token.trim(); + if token == "--" { + for item in iter { + positionals.push(take_new_val(item)?); + } + break; + } + apply_new_token( + token, + &mut iter, + &mut aspect, + &mut load_from, + &mut current_dir, + &mut positionals, + )?; + } + if positionals.len() != 2 { + return None; + } + let workspace_name = positionals.pop()?; + let template_name = positionals.pop()?; + Some(Options { template_name, workspace_name, aspect, load_from, current_dir }) +} + +fn apply_new_token<'a>( + token: &'a str, + iter: &mut std::iter::Peekable>, + aspect: &mut Option, + load_from: &mut Option, + current_dir: &mut bool, + positionals: &mut Vec, +) -> Option<()> { + match token { + "--no-color" | "--no-color=true" | "--no-color=false" | "--color" | "--color=true" + | "--color=false" | "--color=always" | "--color=never" | "--standalone" + | "--standalone=true" | "--standalone=false" | "--skip-git" | "--skip-git=true" + | "--skip-git=false" | "-s" | "--empty" | "--empty=true" | "--empty=false" | "-e" => {} + "--current-dir" | "--current-dir=true" | "-c" => *current_dir = true, + "--current-dir=false" => *current_dir = false, + "--aspect" | "-a" | "--template" | "-t" | "--env" => { + *aspect = Some(take_new_val(iter.next()?)?); + } + "--default-scope" | "-d" => { + take_new_val(iter.next()?)?; + } + "--load-from" => *load_from = Some(take_new_val(iter.next()?)?), + "--agent" => { + if iter + .peek() + .is_some_and(|next| !next.starts_with('-')) + { + iter.next(); + } + } + _ => apply_inline_new_token(token, aspect, load_from, current_dir, positionals)?, + } + Some(()) +} + +fn apply_inline_new_token( + token: &str, + aspect: &mut Option, + load_from: &mut Option, + current_dir: &mut bool, + positionals: &mut Vec, +) -> Option<()> { + if let Some(val) = token + .strip_prefix("--aspect=") + .or_else(|| token.strip_prefix("-a=")) + .or_else(|| token.strip_prefix("--template=")) + .or_else(|| token.strip_prefix("-t=")) + .or_else(|| token.strip_prefix("--env=")) + { + *aspect = Some(take_new_val(val)?); + } else if let Some(val) = token.strip_prefix("--load-from=") { + *load_from = Some(take_new_val(val)?); + } else if let Some(val) = token + .strip_prefix("--default-scope=") + .or_else(|| token.strip_prefix("-d=")) + { + take_new_val(val)?; + } else if token.starts_with("--agent=") { + } else if token.starts_with('-') { + apply_short_new_flags(token, current_dir)?; + } else { + positionals.push(take_new_val(token)?); + } + Some(()) +} + +#[inline] +fn apply_short_new_flags(token: &str, current_dir: &mut bool) -> Option<()> { + let flags = token.strip_prefix('-')?; + if flags.is_empty() || flags.starts_with('-') { + return None; + } + for ch in flags.chars() { + match ch { + 's' | 'e' => {} + 'c' => *current_dir = true, + _ => return None, + } + } + Some(()) +} + +#[inline] +fn take_new_val(val: &str) -> Option { + let trimmed = val.trim(); + (!trimmed.is_empty() && !trimmed.starts_with('-')).then(|| trimmed.to_owned()) +} diff --git a/native/cli/src/commands/new_cmd/tests.rs b/native/cli/src/commands/new_cmd/tests.rs new file mode 100644 index 000000000000..3d112e3dd880 --- /dev/null +++ b/native/cli/src/commands/new_cmd/tests.rs @@ -0,0 +1,82 @@ +//! Unit tests for `bit new` [`Options`] CLI parsing ([`parse_options`]) and workspace creation validation ([`validate_options`]). +//! +//! Verifies template, `--aspect`, `--default-scope`, `--standalone`, and `--empty` flags. + +use super::{Options, parse_options, validate_options}; +use std::fs; +use tempfile::tempdir; + +/// Verifies `bit new` positional template/workspace parsing and supported boolean flags. +#[test] +fn parses_new_arguments_and_options() { + assert_eq!( + parse_options(&[ + "react", + "my-workspace", + "--aspect", + "teambit.react/react-env", + "--default-scope", + "my-org.my-scope", + "--standalone", + "--skip-git", + "--empty", + ]), + Some(Options { + template_name: "react".to_owned(), + workspace_name: "my-workspace".to_owned(), + aspect: Some("teambit.react/react-env".to_owned()), + load_from: None, + current_dir: false, + }), + ); + assert_eq!(parse_options(&["react"]), None); + assert_eq!(parse_options(&["react", "my-workspace", "--no-standalone"]), None); + assert_eq!(parse_options(&["react", "my-workspace", "--no-skip-git"]), None); + assert_eq!(parse_options(&["react", "my-workspace", "--no-empty"]), None); + assert_eq!(parse_options(&["react", "my-workspace", "--no-current-dir"]), None); +} + +/// Verifies [`validate_options`] error reporting for existing destination paths, `--load-from`, and nested workspaces. +#[test] +fn validates_existing_workspace_existing_path_and_load_from() { + let dir = tempdir().expect("tempdir"); + let base_options = Options { + template_name: "react".to_owned(), + workspace_name: "my-ws".to_owned(), + aspect: None, + load_from: None, + current_dir: false, + }; + + assert_eq!(validate_options(dir.path(), &base_options), Ok(())); + + fs::create_dir_all(dir.path().join("my-ws")).expect("mkdir my-ws"); + let err = validate_options(dir.path(), &base_options).expect_err("expected existing path"); + assert!(err.contains(r#"unable to create a workspace at "my-ws", this path already exists"#)); + + let load_missing_aspect = Options { + workspace_name: "new-ws".to_owned(), + load_from: Some("other-ws".to_owned()), + ..base_options.clone() + }; + let err = + validate_options(dir.path(), &load_missing_aspect).expect_err("expected missing --aspect"); + assert!(err.contains("please provide the aspect-id using --aspect flag")); + + let load_invalid_ws = Options { aspect: Some("my-aspect".to_owned()), ..load_missing_aspect }; + let err = + validate_options(dir.path(), &load_invalid_ws).expect_err("expected invalid workspace"); + assert!(err.contains(r#"fatal: "other-ws" is not a valid Bit workspace"#)); + + fs::write(dir.path().join("workspace.jsonc"), "{}\n").expect("write workspace.jsonc"); + fs::write(dir.path().join(".bitmap"), "{}\n").expect("write .bitmap"); + let err = validate_options(dir.path(), &base_options).expect_err("expected inside existing ws"); + assert!(err.contains( + "Error: unable to generate a new workspace inside of an existing workspace" + ),); +} + +#[test] +fn empty_new_args_return_none() { + assert_eq!(parse_options(&[]), None); +} diff --git a/native/cli/src/commands/npmrc.rs b/native/cli/src/commands/npmrc.rs new file mode 100644 index 000000000000..5a1f8f2c8390 --- /dev/null +++ b/native/cli/src/commands/npmrc.rs @@ -0,0 +1,68 @@ +//! Native `bit npmrc` (`NpmrcCmd` in `scopes/cloud/cloud/npmrc.cmd.ts`). +//! +//! Dispatches `npmrc` subcommands ([`COMMAND`]) and records invocations in [`History`] into [`Outcome`]. + +#[cfg(test)] +mod tests; + +use super::{Command, color::Chalk, workspace_command}; +use crate::{history::History, native::Outcome, workspace}; +use std::{ + env, + ffi::OsString, + io::{self, Write}, + process::ExitCode, +}; + +/// Descriptor for the native `bit npmrc` command. +pub const COMMAND: Command = Command { name: "npmrc", aliases: &[], run }; + +/// Entry point for `bit npmrc`. +fn run(args: &[OsString]) -> io::Result { + let Some(subcommand) = parse_subcommand(args) else { + return Ok(Outcome::Declined("arguments outside the native `npmrc` grammar".to_owned())); + }; + if subcommand == "generate" { + return Ok(Outcome::Declined("npmrc generate requires canonical aspect".to_owned())); + } + let chalk = Chalk::detect(args); + let history = env::current_dir() + .ok() + .and_then(|cwd| workspace::root(&cwd)) + .map(|root| History::start(&root, args)); + let output = workspace_command::loader_output(chalk, false); + output.restart(r#"running command "npmrc"..."#); + output.stop(); + let message = unknown_subcommand_message(&subcommand, chalk); + let mut stdout = io::stdout().lock(); + writeln!(stdout, "{message}")?; + stdout.flush()?; + if let Some(history) = history { + history.end(true); + } + Ok(Outcome::Exit(ExitCode::SUCCESS)) +} + +/// Formats the red diagnostic emitted when `subcommand` is not a valid `bit npmrc` subcommand. +#[inline] +fn unknown_subcommand_message(subcommand: &str, chalk: Chalk) -> String { + chalk.red(&format!( + r#""{subcommand}" is not a subcommand of "npmrc", please run "bit npmrc --help" to list the subcommands"#, + )) +} + +#[inline] +fn parse_subcommand(args: &[OsString]) -> Option { + let tokens = args + .get(1..)? + .iter() + .map(|arg| arg.to_str()) + .collect::>>()?; + let filtered = super::scope_set::collect_non_flag_tokens(&tokens)?; + let [subcommand] = filtered.as_slice() else { + return None; + }; + (!subcommand.is_empty() && !subcommand.contains(char::is_whitespace)).then(|| { + (*subcommand).to_owned() + }) +} diff --git a/native/cli/src/commands/npmrc/tests.rs b/native/cli/src/commands/npmrc/tests.rs new file mode 100644 index 000000000000..06e9089bce57 --- /dev/null +++ b/native/cli/src/commands/npmrc/tests.rs @@ -0,0 +1,34 @@ +//! Unit tests for `bit npmrc` CLI subcommand parsing ([`parse_subcommand`]). +//! +//! Verifies positional subcommand extraction and rejection of missing or flag arguments. + +use super::parse_subcommand; +use std::ffi::OsString; + +/// Converts string slices into owned [`OsString`] CLI arguments for `npmrc` tests. +fn os_args(args: &[&str]) -> Vec { + args.iter() + .map(OsString::from) + .collect() +} + +/// Verifies that [`parse_subcommand`] extracts the positional subcommand and ignores `--no-color`. +#[test] +fn parses_subcommand() { + assert_eq!(parse_subcommand(&os_args(&["npmrc", "foo"])), Some("foo".to_owned())); + assert_eq!(parse_subcommand(&os_args(&["npmrc", "generate"])), Some("generate".to_owned())); + assert_eq!(parse_subcommand(&os_args(&["npmrc", "bar", "--no-color"])), Some("bar".to_owned())); +} + +#[test] +fn rejects_flags_or_missing_subcommand() { + assert_eq!(parse_subcommand(&os_args(&["npmrc"])), None); + assert_eq!(parse_subcommand(&os_args(&["npmrc", "--help"])), None); + assert_eq!(parse_subcommand(&os_args(&["npmrc", "-h"])), None); + assert_eq!(parse_subcommand(&os_args(&["npmrc", "a", "b"])), None); +} + +#[test] +fn empty_slice_returns_none_for_npmrc() { + assert_eq!(parse_subcommand(&[]), None); +} diff --git a/native/cli/src/commands/pack_cmd.rs b/native/cli/src/commands/pack_cmd.rs new file mode 100644 index 000000000000..ee680119061d --- /dev/null +++ b/native/cli/src/commands/pack_cmd.rs @@ -0,0 +1,216 @@ +//! Native `bit pack [scopePath]` (`scopes/pkg/pkg/pack.cmd.tsx`). +//! +//! Validates [`Options`] pack arguments ([`COMMAND`]) and creates component tarballs through [`packer`] into [`Outcome`]. + +mod packer; +#[cfg(test)] +mod tests; + +use super::{Command, workspace_command}; +use crate::{native::Outcome, workspace}; +use std::{env, ffi::OsString, io, path::Path}; + +/// Descriptor for the native `bit pack` command. +pub const COMMAND: Command = Command { name: "pack", aliases: &[], run }; + +/// Error message emitted when the target scope cannot be located. +const SCOPE_NOT_FOUND_ERR: &str = "scope not found"; + +/// Parsed command-line options for `bit pack`. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub(super) struct Options { + pub comp_id: String, + pub scope_path: Option, + pub out_dir: Option, + pub override_existing: bool, + pub keep: bool, + pub prefix: bool, + pub json: bool, +} + +fn run(args: &[OsString]) -> io::Result { + let Some(argv) = workspace_command::utf8_args(args) else { + return Ok(Outcome::Declined("pack arguments are not UTF-8".into())); + }; + let Some(options) = parse_options(&argv[1..]) else { + return Ok(Outcome::Declined("outside native pack grammar".into())); + }; + let ci_enabled = env::var_os("CI") + .is_some_and(|val| { + let text = val.to_string_lossy(); + let trimmed = text + .trim() + .trim_matches(['"', '\'']) + .trim(); + !trimmed.is_empty() + && !trimmed.eq_ignore_ascii_case("0") + && !trimmed.eq_ignore_ascii_case("false") + && !trimmed.eq_ignore_ascii_case("no") + && !trimmed.eq_ignore_ascii_case("off") + }); + if ci_enabled { + return Ok(Outcome::Declined("CI pack delegated".into())); + } + let cwd = env::current_dir()?; + if is_missing_scope(&cwd, &options) { + return missing_scope_error(&cwd, args, options.json); + } + if let Some(outcome) = packer::try_pack(&cwd, args, &options)? { + return Ok(outcome); + } + Ok(Outcome::Declined("pack execution delegated".into())) +} + +/// Emits the `scope not found` failure report for `bit pack` in plain text or `--json` mode. +pub(super) fn missing_scope_error( + host_dir: &Path, + args: &[OsString], + json: bool, +) -> io::Result { + if json { + workspace_command::execute_failure_scope( + host_dir, + args, + "pack", + true, + "", + io::Error::other(SCOPE_NOT_FOUND_ERR), + ) + } else { + workspace_command::execute_without_loader_scope(host_dir, args, "pack", |_| { + Err(io::Error::other(SCOPE_NOT_FOUND_ERR)) + }) + } +} + +#[inline] +pub(super) fn is_missing_scope(cwd: &Path, options: &Options) -> bool { + options.scope_path.is_none() + && workspace::root(cwd).is_none() + && !cwd + .ancestors() + .any(|dir| { + dir.join("scope.json").is_file() + || dir.join(".bit").is_dir() + || dir.join(".git/bit").is_dir() + }) +} + +pub(super) fn parse_options(args: &[&str]) -> Option { + let mut options = Options::default(); + let mut iter = args.iter().copied(); + while let Some(raw_arg) = iter.next() { + let arg = raw_arg.trim(); + match arg { + "--" => { + consume_trailing_pack_positions(iter, &mut options)?; + break; + } + "--no-color" | "--no-color=true" | "--no-color=false" | "--color" | "--color=true" + | "--color=false" | "--color=always" | "--color=never" => {} + "-o" | "--override" | "--override=true" => options.override_existing = true, + "--override=false" => options.override_existing = false, + "-k" | "--keep" | "--keep=true" => options.keep = true, + "--keep=false" => options.keep = false, + "-p" | "--prefix" | "--prefix=true" => options.prefix = true, + "--prefix=false" => options.prefix = false, + "-j" | "--json" | "--json=true" => options.json = true, + "--json=false" => options.json = false, + "-d" | "--out-dir" => { + let val = iter.next()?.trim(); + (!val.is_empty() && !val.starts_with('-')).then_some(())?; + options.out_dir = Some(val.to_owned()); + } + _ => apply_inline_or_positional(arg, &mut options)?, + } + } + (!options.comp_id.is_empty()).then_some(options) +} + +fn consume_trailing_pack_positions<'a>( + iter: impl Iterator, + options: &mut Options, +) -> Option<()> { + for rest in iter { + let rest = rest.trim(); + if rest.starts_with('-') || rest.is_empty() { + return None; + } + apply_inline_or_positional(rest, options)?; + } + Some(()) +} + +fn apply_inline_or_positional(arg: &str, options: &mut Options) -> Option<()> { + let arg = arg.trim(); + if let Some(val) = arg + .strip_prefix("--out-dir=") + .or_else(|| arg.strip_prefix("-d=")) + { + let val = val.trim(); + (!val.is_empty() && !val.starts_with('-')).then_some(())?; + options.out_dir = Some(val.to_owned()); + } else if !arg.starts_with('-') && !arg.is_empty() { + if options.comp_id.is_empty() { + arg.clone_into(&mut options.comp_id); + } else if options.scope_path.is_none() { + options.scope_path = Some(arg.to_owned()); + } else { + return None; + } + } else { + apply_short_pack_flags(arg, options)?; + } + Some(()) +} + +#[inline] +fn apply_short_pack_flags(arg: &str, options: &mut Options) -> Option<()> { + let flags = arg.strip_prefix('-')?; + if flags.is_empty() || flags.starts_with('-') { + return None; + } + for ch in flags.chars() { + match ch { + 'o' => options.override_existing = true, + 'k' => options.keep = true, + 'p' => options.prefix = true, + 'j' => options.json = true, + _ => return None, + } + } + Some(()) +} + +#[inline] +pub(super) fn format_pack_report( + payload: &serde_json::Value, + chalk: super::color::Chalk, +) -> String { + let warnings = join_yellow_array(&payload["warnings"], chalk); + let errors = join_yellow_array(&payload["errors"], chalk); + let id_str = payload["id"].as_str().map_or("", str::trim); + let tar_line = payload["metadata"]["tarPath"] + .as_str() + .map(str::trim) + .filter(|tar_path| !tar_path.is_empty()) + .map(|tar_path| chalk.green(&format!("tar path for component {id_str}: {tar_path}"))) + .unwrap_or_default(); + format!("{warnings}\n{errors}\n{tar_line}") +} + +#[inline] +fn join_yellow_array(arr_val: &serde_json::Value, chalk: super::color::Chalk) -> String { + arr_val + .as_array() + .map(|arr| { + arr.iter() + .filter_map(serde_json::Value::as_str) + .map(str::trim) + .filter(|msg| !msg.is_empty()) + .map(|msg| chalk.yellow(msg)) + .collect::>() + .join("\n") + }) + .unwrap_or_default() +} diff --git a/native/cli/src/commands/pack_cmd/packer.rs b/native/cli/src/commands/pack_cmd/packer.rs new file mode 100644 index 000000000000..2d63c0aa7020 --- /dev/null +++ b/native/cli/src/commands/pack_cmd/packer.rs @@ -0,0 +1,391 @@ +//! Native component tarball packer (`scopes/pkg/pkg/packer.ts`). +//! +//! Resolves component artifacts ([`Options`], [`ResolvedComponent`]) via [`resolve_component_entry`] and formats [`Report`] via [`format_pack_report`]. + +use super::{Options, format_pack_report}; +use crate::{ + commands::{ + aspect::workspace_default_scope, + color::Chalk, + debug_cat::lookup::resolve_component_entry, + workspace_command::{self, results::Report}, + }, + native::Outcome, + workspace, +}; +use bit_install::{ + policy::scope::{hex_sha1, is_hash}, + workspace::Bitmap, +}; +use bit_object_import::parse_jsonc; +use serde_json::{Value, json}; +use std::{ + ffi::OsString, + fs, + io::{self, Write}, + path::{Path, PathBuf}, + process::ExitCode, +}; +/// Resolved component metadata and source files ready to be packed into a tarball. +struct ResolvedComponent { + id_display: String, + scope_name: String, + comp_name: String, + version: String, + main_file: String, + files: Vec<(String, Vec)>, +} + +pub(super) fn try_pack( + cwd: &Path, + args: &[OsString], + options: &Options, +) -> io::Result> { + let ws_root = workspace::root(cwd); + let Some(scope_dir) = resolve_target_scope(cwd, ws_root.as_deref(), options) else { + return super::missing_scope_error(ws_root.as_deref().unwrap_or(cwd), args, options.json) + .map(Some); + }; + let Some(comp) = resolve_component(ws_root.as_deref(), &scope_dir, &options.comp_id) else { + return Ok(None); + }; + let payload = execute_pack(cwd, &comp, options)?; + let host_dir = ws_root.as_deref().unwrap_or(cwd); + if options.json { + let mut stdout = io::stdout().lock(); + stdout.write_all(serde_json::to_string_pretty(&payload)?.as_bytes())?; + stdout.flush()?; + return Ok(Some(Outcome::Exit(ExitCode::SUCCESS))); + } + let chalk = Chalk::detect(args); + let data = format_pack_report(&payload, chalk); + workspace_command::execute_without_loader_scope(host_dir, args, "pack", move |_| { + Ok(Report { data, code: 0, details: None }) + }) + .map(Some) +} + +fn resolve_target_scope(cwd: &Path, ws_root: Option<&Path>, options: &Options) -> Option { + let dir = match (&options.scope_path, ws_root) { + (Some(raw), _) => cwd.join(raw), + (None, Some(root)) => return Some(workspace::scope_path(root)), + (None, None) => cwd.to_path_buf(), + }; + if dir.join("scope.json").is_file() || dir.join("objects").is_dir() { + Some(dir) + } else { + let scope_dir = workspace::scope_path(&dir); + scope_dir.is_dir().then_some(scope_dir) + } +} + +fn resolve_component( + ws_root: Option<&Path>, + scope_dir: &Path, + raw_id: &str, +) -> Option { + let (bare_id, explicit_ver) = match raw_id.trim().rsplit_once('@') { + Some((id, ver)) => { + let (id, ver) = (id.trim(), ver.trim()); + (!id.is_empty() && !ver.is_empty()).then_some((id, Some(ver)))? + } + None => (raw_id.trim(), None), + }; + if let Some(root) = ws_root + && let Some(comp) = resolve_from_workspace(root, bare_id, explicit_ver, raw_id) + { + return Some(comp); + } + resolve_from_scope(scope_dir, bare_id, explicit_ver, raw_id) +} + +fn resolve_from_workspace( + root: &Path, + bare_id: &str, + explicit_ver: Option<&str>, + raw_id: &str, +) -> Option { + let ws_val = parse_jsonc(&fs::read(root.join("workspace.jsonc")).ok()?).ok()?; + let default_scope = workspace_default_scope(&ws_val).ok(); + let bitmap = Bitmap::load(root, default_scope.as_deref()).ok()?; + let normalized = bare_id.trim_start_matches("./").trim_end_matches('/'); + let entry = bitmap.entries + .iter() + .find(|entry| { + entry.owns_its_dir() + && (entry.id.to_string_without_version() == bare_id + || entry.id.name == bare_id + || entry.key == bare_id + || (!entry.root_dir.is_empty() + && entry.root_dir.trim_end_matches('/') == normalized)) + })?; + let scope_name = entry.id.scope + .as_deref() + .or(default_scope.as_deref()) + .map(str::trim) + .filter(|scope| !scope.is_empty()) + .unwrap_or("my-scope") + .to_owned(); + let version = explicit_ver + .or_else(|| { + entry.id.version + .as_deref() + .filter(|ver| !ver.is_empty()) + }) + .unwrap_or("0.0.1") + .to_owned(); + let comp_dir = root.join(&entry.root_dir); + let mut files = Vec::new(); + collect_dir_files(&comp_dir, &comp_dir, &mut files).ok()?; + Some(ResolvedComponent { + id_display: raw_id.to_owned(), + scope_name, + comp_name: entry.id.name.clone(), + version, + main_file: entry.main_file.clone(), + files, + }) +} + +fn collect_dir_files(base: &Path, curr: &Path, out: &mut Vec<(String, Vec)>) -> io::Result<()> { + if !curr.is_dir() { + return Ok(()); + } + for entry in fs::read_dir(curr)?.flatten() { + let name = entry.file_name(); + if name == "node_modules" || name == ".git" { + continue; + } + let path = entry.path(); + if path.is_dir() { + collect_dir_files(base, &path, out)?; + } else if path.is_file() + && let Ok(rel) = path.strip_prefix(base) + { + let rel_str = rel.to_string_lossy().replace('\\', "/"); + out.push((rel_str, fs::read(&path)?)); + } + } + Ok(()) +} + +#[inline] +fn read_typed_scope_object(scope_dir: &Path, hash: &str, expected: &str) -> Option { + let (kind, val) = bit_object_import::read_scope_object(scope_dir, hash).ok().flatten()?; + (kind == expected).then_some(val) +} + +fn resolve_from_scope( + scope_dir: &Path, + bare_id: &str, + explicit_ver: Option<&str>, + raw_id: &str, +) -> Option { + let (scope_opt, comp_name, comp_hash) = resolve_component_entry(scope_dir, bare_id)?; + let comp_val = read_typed_scope_object(scope_dir, &comp_hash, "Component")?; + let (version, ver_hash) = resolve_scope_version(&comp_val, explicit_ver)?; + let ver_val = read_typed_scope_object(scope_dir, &ver_hash, "Version")?; + let scope_name = scope_opt + .as_deref() + .or_else(|| comp_val["scope"].as_str()) + .map(str::trim) + .filter(|scope| !scope.is_empty()) + .unwrap_or("my-scope") + .to_owned(); + let main_file = ver_val["mainFile"] + .as_str() + .map(str::trim) + .filter(|main| !main.is_empty()) + .unwrap_or("index.js") + .to_owned(); + Some(ResolvedComponent { + id_display: raw_id.to_owned(), + scope_name, + comp_name, + version, + main_file, + files: load_version_files(scope_dir, &ver_val), + }) +} + +fn resolve_scope_version(comp_val: &Value, explicit_ver: Option<&str>) -> Option<(String, String)> { + if let Some(req) = + explicit_ver.filter(|ver| !ver.is_empty() && !matches!(*ver, "latest" | "head")) + { + if let Some(hash) = comp_val["versions"] + .get(req) + .and_then(Value::as_str) + .or_else(|| comp_val["orphanedVersions"].get(req).and_then(Value::as_str)) + .map(str::trim) + .filter(|hash| !hash.is_empty()) + { + return Some((req.to_owned(), hash.to_owned())); + } + if is_hash(req) { + return Some((req.to_owned(), req.to_owned())); + } + return None; + } + let head = comp_val["head"] + .as_str() + .map(str::trim) + .filter(|head| !head.is_empty())?; + let tag = crate::commands::show_cmd::remote_show::find_tag_for_hash(comp_val, head) + .unwrap_or_else(|| head.to_owned()); + Some((tag, head.to_owned())) +} + +fn load_version_files(scope_dir: &Path, ver_val: &Value) -> Vec<(String, Vec)> { + ver_val["files"] + .as_array() + .map(|arr| { + arr.iter() + .filter_map(|item| { + let rel = item["relativePath"] + .as_str() + .map(str::trim) + .filter(|rel| !rel.is_empty())?; + let hash = item["file"] + .as_str() + .map(str::trim) + .filter(|hash| !hash.is_empty())?; + let (_, bytes) = + bit_object_import::read_raw_scope_object(scope_dir, hash).ok()??; + Some((rel.to_owned(), bytes)) + }) + .collect() + }) + .unwrap_or_default() +} + +fn execute_pack(cwd: &Path, comp: &ResolvedComponent, options: &Options) -> io::Result { + if is_hash(&comp.version) { + let warn = format!( + r#""package.json at {}" contain a snap version which is not a valid semver, can't pack it"#, + cwd.display(), + ); + return Ok( + json!({ "id": comp.id_display, "warnings": [warn], "errors": [], "startTime": 1, "endTime": 2 }), + ); + } + let (tar_name, tar_path) = resolve_tar_destination(cwd, comp, options.out_dir.as_deref()); + let pkg_name = format!("@{}/{}", comp.scope_name, comp.comp_name.replace('/', ".")); + let pkg_json = json!({ "name": pkg_name, "version": comp.version, "main": comp.main_file }); + write_tarball_and_build_result(comp, &pkg_json, &tar_name, &tar_path, options.override_existing) +} + +#[inline] +fn resolve_tar_destination( + cwd: &Path, + comp: &ResolvedComponent, + out_dir: Option<&str>, +) -> (String, PathBuf) { + let tar_name = + format!("{}-{}-{}.tgz", comp.scope_name, comp.comp_name.replace('/', "-"), comp.version); + let dir_str = out_dir + .map(str::trim) + .filter(|dir| !dir.is_empty()) + .unwrap_or("."); + let tar_path = cwd.join(dir_str).join(&tar_name); + (tar_name, tar_path) +} + +fn write_tarball_and_build_result( + comp: &ResolvedComponent, + pkg_json: &Value, + tar_name: &str, + tar_path: &Path, + override_existing: bool, +) -> io::Result { + let mut warnings = Vec::new(); + let mut errors = Vec::new(); + if tar_path.exists() { + let display = tar_path.display(); + if override_existing { + warnings.push(format!(r#""{display}" already exists, override it"#)); + let _ = fs::remove_file(tar_path); + } else { + errors.push(format!(r#""{display}" already exists, use --override flag to override"#)); + } + } + let mut metadata = + json!({ "pkgJson": pkg_json, "tarPath": tar_path.to_string_lossy(), "tarName": tar_name }); + if errors.is_empty() { + if let Some(parent) = tar_path + .parent() + .filter(|dir| !dir.as_os_str().is_empty()) + { + fs::create_dir_all(parent)?; + } + let tgz_bytes = build_tgz_bytes(pkg_json, &comp.files)?; + metadata["checksum"] = Value::String(hex_sha1(&tgz_bytes)); + fs::write(tar_path, &tgz_bytes)?; + } + Ok( + json!({ "id": comp.id_display, "metadata": metadata, "warnings": warnings, "errors": errors, "startTime": 1, "endTime": 2 }), + ) +} + +pub(super) fn build_tgz_bytes( + pkg_json: &Value, + files: &[(String, Vec)], +) -> io::Result> { + let mut builder = tar::Builder::new(Vec::new()); + let pkg_bytes = serde_json::to_vec_pretty(pkg_json).map_err(io::Error::other)?; + append_tar_entry(&mut builder, "package/package.json", &pkg_bytes)?; + for (rel_path, content) in files { + if rel_path != "package.json" { + let entry_path = format!("package/{}", rel_path.trim_start_matches('/')); + append_tar_entry(&mut builder, &entry_path, content)?; + } + } + let tar_bytes = builder.into_inner()?; + Ok(gzip_stored(&tar_bytes)) +} + +#[inline] +fn append_tar_entry( + builder: &mut tar::Builder>, + path: &str, + content: &[u8], +) -> io::Result<()> { + let mut header = tar::Header::new_gnu(); + header.set_size(content.len() as u64); + header.set_mode(0o644); + header.set_cksum(); + builder.append_data(&mut header, path, content) +} + +fn gzip_stored(data: &[u8]) -> Vec { + let mut out = Vec::with_capacity(data.len() + 32); + out.extend_from_slice(&[0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff]); + let chunks: Vec<&[u8]> = + if data.is_empty() { vec![&[]] } else { data.chunks(65_535).collect() }; + for (idx, chunk) in chunks.iter().enumerate() { + let is_last = u8::from(idx + 1 == chunks.len()); + let len = u16::try_from(chunk.len()).unwrap_or(0); + let nlen = !len; + out.push(is_last); + out.extend_from_slice(&len.to_le_bytes()); + out.extend_from_slice(&nlen.to_le_bytes()); + out.extend_from_slice(chunk); + } + let crc = crc32(data); + let isize_val = u32::try_from(data.len() & 0xffff_ffff).unwrap_or(0); + out.extend_from_slice(&crc.to_le_bytes()); + out.extend_from_slice(&isize_val.to_le_bytes()); + out +} + +#[inline] +fn crc32(data: &[u8]) -> u32 { + let mut crc = 0xffff_ffffu32; + for &byte in data { + crc ^= u32::from(byte); + for _ in 0..8 { + let mask = (crc & 1).wrapping_neg(); + crc = (crc >> 1) ^ (0xedb8_8320 & mask); + } + } + !crc +} diff --git a/native/cli/src/commands/pack_cmd/tests.rs b/native/cli/src/commands/pack_cmd/tests.rs new file mode 100644 index 000000000000..ee567f94b2cb --- /dev/null +++ b/native/cli/src/commands/pack_cmd/tests.rs @@ -0,0 +1,67 @@ +//! Unit tests for `bit pack` [`Options`] CLI parsing ([`parse_options`]), missing scope checks ([`is_missing_scope`]), and `.tgz` tarball generation ([`build_tgz_bytes`]). +//! +//! Verifies `--out-dir`, `--override`, `--keep`, and `--prefix` flags and gzipped tar entries. + +use super::{Options, is_missing_scope, packer::build_tgz_bytes, parse_options}; +use flate2::read::GzDecoder; +use serde_json::json; +use std::io::Read; + +/// Verifies `bit pack` option parsing and missing scope detection on an uninitialized directory. +#[test] +fn parses_pack_options_and_detects_missing_scope() { + assert_eq!(parse_options(&[]), None); + let opts = parse_options(&["comp1", "-d", "out", "-o", "-k", "-p", "-j"]).unwrap(); + assert_eq!( + opts, + Options { + comp_id: "comp1".into(), + scope_path: None, + out_dir: Some("out".into()), + override_existing: true, + keep: true, + prefix: true, + json: true, + }, + ); + let temp = tempfile::tempdir().unwrap(); + assert!(is_missing_scope(temp.path(), &opts)); +} + +/// Verifies that [`build_tgz_bytes`] produces a gzipped tar archive with `package/package.json` and component files. +#[test] +fn builds_valid_tgz_archive() { + let pkg = json!({ + "name": "@my-scope/comp1", + "version": "0.0.1", + "main": "index.js", + }); + let files = vec![("index.js".to_owned(), b"module.exports = 42;\n".to_vec())]; + let tgz = build_tgz_bytes(&pkg, &files).unwrap(); + let mut gz = GzDecoder::new(tgz.as_slice()); + let mut tar_bytes = Vec::new(); + gz.read_to_end(&mut tar_bytes).unwrap(); + let mut archive = tar::Archive::new(tar_bytes.as_slice()); + let paths: Vec = archive + .entries() + .unwrap() + .map(|entry| { + entry + .unwrap() + .path() + .unwrap() + .to_string_lossy() + .into_owned() + }) + .collect(); + assert!(paths.contains(&"package/package.json".to_owned())); + assert!(paths.contains(&"package/index.js".to_owned())); +} + +#[test] +fn parses_optional_scope_path_and_rejects_third_positional_argument() { + let with_scope = parse_options(&["comp1", "my-scope-dir"]).unwrap(); + assert_eq!(with_scope.scope_path.as_deref(), Some("my-scope-dir")); + assert_eq!(parse_options(&["comp1", "comp2", "comp3"]), None); + assert_eq!(parse_options(&["comp1", "--unknown"]), None); +} diff --git a/native/cli/src/commands/pattern.rs b/native/cli/src/commands/pattern.rs new file mode 100644 index 000000000000..ea9db0d379b5 --- /dev/null +++ b/native/cli/src/commands/pattern.rs @@ -0,0 +1,397 @@ +//! Native `bit pattern ` (`PatternCommand` in `scopes/workspace/workspace/pattern.cmd.ts`). +//! +//! Evaluates component pattern expressions ([`COMMAND`], [`Options`]) against workspace or remote ids via [`Engine`] and [`patterns`]. + +#[cfg(test)] +mod tests; + +use super::{ + Command, + color::Chalk, + engine::Engine, + inventory, patterns, + remote_resolution::Session, + scope_set::selection::{entry_full_id, exists_in_scope_index, valid_id_chunk, valid_scope_str}, + status::cache::cache_root, + workspace_command::{self, NodeCli, format::Formatter, results::Report}, +}; +use crate::{native::Outcome, workspace}; +use bit_install::workspace::Bitmap; +use serde_json::{Value, json}; +use std::{ + env, + ffi::OsString, + fs, + io::{self, Write}, + path::Path, +}; +/// Descriptor for the `bit pattern` CLI command. +pub const COMMAND: Command = Command { name: "pattern", aliases: &[], run }; + +#[derive(Debug, Clone, PartialEq, Eq)] +struct Options { + pattern: String, + json: bool, + remote: bool, +} + +enum Failure { + Declined(String), + User(String), +} + +impl From for Failure { + #[inline] + fn from(error: io::Error) -> Self { + Self::Declined(error.to_string()) + } +} + +fn run(args: &[OsString]) -> io::Result { + let Some(options) = parse_options(args) else { + return Ok(Outcome::Declined("arguments outside the native `pattern` grammar".to_owned())); + }; + let cwd = env::current_dir()?; + let Some(root) = workspace::root(&cwd) else { + return super::lint_cmd::emit_pattern_error( + &cwd, + args, + "pattern", + options.json, + crate::commands::log::OUTSIDE_WORKSPACE_ERR.into(), + ); + }; + let Some(default_scope) = workspace_default_scope(&root) else { + return Ok(Outcome::Declined("unsupported workspace configuration".to_owned())); + }; + let mut session = Session::new(Some(&root), args); + let resolved = if options.remote { + remote_ids(&root, &options.pattern, &mut session) + } else { + local_ids(&root, &default_scope, &options.pattern) + }; + match resolved { + Ok(ids) => present(&root, args, &options, &ids, &session), + Err(Failure::User(message)) => { + if ci_enabled() { + return Ok(Outcome::Declined("CI pattern error stack".to_owned())); + } + present_error(&root, args, &options, &session, io::Error::other(message)) + } + Err(Failure::Declined(reason)) if session.owned() => { + present_error(&root, args, &options, &session, io::Error::other(reason)) + } + Err(Failure::Declined(reason)) => Ok(Outcome::Declined(format!("pattern: {reason}"))), + } +} + +#[inline] +fn ci_enabled() -> bool { + env::var_os("CI") + .is_some_and(|val| { + let text = val.to_string_lossy(); + let trimmed = text + .trim() + .trim_matches(['"', '\'']) + .trim(); + !matches!(trimmed.to_ascii_lowercase().as_str(), "" | "0" | "false" | "no" | "off") + }) +} + +fn parse_options(args: &[OsString]) -> Option { + let mut pattern = None; + let mut json = false; + let mut remote = false; + let mut iter = args + .iter() + .skip(1) + .map(|arg| arg.to_str()) + .collect::>>()? + .into_iter(); + while let Some(raw_token) = iter.next() { + match raw_token.trim() { + "--" => { + super::internalize::consume_trailing_internalize(iter, &mut pattern)?; + break; + } + "--no-color" | "--no-color=true" | "--no-color=false" | "--color" | "--color=true" + | "--color=false" | "--color=always" | "--color=never" => {} + "-j" | "--json" | "--json=true" => json = true, + "--json=false" => json = false, + "-r" | "--remote" | "--remote=true" => remote = true, + "--remote=false" => remote = false, + "-jr" | "-rj" => (json, remote) = (true, true), + flag if flag.starts_with('-') => return None, + value if pattern.is_none() && !value.is_empty() => { + pattern = Some(value.to_owned()); + } + _ => return None, + } + } + build_pattern_options(pattern?, json, remote) +} + +#[inline] +fn build_pattern_options(pattern: String, json: bool, remote: bool) -> Option { + let has_state = pattern.contains('$') || pattern.contains(':') || pattern.contains(" AND "); + let remote_missing_slash = remote + && pattern + .split(',') + .map(str::trim) + .filter(|part| !part.starts_with('!')) + .any(|part| !part.contains('/')); + if has_state && !remote_missing_slash { + return None; + } + Some(Options { pattern, json, remote }) +} + +#[inline] +fn workspace_default_scope(root: &Path) -> Option { + let bytes = fs::read(root.join("workspace.jsonc")).ok()?; + let config = bit_workspace_config::parse_jsonc(&bytes).ok()?; + super::aspect::workspace_default_scope(&config).ok() +} + +fn local_ids(root: &Path, default_scope: &str, pattern: &str) -> Result, Failure> { + let bitmap = Bitmap::load(root, Some(default_scope))?; + if bitmap.lane + .as_ref() + .is_some_and(|lane| lane.name != "main" && lane.exported) + { + let scope_dir = workspace::scope_path(root); + if !scope_dir.join("objects").is_dir() { + return Err(Failure::Declined("missing local scope objects".to_owned())); + } + } + if !is_pattern(pattern) { + return resolve_literal_id(root, &bitmap, pattern); + } + let active = bitmap.entries + .iter() + .filter(|entry| entry.owns_its_dir()) + .map(entry_full_id) + .collect::>(); + filter_pool_by_pattern(&active, pattern) +} + +#[inline] +fn is_pattern(pattern: &str) -> bool { + pattern.contains(['*', ',', '!', '$', ':']) +} + +fn resolve_literal_id(root: &Path, bitmap: &Bitmap, pattern: &str) -> Result, Failure> { + if pattern.contains('@') || pattern.starts_with("teambit.") { + return Err(Failure::Declined( + "literal id requires package or aspect resolution".to_owned(), + )); + } + if let Some(matched) = find_bitmap_literal(bitmap, pattern) { + return Ok(matched); + } + if is_known_empty_literal(root, pattern)? { + return Ok(Vec::new()); + } + Err(Failure::User(format!( + r#"error: component ID "{pattern}" is invalid, please use the following format: [scope]/"#, + ))) +} + +fn find_bitmap_literal(bitmap: &Bitmap, pattern: &str) -> Option> { + let normalized = inventory::normalize(pattern); + let directory = normalized.strip_prefix("./").unwrap_or(&normalized); + let entry = bitmap.entries + .iter() + .find(|entry| !entry.root_dir.is_empty() && entry.root_dir == directory) + .or_else(|| { + bitmap.entries + .iter() + .find(|entry| { + entry.id.to_string_without_version() == pattern || entry.id.name == pattern + }) + })?; + Some(if entry.owns_its_dir() { vec![entry_full_id(entry)] } else { Vec::new() }) +} + +#[inline] +fn is_known_empty_literal(root: &Path, pattern: &str) -> Result { + if let Some((scope, name)) = pattern.split_once('/') { + if valid_scope_str(scope) && valid_id_chunk(name) { + return Ok(true); + } + return Err(Failure::Declined("invalid scoped literal component id".to_owned())); + } + Ok(!pattern.contains('.') && exists_in_scope_index(root, pattern)) +} + +fn remote_ids(root: &Path, pattern: &str, session: &mut Session) -> Result, Failure> { + let scopes = remote_scopes_from_pattern(pattern)?; + if scopes.is_empty() { + return Ok(Vec::new()); + } + let cli = NodeCli::find()?; + let mut engine = Engine::new()?; + let mut all_ids = Vec::new(); + for scope in &scopes { + all_ids.extend(fetch_remote_scope_ids(root, scope, &cli, session, &mut engine)?); + } + filter_pool_by_pattern(&all_ids, pattern) +} + +fn remote_scopes_from_pattern(pattern: &str) -> Result, Failure> { + let mut scopes = Vec::new(); + for part in pattern + .split(',') + .map(str::trim) + .filter(|part| !part.starts_with('!')) + { + let Some((scope, _)) = part.split_once('/') else { + return Err(Failure::User(format!( + r#"when using --remote, the pattern must include the scope name followed by "/", e.g. "scope-name/**". got "{part}""#, + ))); + }; + if scope.is_empty() || scope.contains(['*', '?']) { + return Err(Failure::Declined("remote scope name contains wildcards".to_owned())); + } + if !scopes.iter().any(|seen| seen == scope) { + scopes.push(scope.to_owned()); + } + } + Ok(scopes) +} + +fn fetch_remote_scope_ids( + root: &Path, + scope: &str, + cli: &NodeCli, + session: &mut Session, + engine: &mut Engine, +) -> Result, Failure> { + session + .resolve(std::slice::from_ref(&scope.to_owned())) + .map_err(|error| { + if super::remote_resolution::is_ci_decline(&error) { + Failure::Declined(error.to_string()) + } else { + Failure::User(error.to_string()) + } + })?; + let query = json!({ + "kind": "commandRemoteInventory", + "root": root, + "mode": "list", + "scope": scope, + "namespaces": Value::Null, + "includeDeleted": false, + "hosts": session.overrides(), + "globalConfigDir": cache_root().join("config"), + "nodeExecutable": cli.node, + "bitDir": cli.bit_dir, + "bitVersion": cli.version, + }); + let result: Value = engine + .request(&query) + .map_err(|error| { + if super::engine::is_canonical(&error) { + Failure::User(error.to_string()) + } else { + Failure::Declined(error.to_string()) + } + })?; + parse_remote_component_ids(&result) +} + +fn parse_remote_component_ids(result: &Value) -> Result, Failure> { + result["components"] + .as_array() + .ok_or_else(|| Failure::Declined("remote list components".to_owned()))? + .iter() + .map(|row| { + row["id"] + .as_str() + .map(str::trim) + .filter(|id| !id.is_empty()) + .map(str::to_owned) + .ok_or_else(|| Failure::Declined("remote list component id".to_owned())) + }) + .collect() +} + +fn filter_pool_by_pattern(pool_ids: &[String], pattern: &str) -> Result, Failure> { + let mut entries = Vec::<(&str, &String)>::with_capacity(pool_ids.len()); + for id in pool_ids { + let identity = id.rsplit_once('@').map_or(id.as_str(), |(identity, _)| identity); + if let Some((_, full)) = entries + .iter_mut() + .find(|(seen, _)| *seen == identity) + { + *full = id; + } else { + entries.push((identity, id)); + } + } + let identities = entries + .iter() + .map(|(identity, _)| *identity) + .collect::>(); + let matched = patterns::extended::matching(&identities, &patterns::split(pattern))?; + Ok(matched + .into_iter() + .filter_map(|identity| { + entries + .iter() + .find_map(|(seen, full)| (*seen == identity).then(|| (*full).clone())) + }) + .collect()) +} + +fn present( + root: &Path, + args: &[OsString], + options: &Options, + ids: &[String], + session: &Session, +) -> io::Result { + if options.json { + let body = serde_json::to_string_pretty(ids)?; + return workspace_command::execute_json(root, args, "pattern", |_output| { + io::stdout() + .lock() + .write_all(session.output().as_bytes())?; + Ok(Report { data: body, code: 0, details: None }) + }); + } + let formatter = Formatter::new(Chalk::detect(args)); + let items = ids + .iter() + .map(|id| formatter.item(id)) + .collect::>(); + let title = + formatter.success_summary(&format!("found {} components matching the pattern", ids.len())); + let report = Report { data: format!("{title}\n{}", items.join("\n")), code: 0, details: None }; + workspace_command::execute_with(root, args, "pattern", &report, |_output| { + io::stdout() + .lock() + .write_all(session.output().as_bytes()) + }) +} + +fn present_error( + root: &Path, + args: &[OsString], + options: &Options, + session: &Session, + error: io::Error, +) -> io::Result { + let callback = |_output: &bit_install::output::Output| -> io::Result { + io::stdout() + .lock() + .write_all(session.output().as_bytes())?; + Err(error) + }; + if options.json { + workspace_command::execute_json(root, args, "pattern", callback) + } else { + workspace_command::execute_then(root, args, "pattern", callback) + } +} diff --git a/native/cli/src/commands/pattern/tests.rs b/native/cli/src/commands/pattern/tests.rs new file mode 100644 index 000000000000..ffb471db84bf --- /dev/null +++ b/native/cli/src/commands/pattern/tests.rs @@ -0,0 +1,49 @@ +//! Unit tests for `bit pattern` [`Options`] CLI argument parsing ([`parse_options`]) and stateful selector rejection. +//! +//! Verifies `--remote`, `--json`, and rejection of `$`-prefixed state selectors with `--remote`. + +use super::{Options, parse_options}; +use std::ffi::OsString; + +/// Parses `words` as [`OsString`] CLI arguments through [`parse_options`]. +fn parse(words: &[&str]) -> Option { + let args = words + .iter() + .map(OsString::from) + .collect::>(); + parse_options(&args) +} + +/// Verifies `bit pattern` argument parsing across local globs, `--remote`, and `-j`. +#[test] +fn parses_pattern_and_flags() { + assert_eq!( + parse(&["pattern", "**"]), + Some(Options { pattern: "**".to_owned(), json: false, remote: false }), + ); + assert_eq!( + parse(&["pattern", "scope/**", "--remote", "-j"]), + Some(Options { pattern: "scope/**".to_owned(), json: true, remote: true }), + ); + assert_eq!( + parse(&["pattern", "$modified", "--remote"]), + Some(Options { pattern: "$modified".to_owned(), json: false, remote: true }), + ); +} + +#[test] +fn declines_stateful_or_invalid_arguments() { + assert_eq!(parse(&["pattern"]), None); + assert_eq!(parse(&["pattern", "$modified"]), None); + assert_eq!(parse(&["pattern", "$env:teambit.react/react"]), None); + assert_eq!(parse(&["pattern", "**", "extra"]), None); + assert_eq!(parse(&["pattern", "**", "--help"]), None); +} + +#[test] +fn accepts_double_dash_before_pattern_argument() { + assert_eq!( + parse(&["pattern", "-j", "--", "ui/*"]), + Some(Options { pattern: "ui/*".to_owned(), json: true, remote: false }), + ); +} diff --git a/native/cli/src/commands/patterns.rs b/native/cli/src/commands/patterns.rs new file mode 100644 index 000000000000..332d60198c99 --- /dev/null +++ b/native/cli/src/commands/patterns.rs @@ -0,0 +1,132 @@ +//! Component id patterns (`component-id-patterns.ts`): `splitComponentPatterns` +//! and `matchComponentPatterns`, which runs `multimatch` 5 with `minimatch` +//! 3.1.5 over the ids. The globs `*`, `?` and `**` and `!` negation are +//! ported; classes, braces and extglobs are left to Node. +//! +//! Exposes [`split`], [`matching`], and [`extended`] for comma, version, and brace-expanded pattern matching. + +pub mod extended; + +#[cfg(test)] +mod oracle_tests; +#[cfg(test)] +mod patterns_tests; + +use super::status::js::decline; +use std::io; + +/// Splits `pattern` (`splitComponentPatterns(pattern)`) on commas, trims each part, and prepends `**` when every part is negated. +#[must_use] +#[inline] +pub fn split(pattern: &str) -> Vec { + let mut patterns: Vec = pattern + .split(',') + .map(|part| part.trim().to_string()) + .collect(); + if !patterns.is_empty() + && patterns + .iter() + .all(|part| part.starts_with('!')) + { + patterns.insert(0, "**".to_string()); + } + patterns +} + +/// `matchComponentPatterns(identities, patterns)`: the ids, in their order, +/// whose last matching pattern is not negated. A pattern's `@version` is +/// ignored. +pub fn matching<'i>(identities: &[&'i str], patterns: &[String]) -> io::Result> { + let globs = patterns + .iter() + .map(|pattern| { + let pattern = pattern + .trim() + .split('@') + .next() + .unwrap_or_default() + .trim(); + let (negated, glob) = pattern + .strip_prefix('!') + .map_or((false, pattern), |glob| (true, glob.trim())); + supported(glob)?; + Ok((negated, glob.split('/').collect::>())) + }) + .collect::>>()?; + if globs.is_empty() || identities.is_empty() { + return Ok(Vec::new()); + } + Ok(identities + .iter() + .copied() + .filter(|identity| { + let path: Vec<&str> = identity.trim().split('/').collect(); + globs + .iter() + .fold( + false, + |kept, (negated, glob)| { + if match_segments(&path, glob) { !negated } else { kept } + }, + ) + }) + .collect()) +} + +/// The ported subset of the glob grammar. +#[inline] +fn supported(glob: &str) -> io::Result<()> { + if glob.is_empty() + || glob.starts_with('#') + || glob.contains(['[', ']', '{', '}', '(', ')', '+', '!', '\\', '@', '$', ':']) + { + return Err(decline("component pattern outside the native glob grammar")); + } + Ok(()) +} + +/// `minimatch(path, glob)`: segment by segment, `**` spanning any number of +/// segments, neither matching a dot segment. +fn match_segments(path: &[&str], glob: &[&str]) -> bool { + let Some((first, rest)) = glob.split_first() else { return path.is_empty() }; + if *first == "**" { + // Zero segments, or one more segment that is not a dot segment. + return match_segments(path, rest) + || path + .split_first() + .is_some_and(|(segment, others)| { + !segment.starts_with('.') && match_segments(others, glob) + }); + } + path.split_first() + .is_some_and(|(segment, others)| { + match_segment(segment, first) && match_segments(others, rest) + }) +} + +/// One segment: `*` any run, `?` one character; a glob with either does not +/// match a dot segment unless it starts with a dot itself, and `*` alone +/// needs a non-empty segment. +#[inline] +fn match_segment(segment: &str, glob: &str) -> bool { + let magic = glob.contains(['*', '?']); + if !magic { + return segment == glob; + } + if segment.starts_with('.') && !glob.starts_with('.') || segment.is_empty() { + return false; + } + let segment: Vec = segment.chars().collect(); + let glob: Vec = glob.chars().collect(); + wildcard(&segment, &glob) +} + +#[inline] +fn wildcard(text: &[char], glob: &[char]) -> bool { + match glob.split_first() { + None => text.is_empty(), + Some(('*', rest)) => (0..=text.len()).any(|skip| wildcard(&text[skip..], rest)), + Some(('?', rest)) => !text.is_empty() && wildcard(&text[1..], rest), + Some((literal, rest)) => text.first() == Some(literal) && wildcard(&text[1..], rest), + } +} diff --git a/native/cli/src/commands/patterns/extended.rs b/native/cli/src/commands/patterns/extended.rs new file mode 100644 index 000000000000..60ea80d57874 --- /dev/null +++ b/native/cli/src/commands/patterns/extended.rs @@ -0,0 +1,66 @@ +//! Read-only `multimatch` selection with the shared native minimatch grammar. +//! Bit splits commas and `@` versions before matching, even inside globs. +//! +//! [`matching`] uses [`glob`] and [`ranges`] expansion to evaluate multi-clause component patterns. + +mod ranges; +#[cfg(test)] +mod tests; + +use bit_install::dependencies::glob; +use std::io; + +/// Filters `identities` against `patterns` using `multimatch` evaluation order (ids outer, patterns inner, last matching clause wins). +pub fn matching<'i>(identities: &[&'i str], patterns: &[String]) -> io::Result> { + let compiled = patterns + .iter() + .map(|pattern| { + let pattern = pattern + .trim() + .split('@') + .next() + .unwrap_or_default() + .trim(); + let (negated, pattern) = pattern + .strip_prefix('!') + .map_or((false, pattern), |pattern| (true, pattern.trim())); + Ok((negated, ranges::expand(pattern)?)) + }) + .collect::>>()?; + if compiled.is_empty() || identities.is_empty() { + return Ok(Vec::new()); + } + Ok(identities + .iter() + .copied() + .filter(|id| { + let trimmed_id = id.trim(); + compiled + .iter() + .fold(false, |selected, (negated, patterns)| { + if patterns.iter().any(|pattern| matches(trimmed_id, pattern)) { + !negated + } else { + selected + } + }) + }) + .collect()) +} + +/// Returns whether `identity` matches a single `pattern` clause after accounting for leading `!` negations. +#[inline] +fn matches(identity: &str, pattern: &str) -> bool { + let pattern = pattern.trim(); + if pattern.is_empty() || pattern.starts_with('#') { + return false; + } + let stripped = pattern.trim_start_matches('!'); + let negated = (pattern.len() - stripped.len()) % 2 == 1; + let positive = stripped.trim(); + if positive.is_empty() || positive.starts_with('#') { + return false; + } + let matched = glob::matches(positive, identity); + matched != negated +} diff --git a/native/cli/src/commands/patterns/extended/oracle.json b/native/cli/src/commands/patterns/extended/oracle.json new file mode 100644 index 000000000000..6252b8734d87 --- /dev/null +++ b/native/cli/src/commands/patterns/extended/oracle.json @@ -0,0 +1,264 @@ +{ + "multimatch": "5.0.0", + "minimatch": "3.1.5", + "ids": [ + "scope/ui/foo", + "scope/ui/foobar", + "scope/ui/card", + "scope/ui/comp01", + "scope/ui/comp02", + "scope/ui/comp03", + "scope/ui/a", + "scope/ui/b", + "scope/ui/c", + "scope/.hidden", + "scope/ui/x.test", + "scope/ui/y.test" + ], + "cases": [ + { + "pattern": "**", + "expected": [ + "scope/ui/foo", + "scope/ui/foobar", + "scope/ui/card", + "scope/ui/comp01", + "scope/ui/comp02", + "scope/ui/comp03", + "scope/ui/a", + "scope/ui/b", + "scope/ui/c", + "scope/ui/x.test", + "scope/ui/y.test" + ] + }, + { + "pattern": "**/[fc]*", + "expected": [ + "scope/ui/foo", + "scope/ui/foobar", + "scope/ui/card", + "scope/ui/comp01", + "scope/ui/comp02", + "scope/ui/comp03", + "scope/ui/c" + ] + }, + { + "pattern": "**/[!fc]*", + "expected": [ + "scope/ui/a", + "scope/ui/b", + "scope/ui/x.test", + "scope/ui/y.test" + ] + }, + { + "pattern": "**/[^fc]*", + "expected": [ + "scope/ui/a", + "scope/ui/b", + "scope/ui/x.test", + "scope/ui/y.test" + ] + }, + { + "pattern": "**/!(foo)*", + "expected": [ + "scope/ui/card", + "scope/ui/comp01", + "scope/ui/comp02", + "scope/ui/comp03", + "scope/ui/a", + "scope/ui/b", + "scope/ui/c", + "scope/ui/x.test", + "scope/ui/y.test" + ] + }, + { + "pattern": "**/!(foo)", + "expected": [ + "scope/ui/foobar", + "scope/ui/card", + "scope/ui/comp01", + "scope/ui/comp02", + "scope/ui/comp03", + "scope/ui/a", + "scope/ui/b", + "scope/ui/c", + "scope/ui/x.test", + "scope/ui/y.test" + ] + }, + { + "pattern": "**/!(foo|card)", + "expected": [ + "scope/ui/foobar", + "scope/ui/comp01", + "scope/ui/comp02", + "scope/ui/comp03", + "scope/ui/a", + "scope/ui/b", + "scope/ui/c", + "scope/ui/x.test", + "scope/ui/y.test" + ] + }, + { + "pattern": "**/+(foo|card)", + "expected": [ + "scope/ui/foo", + "scope/ui/card" + ] + }, + { + "pattern": "**/+(foo|card)*", + "expected": [ + "scope/ui/foo", + "scope/ui/foobar", + "scope/ui/card" + ] + }, + { + "pattern": "**/?(foo)*", + "expected": [ + "scope/ui/foo", + "scope/ui/foobar", + "scope/ui/card", + "scope/ui/comp01", + "scope/ui/comp02", + "scope/ui/comp03", + "scope/ui/a", + "scope/ui/b", + "scope/ui/c", + "scope/ui/x.test", + "scope/ui/y.test" + ] + }, + { + "pattern": "**/*(foo)*", + "expected": [ + "scope/ui/foo", + "scope/ui/foobar", + "scope/ui/card", + "scope/ui/comp01", + "scope/ui/comp02", + "scope/ui/comp03", + "scope/ui/a", + "scope/ui/b", + "scope/ui/c", + "scope/ui/x.test", + "scope/ui/y.test" + ] + }, + { + "pattern": "**/@(foo|card)", + "expected": [] + }, + { + "pattern": "**/{foo,card}", + "expected": [] + }, + { + "pattern": "**/comp{01..03}", + "expected": [ + "scope/ui/comp01", + "scope/ui/comp02", + "scope/ui/comp03" + ] + }, + { + "pattern": "**/comp{03..01..2}", + "expected": [ + "scope/ui/comp01", + "scope/ui/comp03" + ] + }, + { + "pattern": "**/{a..c}", + "expected": [ + "scope/ui/a", + "scope/ui/b", + "scope/ui/c" + ] + }, + { + "pattern": "**/{c..a..2}", + "expected": [ + "scope/ui/a", + "scope/ui/c" + ] + }, + { + "pattern": "**, !**/foo, **/foo", + "expected": [ + "scope/ui/foo", + "scope/ui/foobar", + "scope/ui/card", + "scope/ui/comp01", + "scope/ui/comp02", + "scope/ui/comp03", + "scope/ui/a", + "scope/ui/b", + "scope/ui/c", + "scope/ui/x.test", + "scope/ui/y.test" + ] + }, + { + "pattern": "!**/foo", + "expected": [ + "scope/ui/foobar", + "scope/ui/card", + "scope/ui/comp01", + "scope/ui/comp02", + "scope/ui/comp03", + "scope/ui/a", + "scope/ui/b", + "scope/ui/c", + "scope/ui/x.test", + "scope/ui/y.test" + ] + }, + { + "pattern": "!!**/foo", + "expected": [ + "scope/ui/foo" + ] + }, + { + "pattern": "**/*.test", + "expected": [ + "scope/ui/x.test", + "scope/ui/y.test" + ] + }, + { + "pattern": "**/!(*.test)", + "expected": [ + "scope/ui/foo", + "scope/ui/foobar", + "scope/ui/card", + "scope/ui/comp01", + "scope/ui/comp02", + "scope/ui/comp03", + "scope/ui/a", + "scope/ui/b", + "scope/ui/c" + ] + }, + { + "pattern": "**/!(x).test", + "expected": [ + "scope/ui/y.test" + ] + }, + { + "pattern": "**/+(!(x)).test", + "expected": [ + "scope/ui/y.test" + ] + } + ] +} diff --git a/native/cli/src/commands/patterns/extended/ranges.rs b/native/cli/src/commands/patterns/extended/ranges.rs new file mode 100644 index 000000000000..e07ef1f013bc --- /dev/null +++ b/native/cli/src/commands/patterns/extended/ranges.rs @@ -0,0 +1,173 @@ +//! `brace-expansion` numeric/alphabetic sequences, before minimatch parsing. +//! +//! [`expand`] ([`LIMIT`]) expands bounded numeric and alphabetic brace ranges or declines via [`decline`]. + +use crate::commands::status::js::decline; +use std::io; + +/// Maximum pattern length and expanded alternative count accepted before declining. +const LIMIT: usize = 4096; + +/// Expands numeric and alphabetic `{start..end}` brace sequences in `pattern` up to [`LIMIT`]. +pub(super) fn expand(pattern: &str) -> io::Result> { + if pattern.len() > LIMIT { + return Err(decline("component pattern expansion limit")); + } + let Some((open, close, range)) = sequence(pattern)? else { + return Ok(vec![pattern.to_string()]); + }; + let values = range.values()?; + let mut expanded = Vec::with_capacity(values.len()); + for value in values { + let next = format!("{}{value}{}", &pattern[..open], &pattern[close + 1..]); + expanded.extend(expand(&next)?); + if expanded.len() > LIMIT { + return Err(decline("component pattern expansion limit")); + } + } + Ok(expanded) +} + +/// Locates the rightmost unescaped `{start..end}` sequence inside `pattern`, if any. +fn sequence(pattern: &str) -> io::Result> { + for (open, _) in pattern + .char_indices() + .rev() + .filter(|(_, character)| *character == '{') + { + if pattern[..open] + .chars() + .rev() + .take_while(|character| *character == '\\') + .count() + % 2 + == 1 + { + continue; + } + let Some(close) = pattern[open..] + .find('}') + .map(|offset| open + offset) + else { + continue; + }; + let inner = &pattern[open + 1..close]; + if !inner.contains("..") { + continue; + } + let values: Vec<_> = inner.split("..").collect(); + if let Some(range) = Sequence::parse(&values)? { + return Ok(Some((open, close, range))); + } + } + Ok(None) +} + +struct Sequence { + start: i64, + end: i64, + step: i64, + width: usize, + letters: bool, +} +impl Sequence { + fn parse(parts: &[&str]) -> io::Result> { + if !(2..=3).contains(&parts.len()) { + return Ok(None); + } + let numeric = parts[..2].iter().all(|part| numeric_endpoint(part)); + let numbers = numeric + .then(|| { + parts[0] + .parse::() + .ok() + .zip(parts[1].parse::().ok()) + }) + .flatten(); + if numeric && numbers.is_none() { + return Err(decline("component pattern sequence magnitude")); + } + let letters = letter(parts[0]).zip(letter(parts[1])); + let Some((start, end)) = numbers.or(letters) else { return Ok(None) }; + let step = match parts.get(2) { + None => 1, + Some(step) if !numeric_endpoint(step) => return Ok(None), + Some(step) => step + .parse::() + .ok() + .and_then(i64::checked_abs) + .ok_or_else(|| decline("component pattern sequence step"))? + .max(1), + }; + let width = padded_width(&parts[..2], numbers.is_some()); + Ok(Some(Self { start, end, step, width, letters: numbers.is_none() })) + } + + fn values(&self) -> io::Result> { + let step = i128::from(self.step.max(1)); + let distance = (i128::from(self.end) - i128::from(self.start)).abs(); + if distance / step >= LIMIT as i128 { + return Err(decline("component pattern expansion limit")); + } + let direction = if self.end < self.start { -1 } else { 1 }; + let mut value = i128::from(self.start); + let count = usize::try_from(distance / step + 1).unwrap_or(LIMIT); + let mut output = Vec::with_capacity(count); + while if direction > 0 { + value <= i128::from(self.end) + } else { + value >= i128::from(self.end) + } { + output.push(self.format(value)?); + value += direction * step; + } + Ok(output) + } + + #[inline] + fn format(&self, value: i128) -> io::Result { + if self.letters { + let number = + u32::try_from(value).map_err(|_| decline("component pattern letter sequence"))?; + let letter = + char::from_u32(number).ok_or_else(|| decline("component pattern letter sequence"))?; + Ok(if letter == '\\' { String::new() } else { letter.to_string() }) + } else { + Ok(format!("{value:0width$}", width = self.width)) + } + } +} + +#[inline] +fn letter(value: &str) -> Option { + let mut chars = value.chars(); + let letter = chars.next()?; + (letter.is_ascii_alphabetic() && chars.next().is_none()).then_some(i64::from(u32::from(letter))) +} + +#[inline] +fn numeric_endpoint(value: &str) -> bool { + let digits = value + .strip_prefix('-') + .or_else(|| value.strip_prefix('+')) + .unwrap_or(value); + !digits.is_empty() && digits.bytes().all(|byte| byte.is_ascii_digit()) +} + +#[inline] +fn padded_width(parts: &[&str], is_numeric: bool) -> usize { + let padded = parts + .iter() + .any(|part| { + let unsigned = part.trim_start_matches(['-', '+']); + unsigned.starts_with('0') && unsigned.len() > 1 + }); + if is_numeric && padded { + parts[0] + .trim_start_matches('+') + .len() + .max(parts[1].trim_start_matches('+').len()) + } else { + 0 + } +} diff --git a/native/cli/src/commands/patterns/extended/tests.rs b/native/cli/src/commands/patterns/extended/tests.rs new file mode 100644 index 000000000000..07b755ecf726 --- /dev/null +++ b/native/cli/src/commands/patterns/extended/tests.rs @@ -0,0 +1,53 @@ +//! Unit tests for extended component pattern [`matching`], sequence braces, and `minimatch` extglobs. +//! +//! Verifies character classes, `+(...)` / `!(...)` extglobs, and numeric `{start..end}` braces. + +/// Splits `pattern` and returns the matching subset of `ids`. +fn matching<'a>(ids: &[&'a str], pattern: &str) -> Vec<&'a str> { + super::matching(ids, &crate::commands::patterns::split(pattern)).unwrap() +} + +/// Verifies character classes, `+(...)` extglobs, and comma-splitting quirks in [`matching`]. +#[test] +fn extended_patterns_keep_bits_split_and_version_quirks() { + let ids = ["scope/ui/button", "scope/ui/card", "scope/ui/button2", "scope/ui/label"]; + assert_eq!(matching(&ids, "**/[bc]*"), [&ids[0], &ids[1], &ids[2]].map(|id| *id)); + assert_eq!(matching(&ids, "**/+(button|card)"), [ids[0], ids[1]]); + assert!(matching(&ids, "**/@(button|card)").is_empty()); + assert!(matching(&ids, "**/{button,card}").is_empty()); + assert_eq!(matching(&ids, "**, !**/button, **/button"), [ids[0], ids[1], ids[2], ids[3]]); +} + +#[test] +fn sequence_braces_and_negative_extglobs_follow_minimatch() { + let ids = ["s/comp01", "s/comp02", "s/comp03", "s/foo", "s/foobar", "s/card"]; + assert_eq!(matching(&ids, "**/comp{01..03}"), ids[..3]); + assert_eq!(matching(&ids, "**/comp{03..01..2}"), [ids[0], ids[2]]); + assert_eq!(matching(&ids, "**/!(foo)*"), [ids[0], ids[1], ids[2], ids[5]]); +} + +/// Results produced by the pinned CLI's actual multimatch/minimatch modules, +/// including Bit's comma/version splitting before minimatch runs. +#[test] +fn read_only_patterns_match_released_node_oracle() { + let oracle: serde_json::Value = serde_json::from_str(include_str!("oracle.json")).unwrap(); + let ids: Vec<&str> = oracle["ids"] + .as_array() + .unwrap() + .iter() + .map(|id| id.as_str().unwrap()) + .collect(); + for case in oracle["cases"].as_array().unwrap() { + assert_eq!( + serde_json::json!(matching(&ids, case["pattern"].as_str().unwrap())), + case["expected"], + "{}", + case["pattern"], + ); + } +} + +#[test] +fn empty_id_slice_returns_empty_matches_for_extended_glob() { + assert!(matching(&[], "**/[a-z]*").is_empty()); +} diff --git a/native/cli/src/commands/patterns/oracle.txt b/native/cli/src/commands/patterns/oracle.txt new file mode 100644 index 000000000000..91e8c9e91298 --- /dev/null +++ b/native/cli/src/commands/patterns/oracle.txt @@ -0,0 +1,25 @@ +org.scope/ui/* ["org.scope/ui/button","org.scope/ui/card"] +org.scope/* ["org.scope/utils"] +org.scope/** ["org.scope/ui/button","org.scope/ui/card","org.scope/utils"] +**/utils ["org.scope/utils","other/utils"] +other/* ["other/utils"] +*/ui/c?rd ["org.scope/ui/card"] +** ["org.scope/ui/button","org.scope/ui/card","org.scope/utils","other/utils","a/b/c/d","a/bc","a/b","x.y/z-w","s/ab/cd"] +*/* ["org.scope/utils","other/utils","a/bc","a/b","x.y/z-w"] +* [] +a/** ["a/b/c/d","a/bc","a/b"] +a/*/c/* ["a/b/c/d"] +a/**/d ["a/b/c/d"] +**/b ["a/b"] +a/b* ["a/bc","a/b"] +a/? ["a/b"] +a/?? ["a/bc"] +!a/** ["org.scope/ui/button","org.scope/ui/card","org.scope/utils","other/utils","x.y/z-w","s/ab/cd"] +**, !a/** ["org.scope/ui/button","org.scope/ui/card","org.scope/utils","other/utils","x.y/z-w","s/ab/cd"] +a/**, !a/b, a/b/c/d ["a/b/c/d","a/bc"] +s/*/* ["s/ab/cd"] +*.*/** ["org.scope/ui/button","org.scope/ui/card","org.scope/utils","x.y/z-w"] +x.y/*-w ["x.y/z-w"] +**/** ["org.scope/ui/button","org.scope/ui/card","org.scope/utils","other/utils","a/b/c/d","a/bc","a/b","x.y/z-w","s/ab/cd"] +a/**/**/d ["a/b/c/d"] +org.scope/ui/**/button ["org.scope/ui/button"] diff --git a/native/cli/src/commands/patterns/oracle_tests.rs b/native/cli/src/commands/patterns/oracle_tests.rs new file mode 100644 index 000000000000..1ce7365f4349 --- /dev/null +++ b/native/cli/src/commands/patterns/oracle_tests.rs @@ -0,0 +1,37 @@ +//! Oracle tests ([`ORACLE`]) comparing component pattern [`matching`] and [`split`] against released `multimatch` / `minimatch` behavior. +//! +//! Verifies every pattern in `oracle.txt` against [`IDS`] for exact `multimatch` 5 / `minimatch` 3.1.5 parity. + +use super::{matching, split}; + +/// Canonical component ID fixture list used by the `multimatch` / `minimatch` oracle tests. +const IDS: [&str; 10] = [ + "org.scope/ui/button", + "org.scope/ui/card", + "org.scope/utils", + "other/utils", + "other/.hidden", + "a/b/c/d", + "a/bc", + "a/b", + "x.y/z-w", + "s/ab/cd", +]; + +/// Results `multimatch` 5 with `minimatch` 3.1.5 gave for these patterns. +const ORACLE: &str = include_str!("oracle.txt"); + +/// Verifies that [`matching`] and [`split`] agree with every `multimatch` case in [`ORACLE`]. +#[test] +fn matches_agree_with_multimatch() { + for line in ORACLE.lines() { + let (pattern, expected) = line.split_once('\t').unwrap(); + let expected: Vec = serde_json::from_str(expected).unwrap(); + assert_eq!(matching(&IDS, &split(pattern)).unwrap(), expected, "{pattern}"); + } +} + +#[test] +fn split_trims_comma_separated_segments() { + assert_eq!(split(" a/b , c/d "), ["a/b", "c/d"]); +} diff --git a/native/cli/src/commands/patterns/patterns_tests.rs b/native/cli/src/commands/patterns/patterns_tests.rs new file mode 100644 index 000000000000..ddaa7d0a4039 --- /dev/null +++ b/native/cli/src/commands/patterns/patterns_tests.rs @@ -0,0 +1,54 @@ +//! Unit tests for component glob [`matching`], comma-separated pattern lists ([`split`]), and negated patterns. +//! +//! Verifies [`matched`] results across [`IDS`] for `*`, `**`, `?`, version suffixes, and `!` negations. + +use super::{matching, split}; + +/// Sample component ID list used across glob and negation unit tests. +const IDS: [&str; 5] = + ["org.scope/ui/button", "org.scope/ui/card", "org.scope/utils", "other/utils", "other/.hidden"]; + +/// Splits `pattern` and returns the matching subset of [`IDS`]. +fn matched(pattern: &str) -> Vec<&'static str> { + matching(&IDS, &split(pattern)).unwrap() +} + +#[test] +fn globs_match_like_minimatch() { + assert_eq!(matched("org.scope/ui/*"), ["org.scope/ui/button", "org.scope/ui/card"]); + assert_eq!(matched("org.scope/*"), ["org.scope/utils"]); + assert_eq!( + matched("org.scope/**"), + ["org.scope/ui/button", "org.scope/ui/card", "org.scope/utils"], + ); + assert_eq!(matched("**/utils"), ["org.scope/utils", "other/utils"]); + assert_eq!(matched("other/*"), ["other/utils"], "no dot segments"); + assert_eq!(matched("*/ui/c?rd"), ["org.scope/ui/card"]); + assert_eq!(matched("org.scope/ui/button@1.0.0"), ["org.scope/ui/button"]); + assert!(matched("button").is_empty(), "no basename matching"); +} + +#[test] +fn lists_and_negations() { + assert_eq!(matched("other/utils, org.scope/utils"), ["org.scope/utils", "other/utils"]); + assert_eq!(matched("**, !org.scope/ui/**"), ["org.scope/utils", "other/utils"]); + assert_eq!( + matched("!other/**"), + ["org.scope/ui/button", "org.scope/ui/card", "org.scope/utils"], + ); + assert_eq!(matched("!org.scope/**, org.scope/utils"), ["org.scope/utils"]); +} + +#[test] +fn other_globs_are_left_to_node() { + for pattern in + ["org.scope/[ab]*", "org.scope/{ui,utils}", "org.scope/+(ui)", "#comment", r"a\*"] + { + assert!(matching(&IDS, &split(pattern)).is_err(), "{pattern}"); + } +} + +#[test] +fn empty_patterns_return_empty_matches() { + assert!(matching(&IDS, &[]).unwrap().is_empty()); +} diff --git a/native/cli/src/commands/preview_cmd.rs b/native/cli/src/commands/preview_cmd.rs new file mode 100644 index 000000000000..e03e35882f86 --- /dev/null +++ b/native/cli/src/commands/preview_cmd.rs @@ -0,0 +1,217 @@ +//! Native `bit generate-preview` and `bit serve-preview` (`scopes/preview/preview/preview.cmd.tsx`). +//! +//! Validates workspace [`selection`] and [`active_component_count`] for [`GENERATE_PREVIEW`] and [`SERVE_PREVIEW`]. + +#[cfg(test)] +mod tests; + +use super::{ + Command, + color::Chalk, + scope_set::{Failure, selection}, + script::active_component_count, + workspace_command::{self, results::Report}, +}; +use crate::{native::Outcome, workspace}; +use bit_install::{config::WorkspaceConfig, workspace::Bitmap}; +use bit_object_import::parse_jsonc; +use std::{env, ffi::OsString, fs, io, path::Path}; + +/// Descriptor for the native `bit generate-preview` command. +pub const GENERATE_PREVIEW: Command = + Command { name: "generate-preview", aliases: &[], run: run_generate }; +/// Descriptor for the native `bit serve-preview` command. +pub const SERVE_PREVIEW: Command = Command { name: "serve-preview", aliases: &[], run: run_serve }; + +/// Parsed CLI options for `bit generate-preview [component-pattern]`. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub(super) struct GenerateOptions { + pub pattern: Option, + pub name: Option, +} + +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub(super) struct ServeOptions { + pub port: Option, +} + +fn run_generate(args: &[OsString]) -> io::Result { + let Some(argv) = workspace_command::utf8_args(args) else { + return Ok(Outcome::Declined("generate-preview arguments are not UTF-8".into())); + }; + let Some(options) = parse_generate_options(&argv[1..]) else { + return Ok(Outcome::Declined("outside native generate-preview grammar".into())); + }; + let ci_enabled = env::var_os("CI") + .is_some_and(|val| { + let text = val.to_string_lossy(); + let trimmed = text + .trim() + .trim_matches(['"', '\'']) + .trim(); + !matches!(trimmed.to_ascii_lowercase().as_str(), "" | "0" | "false" | "no" | "off") + }); + if ci_enabled { + return Ok(Outcome::Declined("CI generate-preview delegated".into())); + } + let cwd = env::current_dir()?; + let Some(root) = workspace::root(&cwd) else { + let pat = options.pattern.as_deref().unwrap_or("undefined"); + let msg = format!("unable to find components by the pattern: {pat}"); + return workspace_command::execute_failure_scope( + &cwd, + args, + "generate-preview", + false, + "", + io::Error::other(msg), + ); + }; + if let Some(outcome) = try_run_workspace_generate(&root, args, &options)? { + return Ok(outcome); + } + Ok(Outcome::Declined("generate-preview execution delegated".into())) +} + +fn try_run_workspace_generate( + root: &Path, + args: &[OsString], + options: &GenerateOptions, +) -> io::Result> { + let Ok(ws_config) = WorkspaceConfig::load(root) else { + return Ok(None); + }; + if ws_config.workspace.bitmap_auto_sync == Some(true) { + return Ok(None); + } + if let Some(pat) = options.pattern.as_deref() { + let Ok(bitmap) = Bitmap::load(root, ws_config.workspace.default_scope.as_deref()) else { + return Ok(None); + }; + if let Err(Failure::User(msg)) = + selection::resolve_pattern_entries(root, &bitmap, pat, Chalk::detect(args)) + { + let outcome = + workspace_command::execute_then(root, args, "generate-preview", move |_| { + Err(io::Error::other(msg)) + })?; + return Ok(Some(outcome)); + } + return Ok(None); + } + let Ok(bitmap_bytes) = fs::read(root.join(".bitmap")) else { + return Ok(None); + }; + let Ok(bitmap_val) = parse_jsonc(&bitmap_bytes) else { + return Ok(None); + }; + if active_component_count(&bitmap_val) == 0 { + let chalk = Chalk::detect(args); + let data = chalk.green("previews generated successfully in:\n"); + let outcome = workspace_command::execute_then(root, args, "generate-preview", |_| { + Ok(Report { data, code: 0, details: None }) + })?; + return Ok(Some(outcome)); + } + Ok(None) +} + +#[inline] +fn run_serve(args: &[OsString]) -> io::Result { + let Some(argv) = workspace_command::utf8_args(args) else { + return Ok(Outcome::Declined("serve-preview arguments are not UTF-8".into())); + }; + if parse_serve_options(&argv[1..]).is_none() { + return Ok(Outcome::Declined("outside native serve-preview grammar".into())); + } + Ok(Outcome::Declined("serve-preview execution delegated".into())) +} + +pub(super) fn parse_generate_options(args: &[&str]) -> Option { + let mut options = GenerateOptions::default(); + let mut iter = args.iter().copied(); + while let Some(arg) = iter.next() { + match arg { + "--" => { + consume_trailing_preview_pattern(iter, &mut options.pattern)?; + break; + } + "--no-color" | "--no-color=true" | "--no-color=false" | "--color" | "--color=true" + | "--color=false" | "--color=always" | "--color=never" => {} + "-n" | "--name" => { + let val = iter.next()?.trim(); + (!val.is_empty() && !val.starts_with('-')).then_some(())?; + options.name = Some(val.to_owned()); + } + val if let Some(name) = val + .strip_prefix("--name=") + .or_else(|| val.strip_prefix("-n=")) => + { + let name = name.trim(); + (!name.is_empty() && !name.starts_with('-')).then_some(())?; + options.name = Some(name.to_owned()); + } + val if !val.starts_with('-') + && !val.trim().is_empty() + && options.pattern.is_none() => + { + options.pattern = Some(val.trim().to_owned()); + } + _ => return None, + } + } + Some(options) +} + +#[inline] +fn consume_trailing_preview_pattern<'a>( + iter: impl Iterator, + pattern: &mut Option, +) -> Option<()> { + for rest in iter { + let trimmed = rest.trim(); + if trimmed.is_empty() || trimmed.starts_with('-') || pattern.is_some() { + return None; + } + *pattern = Some(trimmed.to_owned()); + } + Some(()) +} + +pub(super) fn parse_serve_options(args: &[&str]) -> Option { + let mut options = ServeOptions::default(); + let mut idx = 0; + while idx < args.len() { + let arg = args[idx].trim(); + match arg { + "--" => return (idx + 1 == args.len()).then_some(options), + "--no-color" | "--no-color=true" | "--no-color=false" | "--color" | "--color=true" + | "--color=false" | "--color=always" | "--color=never" => {} + "-p" | "--port" => { + if let Some(&next) = args.get(idx + 1) + && !next.trim().starts_with('-') + { + options.port = parse_optional_port(next)?; + idx += 1; + } else { + options.port = None; + } + } + val if let Some(port) = val + .strip_prefix("--port=") + .or_else(|| val.strip_prefix("-p=")) => + { + options.port = parse_optional_port(port)?; + } + _ => return None, + } + idx += 1; + } + Some(options) +} + +#[inline] +fn parse_optional_port(port: &str) -> Option> { + let port = port.trim(); + if port.is_empty() { Some(None) } else { port.parse::().ok().map(Some) } +} diff --git a/native/cli/src/commands/preview_cmd/tests.rs b/native/cli/src/commands/preview_cmd/tests.rs new file mode 100644 index 000000000000..72ee04d6786c --- /dev/null +++ b/native/cli/src/commands/preview_cmd/tests.rs @@ -0,0 +1,23 @@ +//! Unit tests for `bit preview` [`GenerateOptions`] ([`parse_generate_options`]) and [`ServeOptions`] ([`parse_serve_options`]) CLI parsing. +//! +//! Verifies `--name` and `--port` flag parsing and default option initialization. + +use super::{GenerateOptions, ServeOptions, parse_generate_options, parse_serve_options}; + +/// Verifies `bit preview` generate and serve option parsing for `-n` and `-p`. +#[test] +fn parses_generate_and_serve_preview_options() { + assert_eq!( + parse_generate_options(&["ui/*", "-n", "compositions"]), + Some(GenerateOptions { pattern: Some("ui/*".into()), name: Some("compositions".into()) }), + ); + assert_eq!(parse_serve_options(&["-p", "3300"]), Some(ServeOptions { port: Some(3300) })); + assert_eq!(parse_serve_options(&["--port=abc"]), None); +} + +/// Verifies that empty argument slices parse to [`GenerateOptions::default`] and [`ServeOptions::default`]. +#[test] +fn empty_preview_args_parse_to_defaults() { + assert_eq!(parse_generate_options(&[]), Some(GenerateOptions::default())); + assert_eq!(parse_serve_options(&[]), Some(ServeOptions::default())); +} diff --git a/native/cli/src/commands/publish_cmd.rs b/native/cli/src/commands/publish_cmd.rs new file mode 100644 index 000000000000..a3a83db22518 --- /dev/null +++ b/native/cli/src/commands/publish_cmd.rs @@ -0,0 +1,317 @@ +//! Native `bit publish ` (`scopes/pkg/pkg/publish.cmd.ts`). +//! +//! Validates [`COMMAND`] workspace [`selection`] and [`active_component_count`] preconditions before package publication. + +#[cfg(test)] +mod tests; + +use super::{ + Command, + aspect::workspace_default_scope, + color::Chalk, + scope_set::{Failure, selection}, + script::active_component_count, + workspace_command::{self, results::Report}, +}; +use crate::{native::Outcome, workspace}; +use bit_install::{ + dependencies::default_package_name, + policy::scope::Scope, + workspace::{Bitmap, BitmapEntry}, +}; +use bit_object_import::parse_jsonc; +use serde_json::json; +use std::{ + env, + ffi::OsString, + fs, + io::{self, Write}, + path::Path, + process::ExitCode, +}; + +/// Descriptor for the native `bit publish` command. +pub const COMMAND: Command = Command { name: "publish", aliases: &[], run }; + +/// Message reported when no workspace components match the publish selection. +const NO_CANDIDATES_MSG: &str = "no component candidates found to publish"; + +/// Parsed CLI options for `bit publish `. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct Options { + pub pattern: String, + pub dry_run: bool, + pub allow_staged: bool, + pub json: bool, +} + +fn run(args: &[OsString]) -> io::Result { + let Some(argv) = workspace_command::utf8_args(args) else { + return Ok(Outcome::Declined("publish arguments are not UTF-8".into())); + }; + let Some(options) = parse_options(&argv[1..]) else { + return Ok(Outcome::Declined("outside native publish grammar".into())); + }; + let ci_enabled = env::var_os("CI") + .is_some_and(|val| { + let text = val.to_string_lossy(); + let trimmed = text + .trim() + .trim_matches(['"', '\'']) + .trim(); + !trimmed.is_empty() + && !trimmed.eq_ignore_ascii_case("0") + && !trimmed.eq_ignore_ascii_case("false") + && !trimmed.eq_ignore_ascii_case("no") + && !trimmed.eq_ignore_ascii_case("off") + }); + if ci_enabled { + return Ok(Outcome::Declined("CI publish delegated".into())); + } + let cwd = env::current_dir()?; + let Some(root) = workspace::root(&cwd) else { + return run_outside_workspace(&options); + }; + if is_empty_workspace(&root).unwrap_or(false) { + let is_json = options.json; + return workspace_command::execute_without_loader(&root, args, "publish", move |_| { + Ok(Report { + data: if is_json { "[]".to_owned() } else { NO_CANDIDATES_MSG.to_owned() }, + code: 0, + details: None, + }) + }); + } + execute_workspace_publish(&root, args, &options) +} + +#[inline] +fn run_outside_workspace(options: &Options) -> io::Result { + let text = if options.json { "[]" } else { NO_CANDIDATES_MSG }; + let mut stdout = io::stdout().lock(); + writeln!(stdout, "{text}")?; + stdout.flush()?; + Ok(Outcome::Exit(ExitCode::SUCCESS)) +} + +fn execute_workspace_publish( + root: &Path, + args: &[OsString], + options: &Options, +) -> io::Result { + let ws_val = parse_jsonc(&fs::read(root.join("workspace.jsonc"))?)?; + let default_scope = workspace_default_scope(&ws_val).ok(); + let bitmap = Bitmap::load(root, default_scope.as_deref())?; + let matched = match selection::resolve_pattern_entries( + root, + &bitmap, + &options.pattern, + Chalk::detect(args), + ) { + Ok(matched) => matched, + Err(Failure::User(err)) if !options.dry_run => { + return super::lint_cmd::emit_pattern_error(root, args, "publish", options.json, err); + } + Err(_) => Vec::new(), + }; + if matched.is_empty() { + return empty_candidates_outcome(root, args, options.json); + } + if let Some(outcome) = check_unexported_error(root, args, options, &matched)? { + return Ok(outcome); + } + if options.dry_run { + let data = format_dry_run_report(&matched, options.json); + return workspace_command::execute_without_loader(root, args, "publish", move |_| { + Ok(Report { data, code: 0, details: None }) + }); + } + Ok(Outcome::Declined("publish execution delegated".into())) +} + +#[inline] +fn empty_candidates_outcome(root: &Path, args: &[OsString], is_json: bool) -> io::Result { + workspace_command::execute_without_loader(root, args, "publish", move |_| { + Ok(Report { + data: if is_json { "[]".to_owned() } else { NO_CANDIDATES_MSG.to_owned() }, + code: 0, + details: None, + }) + }) +} + +fn check_unexported_error( + root: &Path, + args: &[OsString], + options: &Options, + matched: &[&BitmapEntry], +) -> io::Result> { + if options.dry_run { + return Ok(None); + } + if !options.allow_staged { + let unexported = collect_unexported_ids(root, matched); + if !unexported.is_empty() { + let msg = format!( + "unable to publish the following component(s), please make sure they are exported: {}", + unexported.join(", "), + ); + return super::lint_cmd::emit_pattern_error(root, args, "publish", options.json, msg) + .map(Some); + } + } + let untagged = collect_untagged_ids(root, matched); + if !untagged.is_empty() { + let msg = format!( + "unable to publish the following component(s), please make sure they are tagged: {}", + untagged.join(", "), + ); + return super::lint_cmd::emit_pattern_error(root, args, "publish", options.json, msg) + .map(Some); + } + Ok(None) +} + +#[inline] +pub(super) fn collect_unexported_ids(root: &Path, entries: &[&BitmapEntry]) -> Vec { + let scope = Scope::find(root); + entries + .iter() + .filter(|entry| !is_entry_exported(scope.as_ref(), entry)) + .map(|entry| selection::entry_full_id(entry)) + .collect() +} + +#[inline] +pub(super) fn collect_untagged_ids(root: &Path, entries: &[&BitmapEntry]) -> Vec { + let scope = Scope::find(root); + entries + .iter() + .filter(|entry| !is_entry_in_scope(scope.as_ref(), entry)) + .map(|entry| selection::entry_full_id(entry)) + .collect() +} + +fn is_entry_in_scope(scope: Option<&Scope>, entry: &BitmapEntry) -> bool { + let Some(ver) = entry.id.version + .as_deref() + .map(str::trim) + .filter(|ver| !ver.is_empty()) + else { + return false; + }; + scope + .and_then(|sc| { + sc.model(&entry.id.to_string_without_version()) + .ok() + .flatten() + }) + .is_some_and(|model| { + model["versions"].get(ver).is_some() || model["orphanedVersions"].get(ver).is_some() + }) +} + +fn is_entry_exported(scope: Option<&Scope>, entry: &BitmapEntry) -> bool { + let Some(ver) = entry.id.version + .as_deref() + .map(str::trim) + .filter(|ver| !ver.is_empty()) + else { + return false; + }; + let Some(scope_ref) = scope else { + return false; + }; + let Some(model) = scope_ref + .model(&entry.id.to_string_without_version()) + .ok() + .flatten() + else { + return false; + }; + model["scope"] + .as_str() + .is_some_and(|sc| !sc.trim().is_empty()) + && (model["versions"].get(ver).is_some() || model["orphanedVersions"].get(ver).is_some()) + && model["remotes"] + .as_array() + .is_some_and(|remotes| !remotes.is_empty()) +} + +#[inline] +fn entry_ver(entry: &BitmapEntry) -> &str { + entry.id.version + .as_deref() + .map(str::trim) + .filter(|ver| !ver.is_empty()) + .unwrap_or("0.0.1") +} + +fn format_dry_run_report(entries: &[&BitmapEntry], is_json: bool) -> String { + if is_json { + let items: Vec<_> = entries + .iter() + .map(|entry| { + let pkg = default_package_name(&entry.id); + let ver = entry_ver(entry); + json!({ + "id": selection::entry_full_id(entry), + "data": format!("{pkg}@{ver}"), + }) + }) + .collect(); + return serde_json::to_string_pretty(&items).unwrap_or_else(|_| "[]".to_owned()); + } + entries + .iter() + .map(|entry| { + let pkg = default_package_name(&entry.id); + let ver = entry_ver(entry); + format!("publishing {pkg}@{ver} (dry-run)") + }) + .collect::>() + .join("\n") +} + +#[inline] +pub(super) fn is_empty_workspace(root: &Path) -> io::Result { + let ws_config = parse_jsonc(&fs::read(root.join("workspace.jsonc"))?)?; + if super::build_cmd::is_bitmap_auto_sync(&ws_config) { + return Ok(false); + } + let bitmap = parse_jsonc(&fs::read(root.join(".bitmap"))?)?; + Ok(active_component_count(&bitmap) == 0) +} + +pub(super) fn parse_options(args: &[&str]) -> Option { + let mut pattern = None; + let mut dry_run = false; + let mut allow_staged = false; + let mut json = false; + let mut iter = args.iter().copied(); + while let Some(arg) = iter.next() { + match arg { + "--" => { + super::internalize::consume_trailing_internalize(iter, &mut pattern)?; + break; + } + "--no-color" | "--no-color=true" | "--no-color=false" | "--color" | "--color=true" + | "--color=false" | "--color=always" | "--color=never" => {} + "-d" | "--dry-run" | "--dry-run=true" => dry_run = true, + "--dry-run=false" => dry_run = false, + "--allow-staged" | "--allow-staged=true" => allow_staged = true, + "--allow-staged=false" => allow_staged = false, + "-j" | "--json" | "--json=true" => json = true, + "--json=false" => json = false, + "-dj" | "-jd" => { + dry_run = true; + json = true; + } + val if !val.trim().starts_with('-') && !val.trim().is_empty() && pattern.is_none() => { + pattern = Some(val.trim().to_owned()); + } + _ => return None, + } + } + Some(Options { pattern: pattern?, dry_run, allow_staged, json }) +} diff --git a/native/cli/src/commands/publish_cmd/tests.rs b/native/cli/src/commands/publish_cmd/tests.rs new file mode 100644 index 000000000000..44d4a7b7eb91 --- /dev/null +++ b/native/cli/src/commands/publish_cmd/tests.rs @@ -0,0 +1,45 @@ +//! Unit tests for `bit publish` [`Options`] CLI parsing ([`parse_options`]), [`is_empty_workspace`], and unexported/untagged checks ([`collect_unexported_ids`], [`collect_untagged_ids`]). +//! +//! Verifies `--dry-run`, `--allow-staged`, and `--json` flags alongside [`Bitmap`] inspection. + +use super::{ + Options, collect_unexported_ids, collect_untagged_ids, is_empty_workspace, parse_options, +}; +use bit_install::workspace::Bitmap; +use std::fs; + +/// Verifies `bit publish` option parsing, empty workspace detection, and unexported ID collection. +#[test] +fn parses_publish_options_and_detects_empty_workspace() { + assert_eq!(parse_options(&[]), None); + assert_eq!( + parse_options(&["comp1", "-d", "--allow-staged", "-j"]), + Some(Options { pattern: "comp1".into(), dry_run: true, allow_staged: true, json: true }), + ); + + let temp = tempfile::tempdir().unwrap(); + fs::write( + temp.path().join("workspace.jsonc"), + r#"{ "teambit.workspace/workspace": { "defaultScope": "my-scope" } }"#, + ) + .unwrap(); + fs::write(temp.path().join(".bitmap"), "{}").unwrap(); + assert!(is_empty_workspace(temp.path()).unwrap()); + + fs::write( + temp.path().join(".bitmap"), + r#"{"comp1":{"scope":"","version":"","mainFile":"index.js","rootDir":"comp1"}}"#, + ) + .unwrap(); + let bitmap = Bitmap::load(temp.path(), Some("my-scope")).unwrap(); + let entries = bitmap.entries.iter().collect::>(); + assert_eq!(collect_unexported_ids(temp.path(), &entries), vec!["my-scope/comp1".to_owned()]); + assert_eq!(collect_untagged_ids(temp.path(), &entries), vec!["my-scope/comp1".to_owned()]); +} + +/// Verifies that [`parse_options`] rejects multiple positional patterns or unknown flags. +#[test] +fn rejects_multiple_patterns_or_unknown_publish_flags() { + assert_eq!(parse_options(&["comp1", "comp2"]), None); + assert_eq!(parse_options(&["comp1", "--unknown"]), None); +} diff --git a/native/cli/src/commands/recover.rs b/native/cli/src/commands/recover.rs new file mode 100644 index 000000000000..9dbf35b9937c --- /dev/null +++ b/native/cli/src/commands/recover.rs @@ -0,0 +1,62 @@ +//! Restore removed components through one retained import and bitmap publication owner. +//! +//! Coordinates [`COMMAND`], [`options`], [`execution`], [`Batch`], and [`ProjectedLinker`] to produce a [`Report`]. +mod execution; +mod options; + +use super::{ + add::ProjectedLinker, + color::Chalk, + workspace_command::{self, format::Formatter, results::Report}, +}; +use crate::{commands::Command, native::Outcome}; +use bit_object_import::component_recover::Batch; +use std::{ffi::OsString, io}; + +/// Descriptor for the native `bit recover` command. +pub const COMMAND: Command = Command { name: "recover", aliases: &[], run }; +/// Entry point for `bit recover`. +fn run(args: &[OsString]) -> io::Result { + let Some(argv) = workspace_command::utf8_args(args) else { + return Ok(Outcome::Declined("arguments are not UTF-8".into())); + }; + let Some(options) = options::parse(&argv[1..]) else { + return Ok(Outcome::Declined("outside the native recover grammar".into())); + }; + let root = match workspace_command::workspace_or_fail(args, "recover")? { + Ok(root) => root, + Err(outcome) => return Ok(outcome), + }; + let mut preview = match Batch::prepare(&root, options.pattern.trim()) { + Ok(plan) => plan, + Err(error) => return Ok(Outcome::Declined(format!("recover: {error}"))), + }; + let mut linker = if needs_installation(&options, &mut preview) { + ProjectedLinker::prepare_with_installation(&root, args)? + } else { + ProjectedLinker::prepare(&root)? + }; + let stdout = linker.take_stdout(); + if !stdout.is_empty() { + print!("{stdout}"); + } + workspace_command::execute_then(&root, args, "recover", |output| { + execution::execute(&root, args, &options, &mut linker, output) + }) +} + +/// Returns whether `preview` requires dependency installation after component recovery. +#[inline] +fn needs_installation(options: &options::Options, preview: &mut Batch) -> bool { + !options.skip_installation + && (has_non_empty(&preview.import_ids()) + || has_non_empty(&preview.hydration_ids()) + || has_non_empty(&preview.probe_ids()) + || preview.scope_import_ids().is_ok_and(|ids| has_non_empty(&ids))) +} + +#[inline] +fn has_non_empty(ids: &[String]) -> bool { + ids.iter() + .any(|id| !id.trim().is_empty()) +} diff --git a/native/cli/src/commands/recover/execution.rs b/native/cli/src/commands/recover/execution.rs new file mode 100644 index 000000000000..7a1db65e8380 --- /dev/null +++ b/native/cli/src/commands/recover/execution.rs @@ -0,0 +1,106 @@ +//! Import all missing recoveries before publishing per-component remove configuration. +//! +//! [`execute`] runs [`Batch`] recovery plans with [`recover_import`], [`recover_scope_import`], and [`ProjectedLinker`]. +use super::{Chalk, Formatter, OsString, Report, io, options::Options, workspace_command}; +use crate::commands::{ + add::ProjectedLinker, + import::{RecoverOptions, recover_import, recover_probe, recover_scope_import}, +}; +use bit_install::output::Output; +use bit_object_import::component_recover::Batch; +use std::path::Path; + +/// Executes the component recovery [`Batch`] plan for `options.pattern`, importing missing objects and relinking via [`ProjectedLinker`]. +pub(super) fn execute( + root: &Path, + args: &[OsString], + options: &Options<'_>, + linker: &mut ProjectedLinker, + output: &Output, +) -> io::Result { + let pattern = options.pattern.trim(); + if pattern.is_empty() { + return Err(io::Error::other("recover pattern is empty")); + } + if pattern.contains(['*', '?']) { + // ComponentList.getFromFileSystem enters Consumer.loadMany even when + // every selected row is removed; its invalidation precedes that split. + let cache = bit_install::dependencies::cache::invalidation::deps_dir(root); + bit_install::dependencies::cache::invalidation::invalidate(root, &cache)?; + } + let mut plan = Batch::prepare(root, pattern)?; + let missing = plan.scope_import_ids()?; + if !missing.is_empty() { + plan.validate()?; + recover_scope_import(root, &missing, output)?; + plan = Batch::prepare(root, pattern)?; + } + let load = plan.hydration_ids(); + if !load.is_empty() { + let rows = linker.capture(root, plan.bitmap(), &load)?; + plan.refresh_loaded(&rows)?; + } + let probes = plan.probe_ids(); + if !probes.is_empty() { + plan.resolve_remote(&recover_probe(root, &probes)?)?; + } + let stdout = linker.take_stdout(); + if !stdout.is_empty() { + print!("{stdout}"); + } + import(root, args, options, linker, output, &mut plan)?; + plan.commit(&workspace_command::file_id())?; + report(options, &plan, args) +} +/// Imports any missing recovery component versions in `plan` and refreshes the `.bitmap` snapshot. +#[inline] +fn import( + root: &Path, + args: &[OsString], + options: &Options<'_>, + linker: &mut ProjectedLinker, + output: &Output, + plan: &mut Batch, +) -> io::Result<()> { + let ids = plan.import_ids(); + if ids.is_empty() { + return Ok(()); + } + plan.validate()?; + let controls = RecoverOptions { skip_write_config_files: options.skip_config }; + let action = + |installer| recover_import(root, &ids, &controls, Chalk::detect(args), output, installer); + let bitmap = if options.skip_installation { + action(None)? + } else { + linker.with_installer(|installer| action(Some(installer)))? + }; + plan.after_import(&bitmap) +} +#[inline] +fn report(options: &Options<'_>, plan: &Batch, args: &[OsString]) -> io::Result { + let format = Formatter::new(Chalk::detect(args)); + let items = plan + .recovered_ids() + .into_iter() + .filter_map(|id| { + let trimmed = id.trim(); + (!trimmed.is_empty()).then(|| format.item(trimmed)) + }) + .collect::>(); + if items.is_empty() { + return Err(io::Error::other(format!( + r#"no soft-deleted components found matching pattern "{}""#, + options.pattern.trim(), + ))); + } + Ok(Report { + data: format!( + "{}\n{}", + format.success_summary("successfully recovered the following component(s)"), + items.join("\n"), + ), + code: 0, + details: None, + }) +} diff --git a/native/cli/src/commands/recover/options.rs b/native/cli/src/commands/recover/options.rs new file mode 100644 index 000000000000..4618216e400f --- /dev/null +++ b/native/cli/src/commands/recover/options.rs @@ -0,0 +1,61 @@ +//! `RecoverCmd`'s two installation controls apply only to grouped imports. +//! +//! Defines [`Options`] and [`parse`] for `bit recover ` CLI flags. +/// Parsed CLI options for `bit recover `. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub(super) struct Options<'a> { + pub pattern: &'a str, + pub skip_installation: bool, + pub skip_config: bool, +} +/// Parses CLI arguments after `bit recover` into [`Options`], returning `None` for unsupported flags. +pub(super) fn parse<'a>(args: &[&'a str]) -> Option> { + let mut options = Options { pattern: "", skip_installation: false, skip_config: false }; + let mut iter = args.iter().copied(); + while let Some(arg) = iter.next() { + match arg.trim() { + "--" => { + consume_trailing_pattern(iter, &mut options.pattern)?; + break; + } + "--no-color" | "--no-color=true" | "--no-color=false" | "--color" | "--color=true" + | "--color=false" | "--color=always" | "--color=never" => {} + "-x" | "--skip-dependency-installation" | "--skip-dependency-installation=true" => { + options.skip_installation = true; + } + "--skip-dependency-installation=false" => options.skip_installation = false, + "--skip-write-config-files" | "--skip-write-config-files=true" => { + options.skip_config = true; + } + "--skip-write-config-files=false" => options.skip_config = false, + value if is_valid_pattern_token(value) && options.pattern.is_empty() => { + options.pattern = value; + } + _ => return None, + } + } + (!options.pattern.is_empty()).then_some(options) +} + +#[inline] +fn consume_trailing_pattern<'a>( + iter: impl Iterator, + pattern: &mut &'a str, +) -> Option<()> { + for rest in iter { + let trimmed = rest.trim(); + if !is_valid_pattern_token(trimmed) || !pattern.is_empty() { + return None; + } + *pattern = trimmed; + } + Some(()) +} + +#[inline] +fn is_valid_pattern_token(token: &str) -> bool { + !token.is_empty() + && token.len() <= 1024 + && !token.starts_with('-') + && !token.contains(char::is_whitespace) +} diff --git a/native/cli/src/commands/recovery.rs b/native/cli/src/commands/recovery.rs new file mode 100644 index 000000000000..2ce4b9106e31 --- /dev/null +++ b/native/cli/src/commands/recovery.rs @@ -0,0 +1,395 @@ +//! Native component and lane merge abort through the workspace recovery plan. +//! All validation and file restoration stay in the Rust workspace engine. +//! +//! Coordinates [`failure`], [`interactive`], [`report`], [`ProjectedLinker`], and [`Engine`] during merge abort. +mod failure; +mod interactive; +mod report; +#[cfg(test)] +mod tests; + +use super::{ + add::ProjectedLinker, + checkout, + color::Chalk, + engine::Engine, + ids::Id, + installation::{self, Installer}, + status::{ + autosync::Startup, + cache, + capture::{self, Capture}, + environments, + fresh::dependency_grammar, + grammar, + js::{array, decline, object, text}, + }, + workspace_command::{self, format::Formatter, progress::Progress, results::Report}, +}; +use crate::native::Outcome; +use bit_install::{HostInfo, output::Output}; +use report::{ + consume_trailing_recovery, format_result, install_ids, installation_session, + prepare_recovery_context, prepare_request, read_environment, valid_pattern, +}; +use serde_json::{Value, json}; +use std::{ffi::OsString, io, path::Path, sync::Arc}; +/// Target scope of a merge abort (`bit merge --abort` or `bit lane merge-abort`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Kind { + Component, + Lane, +} + +pub(super) struct Options { + kind: Kind, + pattern: Option, + skip_install: bool, + verbose: bool, + silent: bool, +} + +#[inline] +pub(super) fn parse_component(args: &[&str]) -> Option { + parse(args, Kind::Component) +} +#[inline] +pub(super) fn parse_lane(args: &[&str]) -> Option { + parse(args, Kind::Lane) +} + +fn parse(args: &[&str], kind: Kind) -> Option { + let mut opts = + Options { kind, pattern: None, skip_install: false, verbose: false, silent: false }; + let mut abort = false; + let mut iter = args.iter().copied(); + while let Some(raw_arg) = iter.next() { + let arg = raw_arg.trim(); + if arg == "--" { + consume_trailing_recovery(iter, kind, &mut opts.pattern)?; + break; + } + apply_recovery_flag(arg, &mut opts, &mut abort)?; + } + (kind != Kind::Component || abort).then_some(opts) +} + +fn apply_recovery_flag(arg: &str, opts: &mut Options, abort: &mut bool) -> Option<()> { + match arg { + "--no-color" | "--no-color=true" | "--no-color=false" | "--color" | "--color=true" + | "--color=false" | "--color=always" | "--color=never" => Some(()), + "-x" | "--skip-dependency-installation" | "--skip-dependency-installation=true" + if !opts.skip_install => + { + opts.skip_install = true; + Some(()) + } + "--skip-dependency-installation=false" if !opts.skip_install => Some(()), + "--verbose" | "--verbose=true" if !opts.verbose => { + opts.verbose = true; + Some(()) + } + "--verbose=false" if !opts.verbose => Some(()), + _ if opts.kind == Kind::Component => apply_component_flag(arg, opts, abort), + _ => apply_lane_flag(arg, opts), + } +} + +#[inline] +fn apply_component_flag(arg: &str, opts: &mut Options, abort: &mut bool) -> Option<()> { + if matches!(arg, "--abort" | "--abort=true") && !*abort { + *abort = true; + return Some(()); + } + let trimmed = arg.trim(); + (opts.pattern.is_none() && valid_pattern(trimmed)).then(|| { + opts.pattern = Some(trimmed.to_string()); + }) +} + +#[inline] +fn apply_lane_flag(arg: &str, opts: &mut Options) -> Option<()> { + if opts.silent { + return None; + } + match arg { + "-s" | "--silent" | "--silent=true" => opts.silent = true, + "--silent=false" => {} + "-sx" | "-xs" if !opts.skip_install => (opts.silent, opts.skip_install) = (true, true), + _ => return None, + } + Some(()) +} + +pub(super) fn run(args: &[OsString], options: &Options) -> io::Result { + let Some(argv) = workspace_command::utf8_args(args) else { + return Ok(Outcome::Declined("arguments are not UTF-8".to_string())); + }; + let command = if options.kind == Kind::Lane { "merge-abort" } else { "merge" }; + let root = match workspace_command::workspace_or_fail(args, command)? { + Ok(root) => root, + Err(outcome) => return Ok(outcome), + }; + let installer = match start_recovery_installer(&root, &argv, options) { + Ok(installer) => installer, + Err(outcome) => return Ok(outcome), + }; + if options.kind == Kind::Lane && !options.silent { + return interactive::run(&root, args, options, installer); + } + let (environment, installation) = installation_session(installer)?; + let mut startup = Startup::default(); + let prepared = match Prepared::plan(&root, options, &mut startup, environment) { + Ok(prepared) => prepared, + Err(error) => return handle_plan_error(&root, args, command, &startup, error), + }; + execute_prepared(&root, args, options, &startup, prepared, installation) +} + +#[inline] +fn start_recovery_installer( + root: &Path, + argv: &[&str], + options: &Options, +) -> Result, Outcome> { + if options.kind == Kind::Lane && options.skip_install { + return Ok(None); + } + Installer::start(root, argv) + .map(Some) + .map_err(|err| Outcome::Declined(format!("merge recovery installation: {err}"))) +} + +fn handle_plan_error( + root: &Path, + args: &[OsString], + command: &str, + startup: &Startup, + error: io::Error, +) -> io::Result { + if let Some(res) = failure::result(&error) { + return workspace_command::execute_then(root, args, "merge-abort", |_output| { + workspace_command::checkout_lifecycle::before(startup)?; + workspace_command::checkout_lifecycle::loaded(startup); + format_result(res, Kind::Lane, Chalk::detect(args)) + }); + } + if startup.committed { + return workspace_command::checkout_lifecycle::failure(root, args, command, startup, error); + } + Ok(Outcome::Declined(format!("merge recovery: {error}"))) +} + +fn execute_prepared( + root: &Path, + args: &[OsString], + options: &Options, + startup: &Startup, + prepared: Prepared, + installation: Option, +) -> io::Result { + let command = if options.kind == Kind::Lane { "merge-abort" } else { "merge" }; + if options.kind == Kind::Component { + workspace_command::checkout_lifecycle::before(startup)?; + } + workspace_command::execute_then(root, args, command, |output| { + if options.kind == Kind::Lane { + workspace_command::checkout_lifecycle::before(startup)?; + } + workspace_command::checkout_lifecycle::loaded(startup); + let report = execute(root, args, output, prepared, installation)?; + super::status::cleanup_temporary(root)?; + Ok(report) + }) +} + +struct Prepared { + engine: Engine, + token: String, + context: Value, + capture: Capture, + progress: Option, + ids: Vec, + kind: Kind, +} + +impl Prepared { + fn plan( + root: &Path, + options: &Options, + startup: &mut Startup, + mut inherited: Option, + ) -> io::Result { + let home = workspace_command::home(); + let mut engine = Engine::new()?; + let request = prepare_request(root, &home, options)?; + failure::preflight(&request)?; + let environments = if options.kind == Kind::Component { + Some(read_environment(&mut engine, root, &home, startup, inherited.take())?) + } else { + None + }; + let (token, context, targets) = + prepare_recovery_context(&mut engine, &request, options, startup)?; + let environments = match environments { + Some(environment) => environment, + None => read_environment(&mut engine, root, &home, startup, inherited.take())?, + }; + let capture = capture_recovery_targets( + &mut engine, + root, + &home, + &token, + &context, + &targets, + options, + startup, + environments, + )?; + workspace_command::checkout_lifecycle::retain(Some(startup), &capture); + Self::finalize_plan(engine, token, context, capture, options.kind) + } + + fn finalize_plan( + mut engine: Engine, + token: String, + context: Value, + capture: Capture, + kind: Kind, + ) -> io::Result { + let versions = workspace_command::semver_values(Some("reset"), &context)?; + let semver = grammar::snapshot(versions.iter().map(String::as_str)) + .ok_or_else(|| decline("recovery semver grammar"))?; + let plan: Value = engine.request( + &json!({"kind":"commandRecoveryPlan","token":token,"current":capture.captured,"dependencyReadSet":capture.dependency_read_set(),"semver":semver}), + )?; + if plan.get("token").and_then(Value::as_str) != Some(&token) { + return Err(decline("recovery plan token")); + } + let result = plan.get("result").ok_or_else(|| decline("recovery result"))?; + format_result(result, kind, Chalk::detect(&[]))?; + let progress = Progress::parse(plan.get("progress"))?; + let ids = install_ids(result, kind)?; + Ok(Self { engine, token, context, capture, progress, ids, kind }) + } + + fn commit(&mut self, root: &Path) -> io::Result { + capture::commit(&mut self.engine, root, &self.context, &self.capture)?; + let restored_id = self.context["recoveryRestoredFileId"] + .as_str() + .map(str::trim) + .filter(|id| !id.is_empty()); + let file_id = restored_id.map_or_else(workspace_command::file_id, str::to_owned); + let mut read_set = self.capture.dependency_read_set(); + if self.kind == Kind::Lane && restored_id.is_none() { + let restored: Value = self.engine.request( + &json!({"kind":"commandRecoveryRestore","token":self.token,"fileId":file_id,"dependencyReadSet":read_set}), + )?; + if restored.get("restored") != Some(&Value::Bool(true)) { + return Err(io::Error::other("recovery restore receipt")); + } + read_set = + array(restored.get("dependencyReadSet"), "restored dependency read set")?.to_vec(); + } + let mut applied: Value = self.engine.request( + &json!({"kind":"commandRecoveryApply","token":self.token,"fileId":file_id,"dependencyReadSet":read_set}), + )?; + (applied.get("applied") == Some(&Value::Bool(true))) + .then_some(()) + .ok_or_else(|| io::Error::other("recovery apply receipt"))?; + applied + .get_mut("result") + .map(Value::take) + .ok_or_else(|| io::Error::other("recovery apply result")) + } +} + +fn execute( + root: &Path, + args: &[OsString], + output: &Output, + mut prepared: Prepared, + installation: Option, +) -> io::Result { + if let Some(progress) = &prepared.progress { + progress.render_merge(output); + } + let chalk = Chalk::detect(args); + let result = prepared.commit(root)?; + let mut report = format_result(&result, prepared.kind, chalk)?; + if let Some(info) = installation + && !prepared.ids.is_empty() + && result.get("checkoutError").is_none() + { + let (installed, host) = install_retained(root, &mut prepared, info)?; + if let Err(error) = &installed { + output.failure(&format!("installation failed with the following error: {error}")); + } + host.call("installCanonicalFinalize", &json!({}))?; + let stdout = host.take_stdout(); + if !stdout.is_empty() { + print!("{stdout}"); + } + if let Err(error) = installed + && prepared.kind != Kind::Component + { + let err_report = installation::error_report(chalk, &error.to_string()); + report.data = Formatter::join(&[report.data, err_report]); + } + } + Ok(report) +} + +fn install_retained( + root: &Path, + prepared: &mut Prepared, + mut info: HostInfo, +) -> io::Result<(io::Result<()>, Arc)> { + let mut environment = capture::take_callback_environment(&mut prepared.capture) + .ok_or_else(|| io::Error::other("recovery installation provider session"))?; + let projected = ProjectedLinker::project_installation_workspace(root, &mut environment)?; + if !projected.is_empty() { + print!("{projected}"); + } + let host = environment + .into_tracking_host() + .ok_or_else(|| io::Error::other("recovery installation Host"))?; + info.file_id = workspace_command::file_id(); + let host = Arc::new(host); + let installed = Installer::from_host(Arc::clone(&host), info) + .with_initialized_cache(!prepared.capture.components.is_empty()) + .install(root, false, &prepared.ids); + Ok((installed, host)) +} + +#[expect(clippy::too_many_arguments, reason = "Forwards engine, token, context, and environments")] +fn capture_recovery_targets( + engine: &mut Engine, + root: &Path, + home: &Path, + token: &str, + context: &Value, + targets: &[Value], + options: &Options, + startup: &mut Startup, + environments: environments::Environments, +) -> io::Result { + let opts = capture::CaptureOptions { + environments: Some(environments), + incomplete_reset: options.kind == Kind::Component, + ..Default::default() + }; + match capture::capture_with(engine, root, home, context, targets, opts) { + Ok(capture) => Ok(capture), + Err(error) if context["recoveryRestoredFileId"].is_string() => { + failure::retain(startup, &error); + let applied: Value = engine.request(&json!({ + "kind":"commandRecoveryApply","token":token, + "fileId":context["recoveryRestoredFileId"], + "checkoutError":{"message":error.to_string()}, + }))?; + Err(failure::restored(applied["result"].clone())) + } + Err(error) => Err(error), + } +} diff --git a/native/cli/src/commands/recovery/failure.rs b/native/cli/src/commands/recovery/failure.rs new file mode 100644 index 000000000000..fa83c0eac9fb --- /dev/null +++ b/native/cli/src/commands/recovery/failure.rs @@ -0,0 +1,65 @@ +//! A lane abort reports checkout failure after its control restoration has committed. +//! +//! [`preflight`] verifies recovery eligibility ([`array`], [`decline`]) through [`Engine`] and [`Startup`] autosync. +use super::{Engine, array, decline}; +use crate::commands::status::autosync::Startup; +use serde_json::Value; +use std::{fmt, io}; + +/// Runs a preflight recovery preparation check via [`Engine`] before executing a merge abort. +pub(super) fn preflight(request: &Value) -> io::Result<()> { + let mut engine = Engine::new()?; + let result = engine.request::(request); + match result { + Ok(prepared) => { + let token = super::text(prepared.get("token"))?.trim(); + if token.is_empty() || !prepared.get("context").is_some_and(Value::is_object) { + return Err(decline("recovery missing component root")); + } + let capture_targets = + array(prepared.get("captureTargets"), "recovery capture targets")?; + let targets = array(prepared.get("targets"), "recovery targets")?; + if capture_targets.len() != targets.len() || capture_targets.len() > 4096 { + return Err(decline("recovery missing component root")); + } + Ok(()) + } + Err(error) if !crate::commands::engine::is_canonical(&error) => Err(error), + _ => Ok(()), + } +} +#[derive(Debug)] +struct Restored(Value); +impl fmt::Display for Restored { + #[inline] + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("restored lane checkout failure") + } +} +impl std::error::Error for Restored {} +/// Wraps a partially restored lane-abort [`Value`] inside an [`io::Error`]. +#[inline] +pub(super) fn restored(result: Value) -> io::Error { + io::Error::other(Restored(result)) +} +#[inline] +pub(super) fn result(error: &io::Error) -> Option<&Value> { + error + .get_ref()? + .downcast_ref::() + .map(|error| &error.0) +} +#[inline] +pub(super) fn retain(startup: &mut Startup, error: &io::Error) { + if let Some(error) = error + .get_ref() + .and_then(|error| error.downcast_ref::()) + { + if !error.startup.trim().is_empty() && !startup.stdout.ends_with(&error.startup) { + startup.stdout.push_str(&error.startup); + } + if !error.output.trim().is_empty() && !startup.build_stdout.ends_with(&error.output) { + startup.build_stdout.push_str(&error.output); + } + } +} diff --git a/native/cli/src/commands/recovery/interactive.rs b/native/cli/src/commands/recovery/interactive.rs new file mode 100644 index 000000000000..8e057fe95987 --- /dev/null +++ b/native/cli/src/commands/recovery/interactive.rs @@ -0,0 +1,68 @@ +//! Confirmation precedes restoration and any configured component callbacks. +//! +//! [`run`] prompts with [`confirm`] before executing [`Prepared`] recovery and [`installation_session`] for [`Options`]. +use super::{ + Chalk, Engine, Installer, Kind, Options, Outcome, Prepared, Startup, Value, array, execute, + failure, format_result, installation_session, prepare_request, workspace_command, +}; +use crate::commands::install::confirm; +use std::{ffi::OsString, io, path::Path}; + +/// Prompts for interactive confirmation via [`confirm`] and runs the merge-abort recovery plan. +pub(super) fn run( + root: &Path, + args: &[OsString], + options: &Options, + installer: Option, +) -> io::Result { + let mut engine = Engine::new()?; + let preview: Value = + engine.request(&prepare_request(root, &workspace_command::home(), options)?)?; + let chalk = Chalk::detect(args); + let prompt = prompt(&preview, chalk)?; + workspace_command::execute_then(root, args, "merge-abort", |output| { + output.stop(); + if !confirm(&prompt)? { + return Err(io::Error::other("operation aborted")); + } + let (environment, installation) = installation_session(installer)?; + let mut startup = Startup::default(); + let prepared = Prepared::plan(root, options, &mut startup, environment); + workspace_command::checkout_lifecycle::before(&startup)?; + workspace_command::checkout_lifecycle::loaded(&startup); + let report = match prepared { + Ok(prepared) => execute(root, args, output, prepared, installation)?, + Err(error) => match failure::result(&error) { + Some(result) => format_result(result, Kind::Lane, chalk)?, + None => return Err(error), + }, + }; + crate::commands::status::cleanup_temporary(root)?; + Ok(report) + }) +} + +/// Formats the interactive confirmation prompt for `bit lane merge-abort` from `preview`. +#[inline] +fn prompt(preview: &Value, chalk: Chalk) -> io::Result { + let items = array(preview.get("restoredItems"), "recovery restoration items")?; + let mut deleted_dirs = None; + for item in items { + let trimmed = super::text(Some(item))?.trim(); + if deleted_dirs.is_none() + && let Some(dirs) = trimmed + .strip_prefix("deleted components directories:") + .map(str::trim) + .filter(|dirs| !dirs.is_empty()) + { + deleted_dirs = Some(dirs); + } + } + let directories = deleted_dirs.map_or_else(String::new, |dirs| { + format!("\nThe following directories introduced by the merge will be deleted: {dirs}") + }); + Ok(format!( + "Code changes that were done since the last lane-merge will be lost.{directories}\nThe .bitmap and workspace.jsonc files will be restored to the state before the merge.\nThis action is irreversible.\n{}", + chalk.bold("Do you want to continue? [yes(y)/no(n)]"), + )) +} diff --git a/native/cli/src/commands/recovery/report.rs b/native/cli/src/commands/recovery/report.rs new file mode 100644 index 000000000000..73bda8bf9af0 --- /dev/null +++ b/native/cli/src/commands/recovery/report.rs @@ -0,0 +1,226 @@ +//! Canonical component and lane abort reports and preparation helpers. +//! +//! Extracts [`install_ids`] and [`format_result`] to render [`Kind`] recovery results into a [`Report`]. +use super::{ + Chalk, Engine, Formatter, HostInfo, Id, Installer, Kind, Options, Path, Report, Startup, Value, + array, cache, checkout, decline, dependency_grammar, environments, installation, io, json, + object, text, workspace_command, +}; + +/// Extracts the sorted, deduplicated component IDs requiring dependency installation after a [`Kind`] abort. +pub(super) fn install_ids(result: &Value, kind: Kind) -> io::Result> { + if kind == Kind::Lane { + return installation::component_ids(&result["checkoutResults"]); + } + let rows = array(result.get("abortedComponents"), "aborted components")?; + let mut ids = rows + .iter() + .map(|row| { + let raw_id = row.get("id").ok_or_else(|| decline("aborted component id"))?; + Id::from_object(raw_id).map(|id| id.display()) + }) + .collect::>>()?; + ids.sort(); + ids.dedup(); + Ok(ids) +} + +/// Dispatches `result` formatting to either [`format_lane`] or [`format_component`] according to `kind`. +#[inline] +pub(super) fn format_result(result: &Value, kind: Kind, chalk: Chalk) -> io::Result { + if kind == Kind::Lane { format_lane(result, chalk) } else { format_component(result, chalk) } +} + +pub(super) fn format_component(result: &Value, chalk: Chalk) -> io::Result { + let map = object(result)?; + let rows = array(map.get("abortedComponents"), "aborted components")?; + if map + .keys() + .any(|key| key != "abortedComponents") + || rows.len() > 4096 + { + return Err(decline("merge abort result")); + } + let format = Formatter::new(chalk); + let items = rows + .iter() + .map(|row| { + let raw_id = row.get("id").ok_or_else(|| decline("aborted component id"))?; + Id::from_object(raw_id).map(|id| format.item(&id.without_version())) + }) + .collect::>>()?; + Ok(Report { data: format.section("merge aborted", "", &items), code: 0, details: None }) +} + +fn format_lane(result: &Value, chalk: Chalk) -> io::Result { + let map = object(result)?; + let items = array(map.get("restoredItems"), "restored items")?; + if map + .keys() + .any(|key| !matches!(key.as_str(), "checkoutResults" | "restoredItems" | "checkoutError")) + || items.len() > 4096 + { + return Err(decline("lane recovery result")); + } + let summary = lane_restored_summary(items, &Formatter::new(chalk))?; + let checkout = lane_checkout_report(map, chalk)?; + let error = lane_checkout_error(map, chalk)?; + let details = + checkout.details.map(|details| Formatter::join(&[details, summary.clone(), error.clone()])); + Ok(Report { + data: Formatter::join(&[checkout.data, summary, error]), + code: checkout.code, + details, + }) +} + +fn lane_restored_summary(items: &[Value], format: &Formatter) -> io::Result { + let restored = items + .iter() + .filter_map(|item| match text(Some(item)) { + Ok(raw) => { + let trimmed = raw.trim(); + (!trimmed.is_empty()).then(|| Ok(format.item(trimmed))) + } + Err(err) => Some(Err(err)), + }) + .collect::>>()?; + if restored.is_empty() { + return Ok(String::new()); + } + Ok(format!( + "{}\n{}", + format.success_summary("The following have been restored successfully"), + restored.join("\n"), + )) +} + +fn lane_checkout_report(map: &serde_json::Map, chalk: Chalk) -> io::Result { + match map + .get("checkoutResults") + .filter(|value| !value.is_null()) + { + Some(result) => { + let options = checkout::options::parse(&["reset", "--all", "-x"]) + .ok_or_else(|| decline("recovery checkout options"))?; + checkout::report::format(result, &options, chalk) + } + None => Ok(Report { data: String::new(), code: 0, details: None }), + } +} + +fn lane_checkout_error(map: &serde_json::Map, chalk: Chalk) -> io::Result { + let message = map + .get("checkoutError") + .filter(|value| !value.is_null()) + .map(|value| text(value.get("message"))) + .transpose()?; + Ok(message + .map(str::trim) + .filter(|msg| !msg.is_empty()) + .map(|msg| chalk.red(&format!("\n\nFailed to change component files to the pre-merge state due to an error:\n{msg}\nplease fix the error and then run \"bit checkout reset --all\" to revert the components to the pre-merge state"))) + .unwrap_or_default()) +} + +#[inline] +pub(super) fn consume_trailing_recovery<'a>( + iter: impl Iterator, + kind: Kind, + pattern: &mut Option, +) -> Option<()> { + for value in iter { + let value = value.trim(); + (kind == Kind::Component && pattern.is_none() && valid_pattern(value)).then_some(())?; + *pattern = Some(value.to_string()); + } + Some(()) +} + +#[inline] +pub(super) fn valid_pattern(pattern: &str) -> bool { + let pattern = pattern.trim(); + !pattern.is_empty() + && pattern.len() <= 256 + && !pattern.starts_with('-') + && pattern + .bytes() + .all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || b"_./-".contains(&byte) + }) +} + +#[inline] +pub(super) fn installation_session( + installer: Option, +) -> io::Result<(Option, Option)> { + let Some(installer) = installer else { return Ok((None, None)) }; + let (environment, info) = installer.into_parts()?; + Ok((Some(environment), Some(info))) +} + +#[inline] +pub(super) fn read_environment( + engine: &mut Engine, + root: &Path, + home: &Path, + startup: &mut Startup, + inherited: Option, +) -> io::Result { + let (_, environment) = match inherited { + Some(env_state) => { + environments::read_inherited_context(engine, root, home, None, startup, env_state)? + } + None => environments::read_context(engine, root, home, false, None, startup)?, + }; + Ok(environment.for_checkout()) +} + +pub(super) fn prepare_request(root: &Path, home: &Path, options: &Options) -> io::Result { + let operation = if options.kind == Kind::Lane { "laneAbort" } else { "componentAbort" }; + let mut request = json!({ + "kind":"commandRecoveryPrepare","root":root,"home":home, + "globalRemotesFile":cache::cache_root().join("config/global-remotes.json"), + "operation":operation,"dependencyGrammar":dependency_grammar()?, + "options":{"silent":options.kind==Kind::Lane,"verbose":options.verbose, + "skipDependencyInstallation":true,"callbackLifecycle":true}, + }); + if let Some(pattern) = options.pattern + .as_deref() + .map(str::trim) + .filter(|pattern| !pattern.is_empty()) + { + request["options"]["pattern"] = json!(pattern); + } + Ok(request) +} + +pub(super) fn prepare_recovery_context( + engine: &mut Engine, + request: &Value, + options: &Options, + startup: &mut Startup, +) -> io::Result<(String, Value, Vec)> { + let prepared: Value = engine.request(request)?; + let token = text(prepared.get("token"))?.trim().to_string(); + if token.is_empty() { + return Err(decline("recovery token")); + } + let mut context = prepared + .get("context") + .filter(|value| value.is_object()) + .ok_or_else(|| decline("recovery context"))? + .clone(); + let targets = array(prepared.get("captureTargets"), "recovery capture targets")?.to_vec(); + if targets.len() > 4096 { + return Err(decline("recovery target limit")); + } + if options.kind == Kind::Lane { + startup.committed = true; + let file_id = workspace_command::file_id(); + let _: Value = engine.request( + &json!({"kind":"commandRecoveryRestore","token":token,"fileId":file_id}), + )?; + context["recoveryRestoredFileId"] = json!(file_id); + } + Ok((token, context, targets)) +} diff --git a/native/cli/src/commands/recovery/tests.rs b/native/cli/src/commands/recovery/tests.rs new file mode 100644 index 000000000000..98ead1c2411f --- /dev/null +++ b/native/cli/src/commands/recovery/tests.rs @@ -0,0 +1,39 @@ +//! Unit tests for `bit merge --abort` ([`parse_component`]) and `bit lane merge-abort` ([`parse_lane`]) CLI option parsing and report formatting ([`format_component`]). +//! +//! Verifies [`Kind::Lane`] / component abort flags and [`Chalk`] report formatting. + +use super::{Kind, parse_component, parse_lane, report::format_component}; +use crate::commands::color::Chalk; +use serde_json::json; + +/// Verifies that [`parse_component`] and [`parse_lane`] accept only explicit non-interactive abort flags. +#[test] +fn abort_grammars_select_only_explicit_noninteractive_operations() { + assert!(parse_component(&["--abort"]).is_some()); + assert!(parse_component(&["scope/name", "--abort", "-x"]).unwrap().skip_install); + assert!(parse_component(&["--resolve", "--abort"]).is_none()); + assert!(parse_component(&["--abort", "--abort"]).is_none()); + assert!(parse_lane(&["--silent"]).is_some()); + assert_eq!(parse_lane(&["-s", "-x"]).unwrap().kind, Kind::Lane); + assert!(!parse_lane(&[]).unwrap().silent); + assert!(parse_lane(&["--silent", "component"]).is_none()); +} + +/// Verifies that [`format_component`] strips `@version` suffixes from aborted component IDs. +#[test] +fn component_abort_reports_unversioned_ids() { + let report = format_component( + &json!({"abortedComponents":[{"id":"scope/name@abc"}]}), + Chalk::new(false), + ) + .unwrap(); + assert!(report.data.contains("merge aborted")); + assert!(report.data.contains("scope/name")); + assert!(!report.data.contains("@abc")); +} + +#[test] +fn component_abort_with_unexpected_key_declines_formatting() { + let extra = json!({"abortedComponents": [], "extra": 1}); + assert!(format_component(&extra, Chalk::new(false)).is_err()); +} diff --git a/native/cli/src/commands/refactor.rs b/native/cli/src/commands/refactor.rs new file mode 100644 index 000000000000..7786e76a67ee --- /dev/null +++ b/native/cli/src/commands/refactor.rs @@ -0,0 +1,409 @@ +//! Native `bit refactor` (`RefactorCmd` and `DependencyNameRefactorCmd` in `scopes/component/refactoring/refactor.cmd.ts`, [`COMMAND`], [`Action`]) via [`resolve_component_entry`]. + +#[cfg(test)] +mod tests; + +use super::{ + Command, + color::Chalk, + debug_cat::lookup::resolve_component_entry, + inventory, + lane::scope_root, + remote_action::startup, + workspace_command::{self, results::Report}, +}; +use crate::{native::Outcome, workspace}; +use bit_install::{ + config::WorkspaceConfig, + model::{ComponentId, WorkspaceComponent}, + workspace::Workspace, +}; +use serde_json::Value; +use std::{ + env, + ffi::OsString, + fs, + io::{self, Write}, + path::{Path, PathBuf}, + process::ExitCode, +}; +/// Descriptor for the `bit refactor` CLI command. +pub const COMMAND: Command = Command { name: "refactor", aliases: &[], run }; + +#[derive(Debug, Clone, PartialEq, Eq)] +enum Action { + DependencyName { old_id: String, new_id: String }, + Unrecognized(String), +} + +#[derive(Debug)] +enum Failure { + Declined(String), + User(String), +} + +impl From for Failure { + #[inline] + fn from(error: io::Error) -> Self { + Self::Declined(error.to_string()) + } +} + +struct Prepared { + report: String, + writes: Vec<(PathBuf, Vec)>, +} + +fn run(args: &[OsString]) -> io::Result { + let Some(action) = parse_options(args) else { + return Ok(Outcome::Declined("arguments outside the native `refactor` grammar".to_owned())); + }; + let chalk = Chalk::detect(args); + match action { + Action::Unrecognized(sub) => run_unrecognized(args, &sub, chalk), + Action::DependencyName { old_id, new_id } => { + let cwd = env::current_dir()?; + if let Some(root) = workspace::root(&cwd) { + return run_dependency_name(&root, args, &old_id, &new_id); + } + run_dependency_name_non_workspace(&cwd, args, &old_id, &new_id) + } + } +} + +fn parse_options(args: &[OsString]) -> Option { + let raw = args + .iter() + .skip(1) + .map(|arg| arg.to_str()) + .collect::>>()?; + let tokens = super::scope_set::collect_non_flag_tokens(&raw)?; + match tokens.as_slice() { + ["dependency-name", old_id, new_id] if !old_id.is_empty() && !new_id.is_empty() => { + Some(Action::DependencyName { + old_id: (*old_id).to_owned(), + new_id: (*new_id).to_owned(), + }) + } + [sub] if !sub.is_empty() && *sub != "dependency-name" => { + Some(Action::Unrecognized((*sub).to_owned())) + } + _ => None, + } +} + +fn run_unrecognized(args: &[OsString], sub: &str, chalk: Chalk) -> io::Result { + let data = chalk.red(&format!( + r#""{sub}" is not a subcommand of "refactor", please run "bit refactor --help" to list the subcommands"#, + )); + let report = Report { data, code: 0, details: None }; + match scope_root::discover().ok().flatten() { + Some(root) if !root.bare => { + if load_workspace(&root.path).is_err() { + return Ok(Outcome::Declined("invalid workspace configuration".to_owned())); + } + workspace_command::execute_then(&root.path, args, "refactor", |_output| Ok(report)) + } + Some(root) => { + if startup::supported(None, &root.path, &Value::Null, true).ok() != Some(true) { + return Ok(Outcome::Declined("bare scope requires unsupported startup".to_owned())); + } + workspace_command::execute_with_scope(&root.path, args, "refactor", &report) + } + None => { + let mut stdout = io::stdout().lock(); + writeln!(stdout, "{}", report.data)?; + stdout.flush()?; + Ok(Outcome::Exit(ExitCode::SUCCESS)) + } + } +} + +#[inline] +fn ci_enabled() -> bool { + env::var_os("CI") + .is_some_and(|val| { + let text = val.to_string_lossy(); + let trimmed = text + .trim() + .trim_matches(['"', '\'']) + .trim(); + !matches!(trimmed.to_ascii_lowercase().as_str(), "" | "0" | "false" | "no" | "off") + }) +} + +fn run_dependency_name( + root: &Path, + args: &[OsString], + old: &str, + new: &str, +) -> io::Result { + match prepare_dependency_name(root, old, new) { + Ok(prepared) => workspace_command::execute_then(root, args, "dependency-name", |_output| { + for (path, bytes) in &prepared.writes { + fs::write(path, bytes)?; + } + Ok(Report { data: prepared.report, code: 0, details: None }) + }), + Err(Failure::User(_)) if ci_enabled() => { + Ok(Outcome::Declined("CI refactor error stack".to_owned())) + } + Err(Failure::User(message)) => { + super::lint_cmd::emit_pattern_error(root, args, "dependency-name", false, message) + } + Err(Failure::Declined(reason)) => Ok(Outcome::Declined(format!("refactor: {reason}"))), + } +} + +fn run_dependency_name_non_workspace( + cwd: &Path, + args: &[OsString], + old: &str, + new: &str, +) -> io::Result { + if ci_enabled() { + return Ok(Outcome::Declined("CI non-workspace refactor delegated".to_owned())); + } + let Some(sr) = scope_root::discover().ok().flatten() else { + return emit_scope_error(cwd, args, "Host was not found"); + }; + match prepare_bare_scope_dependency_name(&sr.path, old, new) { + Ok(data) => { + let report = Report { data, code: 0, details: None }; + workspace_command::execute_with_scope(&sr.path, args, "dependency-name", &report) + } + Err(Failure::User(msg)) => emit_scope_error(&sr.path, args, &msg), + Err(Failure::Declined(reason)) => Ok(Outcome::Declined(format!("refactor: {reason}"))), + } +} + +#[inline] +fn emit_scope_error(cwd: &Path, args: &[OsString], msg: &str) -> io::Result { + let err = io::Error::other(msg.to_owned()); + workspace_command::execute_failure_scope(cwd, args, "dependency-name", false, "", err) +} + +fn prepare_bare_scope_dependency_name( + scope_dir: &Path, + old: &str, + new: &str, +) -> Result { + let old_pkg = resolve_bare_scope_package_name(scope_dir, old)?; + let new_pkg = resolve_bare_scope_package_name(scope_dir, new)?; + if old_pkg == new_pkg { + return Err(Failure::User(format!( + "refactoring: the old package-name and the new package-name are the same: {old_pkg}", + ))); + } + if scope_has_components(scope_dir) { + return Err(Failure::Declined("bare scope has components".to_owned())); + } + Ok(format!("the following components have been changed ({old_pkg} => {new_pkg}):\n")) +} + +fn load_workspace(root: &Path) -> Result { + let config = WorkspaceConfig::load(root)?; + if config.workspace.bitmap_auto_sync == Some(true) + || config.workspace.default_scope.as_deref().is_none_or(str::is_empty) + { + return Err(Failure::Declined("unsupported workspace settings".to_owned())); + } + let core: Vec = serde_json::from_str(include_str!( + "../../../../scopes/harmony/testing/load-aspect/core-aspects-ids.json" + )) + .map_err(|error| Failure::Declined(error.to_string()))?; + Workspace::load(root, &config, &core).map_err(Failure::from) +} + +fn prepare_dependency_name(root: &Path, old_id: &str, new_id: &str) -> Result { + let ws = load_workspace(root)?; + let old_pkg = resolve_package_name(root, &ws, old_id)?; + let new_pkg = resolve_package_name(root, &ws, new_id)?; + if old_pkg == new_pkg { + return Err(Failure::User(format!( + "refactoring: the old package-name and the new package-name are the same: {old_pkg}", + ))); + } + let mut changed_ids = Vec::new(); + let mut writes = Vec::new(); + for (_entry, comp) in ws.active() { + if refactor_component(comp, &old_pkg, &new_pkg, &mut writes)? { + changed_ids.push(format_component_id(&comp.id)); + } + } + let report = format!( + "the following components have been changed ({old_pkg} => {new_pkg}):\n{}", + changed_ids.join("\n"), + ); + Ok(Prepared { report, writes }) +} + +#[inline] +fn format_component_id(id: &ComponentId) -> String { + let base = id.to_string_without_version(); + match id.version.as_deref() { + Some(ver) if !ver.is_empty() => format!("{base}@{ver}"), + _ => base, + } +} + +fn resolve_package_name(root: &Path, ws: &Workspace, raw_id: &str) -> Result { + let normalized = inventory::normalize(raw_id); + let directory = normalized.strip_prefix("./").unwrap_or(&normalized); + if let Some((_, comp)) = ws + .active() + .find(|(_, comp)| { + (!comp.root_dir.is_empty() && comp.root_dir == directory) + || format_component_id(&comp.id) == raw_id + || comp.id.to_string_without_version() == raw_id + || comp.id.name == raw_id + || comp.package_name == raw_id + }) + { + return Ok(comp.package_name.clone()); + } + resolve_bare_scope_package_name(&workspace::scope_path(root), raw_id) +} + +fn resolve_bare_scope_package_name(scope_dir: &Path, raw_id: &str) -> Result { + if exists_in_scope_index(scope_dir, raw_id) { + if let Some(pkg) = resolve_scope_package_name(scope_dir, raw_id) { + return Ok(pkg); + } + return Err(Failure::Declined("component exists only in local scope".to_owned())); + } + if is_valid_scoped_package_name(raw_id) { + return Ok(raw_id.to_owned()); + } + Err(Failure::User(format!( + r#"refactoring: the id "{raw_id}" is neither a valid scoped-package-name nor an existing component-id"#, + ))) +} + +fn resolve_scope_package_name(scope_dir: &Path, raw_id: &str) -> Option { + let (bare, explicit_ver) = raw_id + .rsplit_once('@') + .map_or((raw_id, None), |(id, ver)| (id, Some(ver))); + let (_, _, comp_hash) = resolve_component_entry(scope_dir, bare)?; + let (kind, comp_val) = bit_object_import::read_scope_object(scope_dir, &comp_hash).ok()??; + if kind != "Component" { + return None; + } + let ver_hash = + match explicit_ver.filter(|ver| !ver.is_empty() && !matches!(*ver, "latest" | "head")) { + Some(ver) => comp_val["versions"][ver] + .as_str() + .or_else(|| comp_val["orphanedVersions"][ver].as_str()) + .unwrap_or(ver), + None => comp_val["head"].as_str()?, + }; + let (vkind, ver_val) = bit_object_import::read_scope_object(scope_dir, ver_hash).ok()??; + (vkind == "Version").then(|| extract_version_package_name(&ver_val)).flatten() +} + +fn extract_version_package_name(ver_val: &Value) -> Option { + for ext in ver_val["extensions"].as_array()? { + if super::update_cmd::matches_aspect(ext, "teambit.dependencies/dependency-resolver") + && let Some(pkg) = ext["data"]["packageName"] + .as_str() + .filter(|val| !val.is_empty()) + { + return Some(pkg.to_owned()); + } + if super::update_cmd::matches_aspect(ext, "teambit.pkg/pkg") + && let Some(pkg) = ext["config"]["packageJson"]["name"] + .as_str() + .or_else(|| ext["data"]["packageJson"]["name"].as_str()) + .filter(|val| !val.is_empty()) + { + return Some(pkg.to_owned()); + } + } + None +} + +#[inline] +fn is_valid_scoped_package_name(name: &str) -> bool { + let name = name.trim(); + name.len() <= 214 + && !name.contains('\\') + && !name.contains("..") + && name + .strip_prefix('@') + .and_then(|rest| rest.split_once('/')) + .is_some_and(|(scope, pkg)| !scope.is_empty() && !pkg.is_empty()) +} + +fn scope_has_components(scope_dir: &Path) -> bool { + fs::read(scope_dir.join("index.json")) + .ok() + .and_then(|bytes| bit_object_import::parse_jsonc(&bytes).ok()) + .and_then(|idx| { + idx["components"] + .as_array() + .or_else(|| idx.as_array()) + .cloned() + }) + .is_some_and(|comps| { + comps + .iter() + .any(|item| item["isSymlink"] != true) + }) +} + +#[inline] +fn exists_in_scope_index(scope_dir: &Path, id: &str) -> bool { + resolve_component_entry(scope_dir, id.split('@').next().unwrap_or(id)).is_some() +} + +fn refactor_component( + comp: &WorkspaceComponent, + old_pkg: &str, + new_pkg: &str, + writes: &mut Vec<(PathBuf, Vec)>, +) -> Result { + let mut comp_changed = false; + for rel_file in &comp.files { + let path = comp.dir.join(rel_file); + let bytes = fs::read(&path)?; + if bytes[..bytes.len().min(512)].contains(&0) { + continue; + } + let content = String::from_utf8_lossy(&bytes); + if !content.contains(old_pkg) { + continue; + } + let updated = replace_package_name(&content, old_pkg, new_pkg)?; + if updated != content { + writes.push((path, updated.into_bytes())); + comp_changed = true; + } + } + Ok(comp_changed) +} + +fn replace_package_name(input: &str, old_name: &str, new_name: &str) -> Result { + let escaped = old_name.replace('.', r"\.").replace('+', r"\+"); + let mut current = input.to_owned(); + for quote in ['\'', '"'] { + let pattern = format!("({quote}~?){escaped}(/|{quote})"); + let regex = + regress::Regex::new(&pattern).map_err(|error| Failure::Declined(error.to_string()))?; + let mut next = String::with_capacity(current.len()); + let mut last = 0; + for mat in regex.find_iter(¤t) { + next.push_str(¤t[last..mat.range.start]); + if let Some(Some(prefix)) = mat.captures.first() { + next.push_str(¤t[prefix.clone()]); + } + next.push_str(new_name); + if let Some(Some(suffix)) = mat.captures.get(1) { + next.push_str(¤t[suffix.clone()]); + } + last = mat.range.end; + } + next.push_str(¤t[last..]); + current = next; + } + Ok(current) +} diff --git a/native/cli/src/commands/refactor/tests.rs b/native/cli/src/commands/refactor/tests.rs new file mode 100644 index 000000000000..0e8f5ffaae80 --- /dev/null +++ b/native/cli/src/commands/refactor/tests.rs @@ -0,0 +1,80 @@ +//! Unit tests for `bit refactor` [`Action`] CLI subcommand parsing ([`parse_options`]) and package-name import rewriting ([`replace_package_name`]). +//! +//! Verifies [`Failure`] reporting, [`extract_version_package_name`], and [`prepare_bare_scope_dependency_name`]. + +use super::{ + Action, Failure, extract_version_package_name, parse_options, + prepare_bare_scope_dependency_name, replace_package_name, +}; +use serde_json::json; +use std::ffi::OsString; + +/// Parses `words` as [`OsString`] CLI arguments through [`parse_options`]. +fn parse(words: &[&str]) -> Option { + let args = words + .iter() + .map(OsString::from) + .collect::>(); + parse_options(&args) +} + +/// Verifies `bit refactor` CLI parsing for `dependency-name` and unrecognized subcommands. +#[test] +fn parses_dependency_name_and_unrecognized_subcommands() { + assert_eq!( + parse(&["refactor", "dependency-name", "@scope/old", "new-comp", "--no-color"]), + Some(Action::DependencyName { + old_id: "@scope/old".to_owned(), + new_id: "new-comp".to_owned(), + }), + ); + assert_eq!( + parse(&["refactor", "unknown-sub", "--no-color"]), + Some(Action::Unrecognized("unknown-sub".to_owned())), + ); + assert_eq!(parse(&["refactor"]), None); + assert_eq!(parse(&["refactor", "--help"]), None); + assert_eq!(parse(&["refactor", "dependency-name", "only-one"]), None); +} + +#[test] +fn extracts_package_name_from_version_extensions() { + let ver = json!({ + "extensions": [ + { + "name": "teambit.dependencies/dependency-resolver", + "data": { "packageName": "@org/my-scope.comp1" } + } + ] + }); + assert_eq!(extract_version_package_name(&ver).as_deref(), Some("@org/my-scope.comp1")); +} + +#[test] +fn replaces_single_double_tilde_and_subpath_package_imports() { + let source = r#" +import a from '@scope/old'; +import b from "@scope/old/sub/path"; +@import '~@scope/old/styles.scss'; +const keep = '@scope/older'; +"#; + let replaced = + replace_package_name(source, "@scope/old", "@scope/new").ok().unwrap_or_default(); + assert!(replaced.contains("from '@scope/new';")); + assert!(replaced.contains(r#"from "@scope/new/sub/path";"#)); + assert!(replaced.contains("'~@scope/new/styles.scss'")); + assert!(replaced.contains("'@scope/older'")); + + let temp = tempfile::tempdir().unwrap(); + let ok = prepare_bare_scope_dependency_name(temp.path(), "@scope/old", "@scope/new").unwrap(); + assert_eq!(ok, "the following components have been changed (@scope/old => @scope/new):\n"); + assert!(matches!( + prepare_bare_scope_dependency_name(temp.path(), "@scope/same", "@scope/same"), + Err(Failure::User(msg)) if msg.contains("are the same"), + )); +} + +#[test] +fn extract_version_package_name_returns_none_when_extension_is_absent() { + assert_eq!(extract_version_package_name(&json!({"extensions": []})), None); +} diff --git a/native/cli/src/commands/remote.rs b/native/cli/src/commands/remote.rs new file mode 100644 index 000000000000..82ea7c2f6863 --- /dev/null +++ b/native/cli/src/commands/remote.rs @@ -0,0 +1,132 @@ +//! Native `bit remote` (`bit remote`, `bit remote list`, `bit remote add`, `bit remote del`). +//! +//! Coordinates [`COMMAND`], [`options`], and [`stores`] to inspect or mutate global and scope remotes into [`Outcome`]. + +mod options; +mod stores; +#[cfg(test)] +mod tests; + +use crate::{ + commands::{Command, color::Chalk, workspace_command}, + history::History, + native::Outcome, + workspace, +}; +use options::Options; +use std::{ + env, + ffi::OsString, + io::{self, Write}, + process::ExitCode, +}; +use stores::Failure; + +/// Descriptor for the native `bit remote` command. +pub const COMMAND: Command = Command { name: "remote", aliases: &[], run }; + +/// Entry point for `bit remote`. +fn run(args: &[OsString]) -> io::Result { + let Some(options) = Options::parse(args) else { + return Ok(Outcome::Declined("arguments outside the native `remote` grammar".to_owned())); + }; + let cwd = env::current_dir()?; + let root = workspace::root(&cwd); + if let Some(root) = &root + && invalid_workspace_config(root) + { + return Ok(Outcome::Declined("invalid workspace configuration".to_owned())); + } + let chalk = Chalk::detect(args); + match stores::prepare(&options, args) { + Ok(prepared) => execute(root.as_deref(), args, options.command, chalk, &prepared), + Err(Failure::Declined(reason)) => Ok(Outcome::Declined(format!("remote: {reason}"))), + Err(Failure::User(message)) => { + if env::var_os("CI") + .is_some_and(|value| { + let text = value.to_string_lossy(); + let trimmed = text + .trim() + .trim_matches(['"', '\'']) + .trim(); + !trimmed.is_empty() + && !trimmed.eq_ignore_ascii_case("0") + && !trimmed.eq_ignore_ascii_case("false") + && !trimmed.eq_ignore_ascii_case("no") + && !trimmed.eq_ignore_ascii_case("off") + }) + { + return Ok(Outcome::Declined("CI remote error stack".to_owned())); + } + execute_error(root.as_deref(), args, options.command, chalk, &message) + } + } +} + +/// Returns `true` when `workspace.jsonc` inside `root` is missing, malformed, or lacks a valid `defaultScope`. +#[inline] +fn invalid_workspace_config(root: &std::path::Path) -> bool { + let Ok(bytes) = std::fs::read(root.join("workspace.jsonc")) else { + return true; + }; + let Ok(config) = bit_workspace_config::parse_jsonc(&bytes) else { + return true; + }; + super::aspect::workspace_default_scope(&config).is_err() +} + +fn execute( + root: Option<&std::path::Path>, + args: &[OsString], + command: &str, + chalk: Chalk, + prepared: &stores::Prepared, +) -> io::Result { + let history = root.map(|root| History::start(root, args)); + let output = workspace_command::loader_output(chalk, false); + output.restart(&format!(r#"running command "{command}"..."#)); + let committed = prepared.commit(); + output.stop(); + match committed { + Ok(()) => { + let mut stdout = io::stdout().lock(); + writeln!(stdout, "{}", prepared.report)?; + stdout.flush()?; + if let Some(history) = history { + history.end(true); + } + Ok(Outcome::Exit(ExitCode::SUCCESS)) + } + Err(error) => { + if let Some(history) = history { + history.end(false); + } + let message = error.to_string(); + let mut stderr = io::stderr().lock(); + writeln!(stderr, "{}", chalk.red(message.trim()))?; + stderr.flush()?; + Ok(Outcome::Exit(ExitCode::FAILURE)) + } + } +} + +#[inline] +fn execute_error( + root: Option<&std::path::Path>, + args: &[OsString], + command: &str, + chalk: Chalk, + message: &str, +) -> io::Result { + let history = root.map(|root| History::start(root, args)); + let output = workspace_command::loader_output(chalk, false); + output.restart(&format!(r#"running command "{command}"..."#)); + output.stop(); + if let Some(history) = history { + history.end(false); + } + let mut stderr = io::stderr().lock(); + writeln!(stderr, "{}", chalk.red(message.trim()))?; + stderr.flush()?; + Ok(Outcome::Exit(ExitCode::FAILURE)) +} diff --git a/native/cli/src/commands/remote/options.rs b/native/cli/src/commands/remote/options.rs new file mode 100644 index 000000000000..d644edcff07b --- /dev/null +++ b/native/cli/src/commands/remote/options.rs @@ -0,0 +1,117 @@ +//! Narrow grammar of `RemoteCmd` and its `add`, `del`, and `list` subcommands. +//! +//! Defines [`Action`] (`List`, `Add`, `Del`) and [`Options`] parsed from [`OsString`] CLI arguments. + +use std::ffi::OsString; + +/// Subcommand action for `bit remote` (`list`, `add `, or `del `). +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) enum Action { + List, + Add(String), + Del(String), +} + +/// Validated command-line options for `bit remote`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct Options { + pub(super) command: &'static str, + pub(super) action: Action, + pub(super) global: bool, +} + +impl Options { + /// Parses `args` into [`Options`] when they fit the native `bit remote` (`list`, `add`, `del`) grammar. + pub(super) fn parse(args: &[OsString]) -> Option { + let tokens = args + .iter() + .skip(1) + .map(|arg| arg.to_str().map(str::trim)) + .filter(|token| !token.is_some_and(is_no_color_flag)) + .collect::>>()?; + let (command, rest) = split_subcommand(&tokens)?; + let (global, values) = parse_flags_and_values(rest)?; + let action = match (command, values.as_slice()) { + ("remote" | "list", []) => Action::List, + ("add", [url]) + if valid_target(url) + && url + .strip_prefix("file://") + .is_some_and(|path| !path.trim().is_empty()) => + { + Action::Add((*url).to_owned()) + } + ("del", [name]) if valid_target(name) => Action::Del((*name).to_owned()), + _ => return None, + }; + Some(Self { command, action, global }) + } +} + +#[inline] +fn split_subcommand<'a>(tokens: &'a [&'a str]) -> Option<(&'static str, &'a [&'a str])> { + match tokens.split_first() { + Some((&"list", rest)) => Some(("list", rest)), + Some((&"add", rest)) => Some(("add", rest)), + Some((&"del", rest)) => Some(("del", rest)), + Some((arg, _)) if !arg.starts_with('-') => None, + _ => Some(("remote", tokens)), + } +} + +fn parse_flags_and_values<'a>(tokens: &[&'a str]) -> Option<(bool, Vec<&'a str>)> { + let mut global = false; + let mut values = Vec::with_capacity(tokens.len()); + let mut iter = tokens.iter().copied(); + while let Some(token) = iter.next() { + match token { + "--" => { + consume_trailing_values(iter, &mut values)?; + break; + } + "-g" | "--global" | "--global=true" => global = true, + "--global=false" => global = false, + flag if flag.starts_with('-') => return None, + value => values.push(value), + } + } + Some((global, values)) +} + +#[inline] +fn consume_trailing_values<'a>( + iter: impl Iterator, + values: &mut Vec<&'a str>, +) -> Option<()> { + for rest in iter { + if rest.starts_with('-') || rest.is_empty() { + return None; + } + values.push(rest); + } + Some(()) +} + +#[inline] +fn is_no_color_flag(token: &str) -> bool { + matches!( + token, + "--no-color" + | "--no-color=true" + | "--no-color=false" + | "--color" + | "--color=true" + | "--color=false" + | "--color=always" + | "--color=never", + ) +} + +#[inline] +fn valid_target(value: &str) -> bool { + !value.is_empty() + && value.len() <= 4096 + && value != "__proto__" + && !value.contains(char::is_whitespace) + && !value.contains(['!', '\0']) +} diff --git a/native/cli/src/commands/remote/stores.rs b/native/cli/src/commands/remote/stores.rs new file mode 100644 index 000000000000..45db0d715852 --- /dev/null +++ b/native/cli/src/commands/remote/stores.rs @@ -0,0 +1,363 @@ +//! Scope discovery (`findScopePath`), `GlobalRemotes`, and `ScopeJson` persistence for `bit remote`. +//! +//! Executes [`Action`] and [`Options`] ([`Failure`]) against scope and global remote stores and formats output with [`table`]. + +use super::options::{Action, Options}; +use crate::commands::{ + color::Chalk, list::table, workspace_command, workspace_command::format::Formatter, +}; +use serde_json::{Map, Value, json}; +use std::{ + env, + ffi::OsString, + fs, io, + path::{Component, Path, PathBuf}, +}; + +/// Filename for the global remotes store under the global config directory. +const GLOBAL_REMOTES_FILE: &str = "global-remotes.json"; +/// Recognized top-level property keys in `scope.json`. +const SCOPE_KEYS: [&str; 8] = + ["name", "remotes", "resolverPath", "license", "groupName", "version", "lanes", "config"]; + +/// Error classification distinguishing declined remote actions from user-facing errors. +pub(super) enum Failure { + Declined(String), + User(String), +} + +impl From for Failure { + #[inline] + fn from(error: io::Error) -> Self { + Self::Declined(error.to_string()) + } +} + +/// Staged terminal report and pending file writes for a `bit remote` operation. +pub(super) struct Prepared { + pub(super) report: String, + writes: Vec<(PathBuf, Vec)>, +} + +impl Prepared { + #[inline] + pub(super) fn commit(&self) -> io::Result<()> { + for (path, bytes) in &self.writes { + if let Some(parent) = path + .parent() + .filter(|dir| !dir.as_os_str().is_empty()) + { + fs::create_dir_all(parent)?; + } + fs::write(path, bytes)?; + } + Ok(()) + } +} + +pub(super) fn prepare(options: &Options, args: &[OsString]) -> Result { + let cwd = env::current_dir()?; + let chalk = Chalk::detect(args); + match &options.action { + Action::List => prepare_list(options.global, &cwd, args, chalk), + Action::Add(url) => prepare_add(url, options.global, &cwd, chalk), + Action::Del(name) => prepare_del(name, options.global, &cwd, chalk), + } +} + +fn prepare_list( + global: bool, + cwd: &Path, + args: &[OsString], + chalk: Chalk, +) -> Result { + let mut writes = Vec::new(); + let remotes = if global { + load_global_remotes(&mut writes)? + } else { + let (_, scope_source) = load_local_scope_json(cwd, chalk)?; + let scope_remotes = extract_remotes(scope_source.get("remotes"))?; + let mut combined = load_global_remotes(&mut writes)?; + for (name, host) in scope_remotes { + combined.insert(name, host); + } + combined + }; + let ordered = bit_workspace_config::jsonc::Node::from_value(&Value::Object(remotes)).to_value(); + let remotes = ordered.as_object().ok_or_else(|| Failure::Declined("remote map".to_owned()))?; + let report = if remotes.is_empty() { + Formatter::new(chalk).hint("no configured remotes found in scope") + } else { + let rows = remotes + .iter() + .filter_map(|(name, host)| Some(vec![name.clone(), host.as_str()?.to_owned()])) + .collect::>(); + table::render(&["scope name", "host"], &rows, table::colors(args)) + }; + Ok(Prepared { report, writes }) +} + +fn prepare_add(url: &str, global: bool, cwd: &Path, chalk: Chalk) -> Result { + let raw_target = url + .strip_prefix("file://") + .ok_or_else(|| Failure::Declined("non-file remote URL".to_owned()))?; + let target_dir = resolve_path(cwd, raw_target); + let (_, target_scope) = load_remote_scope_json(&target_dir, chalk)?; + let remote_name = target_scope + .get("name") + .and_then(Value::as_str) + .map(str::trim) + .filter(|name| !name.is_empty() && *name != "__proto__") + .ok_or_else(|| Failure::Declined("remote scope name".to_owned()))? + .to_owned(); + let mut writes = Vec::new(); + if global { + let mut remotes = load_global_remotes(&mut writes)?; + remotes.insert(remote_name.clone(), Value::String(url.to_owned())); + writes.push((global_remotes_path(), render_json(&Value::Object(remotes))?)); + } else { + let (scope_dir, mut scope_source) = load_local_scope_json(cwd, chalk)?; + let mut remotes = extract_remotes(scope_source.get("remotes"))?; + remotes.insert(remote_name.clone(), Value::String(url.to_owned())); + scope_source.insert("remotes".to_owned(), Value::Object(remotes)); + writes.push((scope_dir.join("scope.json"), render_scope_json(&scope_source)?)); + } + let report = Formatter::new(chalk) + .success_summary(&format!( + "added remote scope '{}' with host '{}'", + chalk.bold(&remote_name), + chalk.bold(url), + )); + Ok(Prepared { report, writes }) +} + +fn prepare_del(name: &str, global: bool, cwd: &Path, chalk: Chalk) -> Result { + let mut writes = Vec::new(); + if global { + let mut remotes = load_global_remotes(&mut writes)?; + if remotes.shift_remove(name).is_none() { + return Err(Failure::User(format!( + r#"remote "{name}" was not found globally, to remove a local remote, please omit the "--global" flag"#, + ))); + } + writes.push((global_remotes_path(), render_json(&Value::Object(remotes))?)); + } else { + let (scope_dir, mut scope_source) = load_local_scope_json(cwd, chalk)?; + let mut remotes = extract_remotes(scope_source.get("remotes"))?; + if remotes.shift_remove(name).is_none() { + return Err(Failure::User(format!( + r#"remote "{name}" was not found locally, to remove a global remote, please use "--global" flag"#, + ))); + } + scope_source.insert("remotes".to_owned(), Value::Object(remotes)); + writes.push((scope_dir.join("scope.json"), render_scope_json(&scope_source)?)); + } + let report = + Formatter::new(chalk).success_summary(&format!("removed remote {}", chalk.bold(name))); + Ok(Prepared { report, writes }) +} + +#[inline] +fn global_remotes_path() -> PathBuf { + bit_workspace_config::global_config_dir().join(GLOBAL_REMOTES_FILE) +} + +fn load_global_remotes( + writes: &mut Vec<(PathBuf, Vec)>, +) -> Result, Failure> { + let path = global_remotes_path(); + if !path.exists() { + writes.push((path, b"{}".to_vec())); + return Ok(Map::new()); + } + let bytes = fs::read(&path)?; + if bytes.iter().all(u8::is_ascii_whitespace) { + return Ok(Map::new()); + } + let value: Value = bit_workspace_config::parse_jsonc(&bytes) + .map_err(|error| Failure::Declined(error.to_string()))?; + extract_remotes(Some(&value)) +} + +#[inline] +fn load_local_scope_json( + from_path: &Path, + chalk: Chalk, +) -> Result<(PathBuf, Map), Failure> { + load_scope_json(from_path, chalk, true) +} + +#[inline] +fn load_remote_scope_json( + from_path: &Path, + chalk: Chalk, +) -> Result<(PathBuf, Map), Failure> { + load_scope_json(from_path, chalk, false) +} + +fn load_scope_json( + from_path: &Path, + chalk: Chalk, + allow_workspace_init: bool, +) -> Result<(PathBuf, Map), Failure> { + let Some(scope_dir) = find_scope_path(from_path) else { + if allow_workspace_init && crate::workspace::root(from_path).is_some() { + return Err(Failure::Declined("workspace scope not initialized yet".to_owned())); + } + return Err(Failure::User(format!( + "error: scope not found at {}", + chalk.bold(&from_path.to_string_lossy()), + ))); + }; + let scope_json_path = scope_dir.join("scope.json"); + if !scope_json_path.exists() { + return Err(Failure::Declined("scope.json missing".to_owned())); + } + let value: Value = bit_workspace_config::parse_jsonc(&fs::read(&scope_json_path)?) + .map_err(|error| Failure::Declined(error.to_string()))?; + let Value::Object(object) = value else { + return Err(Failure::Declined("scope.json is not an object".to_owned())); + }; + if object + .keys() + .any(|key| !SCOPE_KEYS.contains(&key.as_str())) + { + return Err(Failure::Declined("scope.json has unknown keys".to_owned())); + } + Ok((scope_dir, object)) +} + +fn extract_remotes(remotes: Option<&Value>) -> Result, Failure> { + let Some(remotes) = remotes.filter(|value| !value.is_null()) else { + return Ok(Map::new()); + }; + let object = remotes + .as_object() + .ok_or_else(|| Failure::Declined("remotes is not an object".to_owned()))?; + if object.contains_key("__proto__") + || object + .keys() + .any(|key| key.trim().is_empty()) + || !object + .values() + .all(|val| { + val.as_str() + .is_some_and(|url| !url.trim().is_empty()) + }) + { + return Err(Failure::Declined("unsupported remote entry".to_owned())); + } + Ok(object.clone()) +} + +fn render_scope_json(source: &Map) -> Result, Failure> { + let mut plain = Map::with_capacity(SCOPE_KEYS.len()); + for key in SCOPE_KEYS { + match key { + "remotes" => { + let remotes = source + .get("remotes") + .and_then(Value::as_object) + .cloned() + .unwrap_or_default(); + plain.insert(key.to_owned(), Value::Object(remotes)); + } + "lanes" => { + let lanes = source + .get("lanes") + .filter(|value| bit_workspace_config::truthy(value)) + .cloned() + .unwrap_or_else(|| json!({ "current": "main", "tracking": [], "new": [] })); + plain.insert(key.to_owned(), lanes); + } + _ => { + if let Some(value) = + source.get(key).filter(|value| bit_workspace_config::truthy(value)) + { + plain.insert(key.to_owned(), value.clone()); + } + } + } + } + render_json(&Value::Object(plain)) +} + +#[inline] +fn render_json(value: &Value) -> Result, Failure> { + let ordered = bit_workspace_config::jsonc::Node::from_value(value).to_value(); + bit_workspace_config::render_scope_json(&ordered).map_err(Failure::from) +} + +/// `resolveHomePath` followed by `path.resolve(cwd, path)`. +pub(super) fn resolve_path(cwd: &Path, raw: &str) -> PathBuf { + let expanded = if raw == "~" { + workspace_command::home() + } else if let Some(rest) = raw + .strip_prefix("~/") + .or_else(|| raw.strip_prefix(r"~\")) + { + workspace_command::home().join(rest) + } else { + PathBuf::from(raw) + }; + normalize_path(&cwd.join(expanded)) +} + +fn normalize_path(path: &Path) -> PathBuf { + let mut normalized = PathBuf::new(); + for component in path.components() { + match component { + Component::CurDir => {} + Component::ParentDir => { + let _ = normalized.pop(); + } + other => normalized.push(other.as_os_str()), + } + } + normalized +} + +/// `findScopePath(fromPath)` (`components/modules/find-scope-path/find-scope-path.ts`). +pub(super) fn find_scope_path(from_path: &Path) -> Option { + if !from_path.exists() { + return None; + } + for current in from_path.ancestors() { + if let Some(found) = scope_at_dir(current) { + return found; + } + if is_git_file(current) { + return None; + } + } + None +} + +fn scope_at_dir(current: &Path) -> Option> { + if is_scope_dir(current) { + if current + .file_name() + .is_some_and(|name| name == ".git") + { + return Some(None); + } + let dot_bit = current.join(".bit"); + return Some(Some(if is_scope_dir(&dot_bit) { dot_bit } else { current.to_path_buf() })); + } + let dot_bit = current.join(".bit"); + if is_scope_dir(&dot_bit) { + return Some(Some(dot_bit)); + } + let git_bit = current.join(".git/bit"); + is_scope_dir(&git_bit).then_some(Some(git_bit)) +} + +#[inline] +fn is_scope_dir(dir: &Path) -> bool { + fs::symlink_metadata(dir.join("objects")).is_ok_and(|metadata| metadata.is_dir()) +} + +#[inline] +fn is_git_file(dir: &Path) -> bool { + fs::symlink_metadata(dir.join(".git")).is_ok_and(|metadata| metadata.is_file()) +} diff --git a/native/cli/src/commands/remote/tests.rs b/native/cli/src/commands/remote/tests.rs new file mode 100644 index 000000000000..633805bd85e2 --- /dev/null +++ b/native/cli/src/commands/remote/tests.rs @@ -0,0 +1,65 @@ +//! Unit tests for `bit remote` [`Action`] and [`Options`] CLI subcommand parsing and URL validation. +//! +//! Verifies `list`, `add`, and `del` subcommands and `--global` flag handling. + +use super::options::{Action, Options}; +use std::ffi::OsString; + +/// Parses `words` as [`OsString`] CLI arguments through [`Options::parse`]. +fn parse(words: &[&str]) -> Option { + let args = words + .iter() + .map(OsString::from) + .collect::>(); + Options::parse(&args) +} + +/// Verifies `bit remote` CLI parsing for `list`, default `remote`, and `--global` / `-g`. +#[test] +fn parses_list_and_default_subcommand() { + assert_eq!( + parse(&["remote"]), + Some(Options { command: "remote", action: Action::List, global: false }), + ); + assert_eq!( + parse(&["remote", "--global"]), + Some(Options { command: "remote", action: Action::List, global: true }), + ); + assert_eq!( + parse(&["remote", "list", "-g"]), + Some(Options { command: "list", action: Action::List, global: true }), + ); +} + +#[test] +fn parses_add_and_del_subcommands() { + assert_eq!( + parse(&["remote", "add", "file:///tmp/scope", "--global"]), + Some(Options { + command: "add", + action: Action::Add("file:///tmp/scope".to_owned()), + global: true, + }), + ); + assert_eq!( + parse(&["remote", "del", "my-scope"]), + Some(Options { command: "del", action: Action::Del("my-scope".to_owned()), global: false }), + ); +} + +#[test] +fn declines_unsupported_remote_arguments() { + assert_eq!(parse(&["remote", "--help"]), None); + assert_eq!(parse(&["remote", "add", "http://localhost:3000"]), None); + assert_eq!(parse(&["remote", "add"]), None); + assert_eq!(parse(&["remote", "del"]), None); + assert_eq!(parse(&["remote", "unknown"]), None); +} + +#[test] +fn empty_args_slice_defaults_to_remote_list_action() { + assert_eq!( + parse(&[]), + Some(Options { command: "remote", action: Action::List, global: false }), + ); +} diff --git a/native/cli/src/commands/remote_action.rs b/native/cli/src/commands/remote_action.rs new file mode 100644 index 000000000000..7beb95f56bb9 --- /dev/null +++ b/native/cli/src/commands/remote_action.rs @@ -0,0 +1,189 @@ +//! Configured HTTP actions and native file pending-directory plans; preparation performs no remote requests. +//! +//! [`Plan`] coordinates [`startup`], [`file`], and [`errors`] to execute remote export/delete actions into a [`Report`]. +pub(super) mod startup; + +mod errors; +mod file; + +use super::workspace_command::{self, NodeCli, results::Report}; +use crate::{native::Outcome, workspace}; +use bit_client_transfer::{ + GlobalConfig, + http::Network, + remotes::{Remote, Remotes}, +}; +use bit_workspace_config::{global_config_dir, parse_jsonc, read_global_config}; +use serde_json::Value; +use std::{ + ffi::OsString, + fs, io, + path::{Path, PathBuf}, +}; + +/// Prepared remote action execution plan holding the scope/workspace root, [`Target`], and concurrency. +pub(super) struct Plan { + root: PathBuf, + workspace: bool, + target: Target, + pub(super) concurrency: usize, +} + +/// Returns `true` when `name` names a built-in pending-directory action supported on file remotes. +#[inline] +pub(super) fn supports_file(name: &str) -> bool { + file::supports(name.trim()) +} + +/// Resolved HTTP or local filesystem remote target for [`Plan`]. +enum Target { + Http { url: String, network: Network }, + File(PathBuf), +} + +impl Plan { + #[inline] + pub(super) fn file_scope(&self) -> Option<&Path> { + match &self.target { + Target::File(scope) => Some(scope), + Target::Http { .. } => None, + } + } + pub(super) async fn action(&self, name: &str, payload: &Value) -> io::Result { + let name = name.trim(); + match &self.target { + Target::Http { url, network } => { + network.action(url, name, payload).await.map_err(errors::format) + } + Target::File(scope) => { + let scope = scope.clone(); + let name = name.to_owned(); + let payload = payload.clone(); + tokio::task::spawn_blocking(move || file::action(&scope, &name, &payload)) + .await + .map_err(io::Error::other)? + } + } + } + + #[inline] + pub(super) fn execute( + &self, + args: &[OsString], + command: &str, + work: impl FnOnce(&bit_install::output::Output) -> io::Result, + ) -> io::Result { + let command = command.trim(); + if self.workspace { + workspace_command::execute_then(&self.root, args, command, work) + } else { + workspace_command::execute_then_scope(&self.root, args, command, work) + } + } +} + +pub(super) fn prepare( + cwd: &Path, + remote: &str, + load_workspace: bool, + pending_deletion: bool, +) -> io::Result> { + let workspace = workspace::root(cwd); + let Some(scope) = find_scope(cwd, workspace.as_deref()) else { return Ok(None) }; + let scope_json: Value = parse_jsonc(&fs::read(scope.join("scope.json"))?)?; + let config_dir = global_config_dir(); + let global: GlobalConfig = serde_json::from_value(read_global_config(&config_dir)?)?; + let workspace_json = workspace + .as_ref() + .map(|root| parse_jsonc(&fs::read(root.join("workspace.jsonc"))?)) + .transpose()? + .unwrap_or(Value::Null); + if !startup::supported(workspace.as_deref(), &scope, &workspace_json, load_workspace)? { + return Ok(None); + } + let global = bit_client_transfer::config::effective( + global, + &scope_json["config"], + &Value::Object(bit_workspace_config::workspace_values(&workspace_json)), + ); + let global_only = load_workspace && workspace.is_none(); + let Some(target) = + resolve_target(remote, &scope_json, &config_dir, &global, global_only, pending_deletion)? + else { + return Ok(None); + }; + Ok(Some(Plan { + workspace: workspace.is_some(), + root: workspace.unwrap_or(scope), + target, + concurrency: bit_client_transfer::fetch::concurrency(&global), + })) +} + +fn find_scope(cwd: &Path, workspace: Option<&Path>) -> Option { + workspace.map_or_else( + || { + cwd.ancestors() + .find_map(|path| { + if path.join("scope.json").is_file() { + return Some(path.to_path_buf()); + } + let nested = workspace::scope_path(path); + nested + .join("scope.json") + .is_file() + .then_some(nested) + }) + }, + |root| { + let scope = workspace::scope_path(root); + scope + .join("scope.json") + .is_file() + .then_some(scope) + }, + ) +} + +fn resolve_target( + remote: &str, + scope_json: &Value, + config_dir: &Path, + global: &GlobalConfig, + global_only: bool, + pending_deletion: bool, +) -> io::Result> { + let trimmed_remote = remote.trim(); + if trimmed_remote.is_empty() { + return Ok(None); + } + let source = if global_only { &Value::Null } else { scope_json }; + let remotes = Remotes::load(source, config_dir, global)?; + let Some(remote) = remotes.configured(trimmed_remote)? else { return Ok(None) }; + let requester = (!global_only) + .then(|| { + scope_json["name"] + .as_str() + .map(str::trim) + .filter(|name| !name.is_empty()) + }) + .flatten(); + target(remote, global, requester, pending_deletion) +} + +fn target( + remote: Remote, + global: &GlobalConfig, + name: Option<&str>, + pending_deletion: bool, +) -> io::Result> { + match remote { + Remote::File(path) if pending_deletion => Ok(file::prepare(&path)?.map(Target::File)), + Remote::File(_) => Ok(None), + Remote::Http(url) => { + let version = NodeCli::find()?.version; + let network = Network::new(global, &version, name, None)?; + Ok(Some(Target::Http { url, network })) + } + } +} diff --git a/native/cli/src/commands/remote_action/errors.rs b/native/cli/src/commands/remote_action/errors.rs new file mode 100644 index 000000000000..0564886f7da1 --- /dev/null +++ b/native/cli/src/commands/remote_action/errors.rs @@ -0,0 +1,131 @@ +//! `MergeConflictOnRemote` terminal formatting for structured HTTP action failures. +//! +//! [`format`] decorates remote merge conflict payloads ([`deleted_report`]) with [`Chalk`] colors. +use crate::commands::color::Chalk; +use serde_json::Value; +use std::{env, io}; + +/// Formats a structured `MergeConflictOnRemote` HTTP action error with [`Chalk`] colors when present. +pub(super) fn format(error: io::Error) -> io::Error { + let Some(value) = bit_client_transfer::http::action_conflict(&error) else { return error }; + let chalk = Chalk::detect(&env::args_os().skip(1).collect::>()); + let conflicts = value["idsAndVersionsWithConflicts"] + .as_array() + .map_or(&[][..], Vec::as_slice); + let updates = value["idsNeedUpdate"] + .as_array() + .map_or(&[][..], Vec::as_slice); + let mut output = Vec::with_capacity(3); + deleted_report(conflicts, updates, chalk, &mut output); + conflict_report(conflicts, chalk, &mut output); + update_report(updates, chalk, &mut output); + let rendered = output.concat(); + if rendered.trim().is_empty() { error } else { io::Error::other(rendered) } +} + +/// Appends the deleted-on-remote recovery guide to `output` when any conflict or update entry has `deleted: true`. +fn deleted_report(conflicts: &[Value], updates: &[Value], chalk: Chalk, output: &mut Vec) { + let deleted: Vec<_> = conflicts + .iter() + .chain(updates) + .filter(|value| deleted_flag(value)) + .map(|value| id(value, chalk)) + .collect(); + if !deleted.is_empty() { + output.push(format!("error: the following component(s) were marked as deleted on the remote scope: {}.\n\ + the current components seem to be re-created and have a different history.\n\ + to resolve this error, you'll need to recover the deleted components, re-do your changes, then tag/snap and export.\n\ + here is a step-by-step guide:\n\ + 1) backup your current component to another directory.\n\ + 2) run \"bit recover \". it'll restore the deleted component.\n\ + 3) re-do your changes on the restored component.\n\ + 4) run \"bit tag\" or \"bit snap\" to create a new version of the component.\n\ + 5) run \"bit export\" to export the new version to the remote scope.\n", deleted.join(", "))); + } +} + +fn conflict_report(conflicts: &[Value], chalk: Chalk, output: &mut Vec) { + let conflicts: Vec<_> = conflicts + .iter() + .filter(|value| !deleted_flag(value)) + .map(|value| { + let versions = value["versions"] + .as_array() + .map_or(&[][..], Vec::as_slice) + .iter() + .map(|value| { + value + .as_str() + .map(str::trim) + .filter(|ver| !ver.is_empty()) + .unwrap_or("undefined") + }) + .collect::>() + .join(", "); + format!("{} (version(s): {versions})", id(value, chalk)) + }) + .collect(); + if !conflicts.is_empty() { + output.push(format!("error: versions conflict occurred when exporting the component(s) {} to the remote scope.\n\ + to resolve this conflict and merge your remote and local changes, please do the following:\n\ + 1) bit reset [component-pattern] [--all]\n\ + 2) bit checkout head [component-pattern]\n\ + once your changes are merged with the new remote version, please tag and export a new version of the component to the remote scope.", conflicts.join(", "))); + } +} + +fn update_report(updates: &[Value], chalk: Chalk, output: &mut Vec) { + let updates: Vec<_> = updates + .iter() + .filter(|value| !deleted_flag(value)) + .map(|value| { + let lane = value["lane"] + .as_str() + .or_else(|| value["lane"]["name"].as_str()) + .map(str::trim) + .filter(|lane| !lane.is_empty()) + .map_or_else(String::new, |lane| format!(" (lane: {lane})")); + format!("{}{lane}", id(value, chalk)) + }) + .collect(); + if !updates.is_empty() { + output.push(format!( + "merge error occurred when exporting the component(s) {} to the remote scope.\n\ + to resolve this error, please re-import the above components.\n\ + if the component is up to date, run \"bit status\" to make sure it's not merge-pending", + updates.join(", "), + )); + } +} + +#[inline] +fn deleted_flag(value: &Value) -> bool { + value["isDeleted"].as_bool() == Some(true) +} +#[inline] +fn id(value: &Value, chalk: Chalk) -> String { + if let Some(raw) = value["id"] + .as_str() + .map(str::trim) + .filter(|raw| !raw.is_empty()) + { + return chalk.bold(raw); + } + if let Some(name) = value["id"]["name"] + .as_str() + .or_else(|| value["id"]["_legacy"]["name"].as_str()) + .map(str::trim) + .filter(|name| !name.is_empty()) + { + return match value["id"]["scope"] + .as_str() + .or_else(|| value["id"]["_legacy"]["scope"].as_str()) + .map(str::trim) + .filter(|scope| !scope.is_empty()) + { + Some(scope) => chalk.bold(&format!("{scope}/{name}")), + None => chalk.bold(name), + }; + } + chalk.bold("undefined") +} diff --git a/native/cli/src/commands/remote_action/file.rs b/native/cli/src/commands/remote_action/file.rs new file mode 100644 index 000000000000..0ba4c679abba --- /dev/null +++ b/native/cli/src/commands/remote_action/file.rs @@ -0,0 +1,88 @@ +//! Built-in pending-directory actions for file remotes, without loading the published runtime. +//! +//! [`supports`] and [`prepare`] coordinate staged object commits and [`removal`] cleanup across local file-remote directories. +mod removal; + +use serde_json::{Value, json}; +use std::{ + fs, io, + path::{Path, PathBuf}, + time::Duration, +}; + +/// Returns `true` when `name` is `"PendingDirStatus"` or `"RemovePendingDir"`. +#[inline] +pub(super) fn supports(name: &str) -> bool { + matches!(name.trim(), "PendingDirStatus" | "RemovePendingDir") +} + +/// Resolves and validates the local file-remote scope directory at `path`. +pub(super) fn prepare(path: &Path) -> io::Result> { + let absolute = std::path::absolute(path)?; + let scope = crate::workspace::root(&absolute) + .map(|root| crate::workspace::scope_path(&root)) + .or_else(|| { + absolute + .ancestors() + .find(|candidate| candidate.join("scope.json").is_file()) + .map(Path::to_path_buf) + }); + if let Some(scope) = &scope { + let Ok(bytes) = fs::read(scope.join("scope.json")) else { return Ok(None) }; + let Ok(json) = bit_object_import::parse_jsonc(&bytes) else { return Ok(None) }; + if json["name"] + .as_str() + .is_none_or(|name| name.trim().is_empty()) + || !scope.join("objects").is_dir() + { + return Ok(None); + } + } + Ok(scope) +} + +/// Executes `"PendingDirStatus"` or `"RemovePendingDir"` against the local file-remote `scope`. +pub(super) fn action(scope: &Path, name: &str, payload: &Value) -> io::Result { + let name = name.trim(); + if !supports(name) { + return Err(io::Error::other("unsupported native file action")); + } + let path = pending_path(scope, payload.get("clientId"))?; + let existed = fs::symlink_metadata(&path).is_ok(); + if name == "PendingDirStatus" { + return Ok(json!({"exists":existed})); + } + match removal::remove(&path) { + Err(error) if error.kind() == io::ErrorKind::DirectoryNotEmpty => { + std::thread::sleep(Duration::from_secs(1)); + removal::remove(&path)?; + } + result => result?, + } + Ok(json!({"existed":existed})) +} + +#[inline] +fn pending_path(scope: &Path, value: Option<&Value>) -> io::Result { + let valid = value + .and_then(Value::as_str) + .map(str::trim) + .filter(|id| { + !id.is_empty() + && !matches!(*id, "." | "..") + && id.encode_utf16().count() <= 4096 + && !id + .chars() + .any(|character| { + matches!( + character, + '/' | '\\' | '\0' | '<' | '>' | '|' | '?' | '*' | ':' | '"', + ) + }) + }); + let Some(id) = valid else { + let shown = value.map_or_else(|| "undefined".to_owned(), Value::to_string); + return Err(io::Error::other(format!("invalid clientId: {shown}"))); + }; + Ok(scope.join("pending-objects").join(id)) +} diff --git a/native/cli/src/commands/remote_action/file/removal.rs b/native/cli/src/commands/remote_action/file/removal.rs new file mode 100644 index 000000000000..b03dc4a1cfcb --- /dev/null +++ b/native/cli/src/commands/remote_action/file/removal.rs @@ -0,0 +1,124 @@ +//! Node recursive removal: try the directory itself before touching its children. +//! +//! [`remove`] deletes staged pending directories and files (`PendingDirStatus`, `RemovePendingDir`) with Node-compatible error semantics. +use std::{fs, io, path::Path}; + +/// Recursively removes `path` (file, symlink, or directory) with Node.js-compatible `rm` error semantics. +pub(super) fn remove(path: &Path) -> io::Result<()> { + if path.as_os_str().is_empty() { + return Ok(()); + } + match fs::symlink_metadata(path) { + Ok(metadata) if metadata.is_dir() && !metadata.file_type().is_symlink() => { + return remove_directory(path); + } + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()), + #[cfg(windows)] + Err(error) if error.kind() == io::ErrorKind::PermissionDenied => { + return repair_permissions(path, error, "lstat"); + } + _ => {} + } + match unlink(path) { + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()), + #[cfg(windows)] + Err(error) if error.kind() == io::ErrorKind::PermissionDenied => { + repair_permissions(path, error, "unlink") + } + Err(error) if error.kind() == io::ErrorKind::IsADirectory => remove_directory(path), + Err(error) => Err(failure(error, "unlink", path)), + Ok(()) => Ok(()), + } +} + +/// Attempts to remove directory `path` directly before recursively unlinking its children. +fn remove_directory(path: &Path) -> io::Result<()> { + match fs::remove_dir(path) { + Ok(()) => return Ok(()), + Err(error) + if matches!(error.kind(), io::ErrorKind::NotFound | io::ErrorKind::NotADirectory) => + { + return Ok(()); + } + Err(error) if children_may_remain(&error) => {} + Err(error) => return Err(failure(error, "rmdir", path)), + } + let children = fs::read_dir(path).map_err(|error| failure(error, "scandir", path))?; + let mut first_error = None; + for child in children { + let result = child + .map_err(|error| failure(error, "scandir", path)) + .and_then(|child| remove(&child.path())); + if let Err(error) = result { + first_error.get_or_insert(error); + } + } + if let Some(error) = first_error { + return Err(error); + } + match fs::remove_dir(path) { + Ok(()) => Ok(()), + Err(error) + if matches!(error.kind(), io::ErrorKind::NotFound | io::ErrorKind::NotADirectory) => + { + Ok(()) + } + Err(error) => Err(failure(error, "rmdir", path)), + } +} + +fn failure(error: io::Error, operation: &str, path: &Path) -> io::Error { + let (code, message) = match error.kind() { + io::ErrorKind::PermissionDenied if cfg!(windows) => ("EPERM", "operation not permitted"), + io::ErrorKind::PermissionDenied => ("EACCES", "permission denied"), + io::ErrorKind::NotFound => ("ENOENT", "no such file or directory"), + io::ErrorKind::NotADirectory => ("ENOTDIR", "not a directory"), + io::ErrorKind::IsADirectory => ("EISDIR", "illegal operation on a directory"), + io::ErrorKind::DirectoryNotEmpty => ("ENOTEMPTY", "directory not empty"), + io::ErrorKind::InvalidFilename => ("ENAMETOOLONG", "name too long"), + io::ErrorKind::AlreadyExists => ("EEXIST", "file already exists"), + _ => return error, + }; + let trimmed = operation.trim(); + let operation = if trimmed.is_empty() { "unlink" } else { trimmed }; + io::Error::new(error.kind(), format!("{code}: {message}, {operation} '{}'", path.display())) +} + +#[inline] +fn children_may_remain(error: &io::Error) -> bool { + matches!(error.kind(), io::ErrorKind::DirectoryNotEmpty | io::ErrorKind::AlreadyExists) + || (cfg!(windows) && error.kind() == io::ErrorKind::PermissionDenied) +} + +#[cfg(not(windows))] +#[inline] +fn unlink(path: &Path) -> io::Result<()> { + fs::remove_file(path) +} + +#[cfg(windows)] +#[inline] +fn unlink(path: &Path) -> io::Result<()> { + use std::os::windows::fs::FileTypeExt; + if fs::symlink_metadata(path)?.file_type().is_symlink_dir() { + fs::remove_dir(path) + } else { + fs::remove_file(path) + } +} + +#[cfg(windows)] +fn repair_permissions(path: &Path, original: io::Error, operation: &str) -> io::Result<()> { + let repair = || { + let mut permissions = fs::metadata(path)?.permissions(); + permissions.set_readonly(false); + fs::set_permissions(path, permissions)?; + fs::metadata(path) + }; + match repair() { + Ok(metadata) if metadata.is_dir() => remove_directory(path), + Ok(_) => unlink(path).map_err(|error| failure(error, "unlink", path)), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()), + Err(_) => Err(failure(original, operation, path)), + } +} diff --git a/native/cli/src/commands/remote_action/startup.rs b/native/cli/src/commands/remote_action/startup.rs new file mode 100644 index 000000000000..7f7eea2e7bdd --- /dev/null +++ b/native/cli/src/commands/remote_action/startup.rs @@ -0,0 +1,105 @@ +//! Decline startup callbacks and lane hydration before issuing any remote action. +//! +//! [`supported`] and [`service_supported`] check `workspace.jsonc` ([`parse_jsonc`]) and [`Installer`] constraints before effects. +use crate::commands::installation::Installer; +use bit_workspace_config::parse_jsonc; +use serde_json::Value; +use std::{fs, io, path::Path}; + +/// Returns `true` when `workspace` and `scope` have no startup callbacks or lane hydration requiring delegation. +#[inline] +pub(in crate::commands) fn supported( + workspace: Option<&Path>, + scope: &Path, + config: &Value, + load_workspace: bool, +) -> io::Result { + let Some(root) = workspace else { return scope_supported(scope) }; + supported_workspace(root, config, load_workspace, Installer::needs_canonical(root)) +} + +/// Returns `true` when `root` and `config` require no service-level aspect callbacks or lane hydration. +#[inline] +pub(in crate::commands) fn service_supported(root: &Path, config: &Value) -> io::Result { + supported_workspace(root, config, true, Installer::needs_service_callbacks(root)) +} + +fn supported_workspace( + root: &Path, + config: &Value, + load_workspace: bool, + callbacks: bool, +) -> io::Result { + if callbacks { + return Ok(false); + } + if !load_workspace { + return Ok(true); + } + let workspace = config + .as_object() + .and_then(|aspects| { + aspects + .iter() + .find(|(key, _)| { + bit_install::config::aspect_id_without_version(key) + == "teambit.workspace/workspace" + }) + }) + .map_or(&Value::Null, |(_, value)| value); + if workspace["bitmapAutoSync"] == true { + return Ok(false); + } + let default_scope = workspace["defaultScope"] + .as_str() + .map(str::trim) + .filter(|scope| !scope.is_empty()); + let bitmap = bit_install::workspace::Bitmap::load(root, default_scope)?; + Ok(bitmap.lane.as_ref().is_none_or(|lane| is_main_lane(&lane.name))) +} + +#[inline] +fn is_main_lane(name: &str) -> bool { + let bare = name + .trim() + .split('@') + .next() + .unwrap_or_default() + .trim() + .trim_matches('/'); + bare.is_empty() || bare.rsplit('/').next().map(str::trim) == Some("main") +} + +fn scope_supported(scope: &Path) -> io::Result { + let path = scope.join("scope.jsonc"); + let raw = match fs::read(path) { + Ok(bytes) if bytes.iter().all(u8::is_ascii_whitespace) => Value::Null, + Ok(bytes) => parse_jsonc(&bytes)?, + Err(error) if error.kind() == io::ErrorKind::NotFound => Value::Null, + Err(error) => return Err(error), + }; + let core: Vec = serde_json::from_str(include_str!( + "../../../../../scopes/harmony/testing/load-aspect/core-aspects-ids.json" + ))?; + if !config_supported(&raw, &core) { + return Ok(false); + } + let global = + bit_workspace_config::read_global_config(&bit_workspace_config::global_config_dir())?; + Ok(config_supported(&global, &core)) +} + +#[inline] +fn config_supported(config: &Value, core: &[String]) -> bool { + let Some(entries) = config.as_object() else { return config.is_null() }; + entries + .keys() + .all(|key| { + let key = key.trim(); + key != "require" + && (!key.contains('/') + || core + .iter() + .any(|id| id == bit_install::config::aspect_id_without_version(key))) + }) +} diff --git a/native/cli/src/commands/remote_resolution.rs b/native/cli/src/commands/remote_resolution.rs new file mode 100644 index 000000000000..ec8d38f06fe7 --- /dev/null +++ b/native/cli/src/commands/remote_resolution.rs @@ -0,0 +1,221 @@ +//! Scalar published remote resolution for read-only native inventory. +//! +//! [`Session`] ([`MAX_SCOPE_CONFIG`]) resolves [`Remotes`] and [`GlobalConfig`] via [`Host`] when needed for read-only remote queries. +use super::{color::Chalk, status::cache::cache_root}; +use crate::{ + discovery::{self, Target}, + host::Host, +}; +use bit_client_transfer::{GlobalConfig, remotes::Remotes}; +use serde_json::{Value, json}; +use std::{ + collections::HashMap, + env, + ffi::OsString, + fs::File, + io::{self, Read}, + path::{Path, PathBuf}, +}; + +/// Maximum byte size accepted when reading `scope.json` during read-only remote resolution. +const MAX_SCOPE_CONFIG: u64 = 1024 * 1024; + +/// One CLI command's resolver callbacks and their observable output. +pub(super) struct Session { + root: Option, + bare_scope_root: Option, + args: Vec, + host: Option, + overrides: HashMap, + output: String, + warning: Option, + owned: bool, +} + +impl Session { + /// Creates a remote-resolution [`Session`] for an optional workspace `root` and CLI `args`. + #[inline] + pub(super) fn new(root: Option<&Path>, args: &[OsString]) -> Self { + Self { + root: root.map(Path::to_path_buf), + bare_scope_root: None, + args: args.to_vec(), + host: None, + overrides: HashMap::new(), + output: String::new(), + warning: None, + owned: false, + } + } + + #[inline] + pub(super) fn for_scope(scope_root: &Path, args: &[OsString]) -> Self { + Self { + root: None, + bare_scope_root: Some(scope_root.to_owned()), + args: args.to_vec(), + host: None, + overrides: HashMap::new(), + output: String::new(), + warning: None, + owned: false, + } + } + + #[inline] + pub(super) const fn owned(&self) -> bool { + self.owned + } + + #[inline] + pub(super) fn output(&self) -> &str { + &self.output + } + + #[inline] + pub(super) fn warning(&self) -> Option<&str> { + self.warning.as_deref() + } + + #[inline] + pub(super) const fn overrides(&self) -> &HashMap { + &self.overrides + } + + #[inline] + pub(super) fn add_warning(&mut self, warning: &str) { + self.owned = true; + self.warning = Some(warning.trim().to_owned()); + } + + pub(super) fn resolve(&mut self, scopes: &[String]) -> io::Result<()> { + let (remotes, custom) = self.plan()?; + let pending = scopes + .iter() + .map(|scope| scope.trim()) + .filter(|scope| !scope.is_empty() && !self.overrides.contains_key(*scope)) + .filter_map(|scope| match remotes.configured(scope) { + Ok(Some(_)) => None, + Ok(None) if custom => Some(Ok(scope.to_owned())), + Ok(None) => None, + Err(error) => Some(Err(error)), + }) + .collect::>>()?; + if pending.is_empty() { + return Ok(()); + } + let ci_enabled = env::var_os("CI") + .is_some_and(|val| { + let text = val.to_string_lossy(); + let trimmed = text + .trim() + .trim_matches(['"', '\'']) + .trim(); + !trimmed.is_empty() + && !trimmed.eq_ignore_ascii_case("0") + && !trimmed.eq_ignore_ascii_case("false") + && !trimmed.eq_ignore_ascii_case("no") + && !trimmed.eq_ignore_ascii_case("off") + }); + if ci_enabled { + return Err(io::Error::other(CiResolverDecline)); + } + for scope in pending { + self.resolve_custom(&scope)?; + } + Ok(()) + } + + fn plan(&self) -> io::Result<(Remotes, bool)> { + let config_dir = cache_root().join("config"); + let global: GlobalConfig = + serde_json::from_value(bit_workspace_config::read_global_config(&config_dir)?) + .map_err(io::Error::other)?; + let scope = match self.root.as_ref() { + Some(root) => read_scope(root, false)?, + None => match self.bare_scope_root.as_ref() { + Some(root) => read_scope(root, true)?, + None => Value::Null, + }, + }; + let custom = scope["resolverPath"] + .as_str() + .is_some_and(|path| !path.trim().is_empty()); + Ok((Remotes::load(&scope, &config_dir, &global)?, custom)) + } + + fn resolve_custom(&mut self, scope: &str) -> io::Result<()> { + // Callback ownership starts before the retained host can run user code. + self.owned = true; + let params = json!({ + "scope": scope, "workspace": self.root.is_some(), + "bareScopeRoot": self.bare_scope_root.as_ref(), + }); + let result = self.host()?.call("inventoryResolve", ¶ms); + if let Some(host) = &self.host { + self.output.push_str(&host.take_stdout()); + } + let receipt = result?; + if let Some(error) = receipt + .get("error") + .filter(|err| !err.is_null()) + { + let message = error["message"] + .as_str() + .or_else(|| error.as_str()) + .map(str::trim) + .filter(|msg| !msg.is_empty()) + .unwrap_or("remote resolver failed"); + return Err(io::Error::other(message.to_owned())); + } + let url = receipt["host"] + .as_str() + .map(str::trim) + .filter(|url| !url.is_empty()) + .ok_or_else(|| io::Error::other("remote resolver host receipt"))?; + bit_client_transfer::remotes::Remote::from_host(url)?; + self.overrides.insert(scope.to_owned(), url.to_owned()); + Ok(()) + } + + fn host(&mut self) -> io::Result<&Host> { + if self.host.is_none() { + let Target::Entry(cli) = discovery::find()? else { + return Err(io::Error::other("the published resolver host is unavailable")); + }; + let root = match self.root.clone().or_else(|| self.bare_scope_root.clone()) { + Some(root) => root, + None => env::current_dir()?, + }; + self.host = Some(Host::new(cli, root, Chalk::detect(&self.args).enabled())); + } + Ok(self.host.as_ref().unwrap()) + } +} + +fn read_scope(root: &Path, bare: bool) -> io::Result { + let mut bytes = Vec::new(); + let scope = if bare { root.to_owned() } else { bit_object_import::scope_path(root) }; + File::open(scope.join("scope.json"))? + .take(MAX_SCOPE_CONFIG + 1) + .read_to_end(&mut bytes)?; + if bytes.len() as u64 > MAX_SCOPE_CONFIG { + return Err(io::Error::other("remote resolver scope configuration limit")); + } + bit_object_import::parse_jsonc(&bytes) +} + +#[derive(Debug)] +struct CiResolverDecline; +impl std::fmt::Display for CiResolverDecline { + #[inline] + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("CI custom resolver errors need the published JavaScript stack") + } +} +impl std::error::Error for CiResolverDecline {} + +#[inline] +pub(super) fn is_ci_decline(error: &io::Error) -> bool { + error.get_ref().is_some_and(::is::) +} diff --git a/native/cli/src/commands/remove.rs b/native/cli/src/commands/remove.rs new file mode 100644 index 000000000000..3ff03b672f6a --- /dev/null +++ b/native/cli/src/commands/remove.rs @@ -0,0 +1,166 @@ +//! `RemoveMain`'s local batch removal with retained provider startup and native scope publication. +//! +//! Coordinates [`COMMAND`], [`preparation`], [`load_free`], [`options`], and [`report`] into a [`Report`]. +pub(super) mod preparation; + +mod load_free; +mod options; +mod report; +use super::{ + add, + color::Chalk, + workspace_command::{self, format::Formatter, results::Report}, +}; +use crate::{commands::Command, native::Outcome}; +use std::{ffi::OsString, io, path::Path}; +/// Descriptor for the native `bit remove` (`rm`) command. +pub const COMMAND: Command = Command { name: "remove", aliases: &["rm"], run }; +/// Entry point for `bit remove`. +fn run(args: &[OsString]) -> io::Result { + let Some(argv) = workspace_command::utf8_args(args) else { + return Ok(Outcome::Declined("arguments are not UTF-8".into())); + }; + let Some(options) = options::parse(&argv[1..]) else { + return Ok(Outcome::Declined("outside the native remove grammar".into())); + }; + let root = match workspace_command::workspace_or_fail(args, "remove")? { + Ok(root) => root, + Err(outcome) => return Ok(outcome), + }; + if let Err(error) = preflight(&root, &options) { + return Ok(Outcome::Declined(format!("remove: {error}"))); + } + let mut linker = add::ProjectedLinker::prepare(&root)?; + let stdout = linker.take_stdout(); + if !stdout.is_empty() { + print!("{stdout}"); + } + workspace_command::execute_then(&root, args, "remove", |output| { + work(&root, args, &options, &mut linker, output) + }) +} +/// Selects components, validates dependents and aspect usage, commits the removal plan, and formats the [`Report`]. +fn work( + root: &Path, + args: &[OsString], + options: &options::Options<'_>, + linker: &mut add::ProjectedLinker, + output: &bit_install::output::Output, +) -> io::Result { + output.status("removing components"); + let bitmap = preparation::bitmap(root)?; + let selected = linker.select_workspace(root, &bitmap, options.pattern.trim(), output)?; + output.status("removing components"); + let mut reader = load_free::Reader::new(root).ok(); + let consumers = + status_consumers(root, &bitmap, &selected, options.force, linker, reader.as_mut())?; + let (removed, modified) = preparation::classify(&selected, &consumers); + let rows = load(root, &bitmap, &removed, options, linker, output, reader.as_ref())?; + let plan = preparation::prepare(root, &removed, options, &rows)?; + if !options.force { + plan.check_aspect_usage(&preparation::facts(&rows))?; + } + linker.validate_captures()?; + if let Some(reader) = &mut reader { + reader.validate()?; + } + plan.commit(&workspace_command::file_id())?; + let formatter = Formatter::new(Chalk::detect(args)); + Ok(Report { + data: report::render(&formatter, &plan.identities(), &plan.lane_identities(), &modified), + code: 0, + details: None, + }) +} +fn load( + root: &Path, + bitmap: &serde_json::Value, + removed: &[String], + options: &options::Options<'_>, + linker: &mut add::ProjectedLinker, + output: &bit_install::output::Output, + reader: Option<&load_free::Reader>, +) -> io::Result> { + if removed.is_empty() { + return Ok(Vec::new()); + } + let has_new = !options.force && removed.iter().any(|id| preparation::is_new(bitmap, id)); + let ids = if has_new { preparation::all(bitmap) } else { removed.to_vec() }; + let (mut rows, pending) = metadata(ids, reader); + if !pending.is_empty() { + output.status(&format!("loading {} component(s)", pending.len())); + let loadable = preparation::loadable(root, bitmap, &pending)?; + if !loadable.is_empty() { + rows.extend(linker.capture_cached(root, bitmap, &loadable)?); + } + print!("{}", linker.take_stdout()); + } + Ok(rows) +} + +fn metadata( + ids: Vec, + reader: Option<&load_free::Reader>, +) -> (Vec, Vec) { + let mut rows = Vec::with_capacity(ids.len()); + let mut pending = Vec::with_capacity(ids.len()); + for id in ids { + if let Some(row) = reader.and_then(|reader| reader.metadata(&id)) { + rows.push(row); + } else { + pending.push(id); + } + } + (rows, pending) +} + +#[inline] +fn preflight(root: &Path, options: &options::Options<'_>) -> io::Result<()> { + let pattern = options.pattern.trim(); + if pattern.is_empty() { + return Err(io::Error::other("remove pattern is empty")); + } + if add::ProjectedLinker::state_pattern(pattern) { + return add::ProjectedLinker::preflight_selection(root, &preparation::bitmap(root)?); + } + let ids = preparation::ids(root, pattern)?; + preparation::prepare(root, &ids, options, &[]).map(|_| ()) +} + +fn status_consumers( + root: &Path, + bitmap: &serde_json::Value, + selected: &[String], + force: bool, + linker: &mut add::ProjectedLinker, + mut reader: Option<&mut load_free::Reader>, +) -> io::Result> { + if force { + return Ok(Vec::new()); + } + let mut rows = Vec::with_capacity(selected.len()); + let mut pending = Vec::with_capacity(selected.len()); + for id in selected { + if let Some(modified) = reader + .as_deref_mut() + .and_then(|reader| reader.status(root, bitmap, id)) + { + rows.push(serde_json::json!({"identity":id,"modified":modified})); + } else { + pending.push(id.clone()); + } + } + let loadable = preparation::loadable(root, bitmap, &pending)?; + if loadable.len() != pending.len() { + use bit_install::dependencies::cache::invalidation::{deps_dir, invalidate}; + invalidate(root, &deps_dir(root))?; + } + if !loadable.is_empty() { + linker.call(root, "identityConsumerMode", &serde_json::json!({"enabled":true}))?; + let captured = linker.capture_consumers(root, bitmap, &loadable); + print!("{}", linker.take_stdout()); + let _ = linker.call(root, "identityConsumerMode", &serde_json::json!({"enabled":false})); + rows.extend(captured?); + } + Ok(rows) +} diff --git a/native/cli/src/commands/remove/load_free.rs b/native/cli/src/commands/remove/load_free.rs new file mode 100644 index 000000000000..c61ed0b6daa1 --- /dev/null +++ b/native/cli/src/commands/remove/load_free.rs @@ -0,0 +1,223 @@ +//! Removal reads source changes and effective configuration without loading environments. +//! +//! [`Reader`] queries [`Engine`] with [`Inventory`] and [`policy`] to prepare load-free component removal. +use super::preparation; +use crate::commands::{ + engine::Engine, + inventory::{Inventory, Options}, + status::policy, + workspace_command, +}; +use serde_json::{Value, json}; +use std::{ + fs, io, + path::{Path, PathBuf}, +}; + +/// Load-free component status and configuration reader backed by [`Engine`] and [`policy`]. +pub(super) struct Reader { + engine: Engine, + context: Value, + files: Vec<(PathBuf, Vec)>, +} +impl Reader { + /// Initializes a load-free [`Reader`] by requesting `statusContext` from [`Engine`]. + #[inline] + pub(super) fn new(root: &Path) -> io::Result { + let mut engine = Engine::new()?; + let context = engine.request(&json!({"kind":"statusContext", "root":root, "home":workspace_command::home(), "history":false}))?; + Ok(Self { engine, context, files: Vec::new() }) + } + #[inline] + pub(super) fn metadata(&self, identity: &str) -> Option { + let target = self.target(identity)?; + let policy = policy::status_policy(&self.context, target).ok()?; + Some( + json!({"identity": identity, "packageName": policy.package_name, "current": {"extensions":policy.extensions}}), + ) + } + #[inline] + pub(super) fn status(&mut self, root: &Path, bitmap: &Value, identity: &str) -> Option { + self.status_checked(root, bitmap, identity) + .ok() + .flatten() + } + fn status_checked( + &mut self, + root: &Path, + bitmap: &Value, + identity: &str, + ) -> io::Result> { + let row = preparation::row(bitmap, identity)?; + let Some(target) = self.target(identity).cloned() else { return Ok(None) }; + if !eligible(root, row) || pending(&self.context, identity) { + return Ok(None); + } + let directory = row["rootDir"] + .as_str() + .map_or("", |dir| dir.trim().trim_start_matches("./")); + let files = self.files(root, directory)?; + let main = row["mainFile"] + .as_str() + .map_or("", |file| file.trim().trim_start_matches("./")); + if !files.iter().any(|file| file == main) { + return Ok(None); + } + self.compare(root, row, &target, identity, &files) + } + fn compare( + &mut self, + root: &Path, + row: &Value, + target: &Value, + identity: &str, + files: &[String], + ) -> io::Result> { + let model = self.model(identity); + let has_history = model.is_some_and(|model| self.has_history(identity, model)); + let version = row["version"].as_str().map_or("", str::trim); + if version.is_empty() && !has_history { + return Ok(Some(false)); + } + let Some(model) = model.filter(|_| !version.is_empty()) else { return Ok(None) }; + if !has_history { + return Ok(Some(false)); + } + let reference = if matches!(version, "" | "latest" | "head") { + model["head"].as_str().map_or(version, str::trim) + } else { + model["versions"][version] + .as_str() + .or_else(|| model["orphanedVersions"][version].as_str()) + .map_or(version, str::trim) + }; + let Some(stored) = self.context["versions"] + .as_array() + .into_iter() + .flatten() + .find(|entry| entry[0].as_str().map(str::trim) == Some(reference)) + .map(|entry| &entry[1]) + else { + return Ok(None); + }; + if stored["mainFile"] != target["mainFile"] { + return Ok(Some(true)); + } + let main = row["mainFile"].as_str().map_or("", str::trim); + let directory = row["rootDir"].as_str().map_or("", str::trim); + let current = json!({"mainFile":main,"dependencies":[],"devDependencies":[],"packageDependencies":{},"devPackageDependencies":{},"peerPackageDependencies":{},"bindingPrefix":"@bit","overrides":{},"extensions":[]}); + let result: Value = self.engine.request(&json!({"kind":"statusCompare", "root":root, "targets":[{"identity":identity,"version":version,"directory":directory,"files":files,"current":current}]}))?; + Ok(result["comparisons"][0]["codeModified"].as_bool()) + } + fn model(&self, identity: &str) -> Option<&Value> { + let identity = identity.trim(); + self.context["models"] + .as_array()? + .iter() + .find(|model| { + format!( + "{}/{}", + model["scope"].as_str().map_or("", str::trim), + model["name"].as_str().map_or("", str::trim), + ) == identity + }) + } + #[inline] + fn has_history(&self, identity: &str, model: &Value) -> bool { + self.context["laneHeads"][identity].is_string() + || model["head"].is_string() + || model["versions"] + .as_object() + .is_some_and(|versions| !versions.is_empty()) + || model["orphanedVersions"] + .as_object() + .is_some_and(|versions| !versions.is_empty()) + } + #[inline] + fn target(&self, identity: &str) -> Option<&Value> { + let identity = identity.trim(); + self.context["targets"] + .as_array()? + .iter() + .find(|target| target["identity"].as_str().map(str::trim) == Some(identity)) + } + fn files(&mut self, root: &Path, directory: &str) -> io::Result> { + let options: Options = + serde_json::from_value(self.context["options"].clone()).map_err(io::Error::other)?; + let inventory = Inventory::new(&options)?; + let comp_dir = root.join(directory); + let manifest = inventory.scan( + &mut self.engine, + json!({"kind":"inventoryBegin","root":comp_dir,"workspace":root,"excludeDirs":[]}), + "inventoryFinish", + )?; + let files = manifest["files"] + .as_array() + .ok_or_else(|| io::Error::other("removal source inventory"))?; + let mut paths = Vec::with_capacity(files.len()); + self.files.reserve(files.len()); + for file in files { + let relative = file["relativePath"] + .as_str() + .ok_or_else(|| io::Error::other("removal source path"))?; + let path = comp_dir.join(relative); + let bytes = fs::read(&path)?; + self.files.push((path, bytes)); + paths.push(relative.to_owned()); + } + Ok(paths) + } + pub(super) fn validate(&mut self) -> io::Result<()> { + for (path, before) in &self.files { + if fs::read(path)? != *before { + return Err(io::Error::other("removal source changed before publication")); + } + } + let result: Value = self.engine.request( + &json!({"kind":"dependencyFreshValidate","readSet":self.context["controlReadSet"]}), + )?; + if result["fresh"] != true { + return Err(io::Error::other("removal configuration changed before publication")); + } + Ok(()) + } +} +#[inline] +fn eligible(root: &Path, row: &Value) -> bool { + let Some(directory) = row["rootDir"] + .as_str() + .map(str::trim) + .filter(|directory| !directory.is_empty() && *directory != ".") + else { + return false; + }; + if !row["config"].as_object().is_none_or(serde_json::Map::is_empty) { + return false; + } + let comp_dir = root.join(directory); + comp_dir.is_dir() && !comp_dir.join("component.json").exists() +} +fn pending(context: &Value, identity: &str) -> bool { + let identity = identity.trim(); + context["unmerged"] + .as_array() + .into_iter() + .flatten() + .any(|entry| { + entry["id"] + .as_str() + .map(|raw| { + raw.trim() + .split('@') + .next() + .unwrap_or(raw) + .trim() + }) + == Some(identity) + || format!( + "{}/{}", + entry["id"]["scope"].as_str().map_or("", str::trim), + entry["id"]["name"].as_str().map_or("", str::trim), + ) == identity + }) +} diff --git a/native/cli/src/commands/remove/options.rs b/native/cli/src/commands/remove/options.rs new file mode 100644 index 000000000000..e1ed6e872c3c --- /dev/null +++ b/native/cli/src/commands/remove/options.rs @@ -0,0 +1,72 @@ +//! `RemoveCmd`'s local, noninteractive flag grammar. +//! +//! Defines [`Options`] (`keep`, `force`, `track`) and [`parse`] for `bit remove ` CLI arguments. +/// Parsed command-line options for `bit remove `. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub(super) struct Options<'a> { + pub pattern: &'a str, + pub keep: bool, + pub force: bool, + pub track: bool, +} +/// Parses `bit remove` arguments into [`Options`] when `-s`/`--silent` is active. +pub(super) fn parse<'a>(args: &[&'a str]) -> Option> { + let mut pattern = None; + let mut silent = false; + let mut options = Options { pattern: "", keep: false, force: false, track: false }; + let mut iter = args.iter().copied(); + while let Some(arg) = iter.next() { + match arg.trim() { + "--" => { + crate::commands::delete::options::consume_trailing_delete_pattern( + iter, + &mut pattern, + )?; + break; + } + "--no-color" | "--no-color=true" | "--no-color=false" | "--color" | "--color=true" + | "--color=false" | "--color=always" | "--color=never" => {} + "-s" | "--silent" | "--silent=true" => silent = true, + "--silent=false" => silent = false, + "--keep-files" | "--keep-files=true" => options.keep = true, + "--keep-files=false" => options.keep = false, + "-f" | "--force" | "--force=true" => options.force = true, + "--force=false" => options.force = false, + "-t" | "--track" | "--track=true" => options.track = true, + "--track=false" => options.track = false, + value if pattern.is_none() && is_valid_pattern(value) => { + pattern = Some(value); + } + value => apply_short_bundle(value, &mut silent, &mut options)?, + } + } + options.pattern = pattern?; + silent.then_some(options) +} + +#[inline] +fn is_valid_pattern(value: &str) -> bool { + !value.starts_with('-') + && value.len() <= 4096 + && !value.contains('\0') + && !value + .trim_start_matches('!') + .trim() + .is_empty() +} + +#[inline] +fn apply_short_bundle(arg: &str, silent: &mut bool, options: &mut Options<'_>) -> Option<()> { + let flags = arg + .strip_prefix('-') + .filter(|flags| !flags.is_empty() && !flags.starts_with('-'))?; + for ch in flags.chars() { + match ch { + 's' => *silent = true, + 'f' => options.force = true, + 't' => options.track = true, + _ => return None, + } + } + Some(()) +} diff --git a/native/cli/src/commands/remove/preparation.rs b/native/cli/src/commands/remove/preparation.rs new file mode 100644 index 000000000000..f8e872ffb069 --- /dev/null +++ b/native/cli/src/commands/remove/preparation.rs @@ -0,0 +1,180 @@ +//! Native status classification and metadata receipts for `RemoveMain`. +//! +//! Resolves target [`ids`] via [`select_ids`] and prepares [`LocalBatch`] removal plans ([`prepare`], [`classify`]) for [`Options`]. +use super::options::Options; +use bit_object_import::component_remove::{LocalBatch, select_ids}; +use serde_json::{Value, json}; +use std::{fs, io, path::Path}; + +/// Resolves bare component IDs matching `pattern` in `root` via [`select_ids`]. +#[inline] +pub(in crate::commands) fn ids(root: &Path, pattern: &str) -> io::Result> { + select_ids(root, pattern) + .map(|ids| { + ids.into_iter() + .map(|id| bare(&id).to_owned()) + .collect() + }) +} +/// Prepares a [`LocalBatch`] removal plan from resolved component `ids` and status `rows`. +#[inline] +pub(super) fn prepare( + root: &Path, + ids: &[String], + options: &Options<'_>, + rows: &[Value], +) -> io::Result { + LocalBatch::prepare(root, ids, options.keep, options.track, &facts(rows)) +} +#[inline] +pub(super) fn classify(selected: &[String], rows: &[Value]) -> (Vec, Vec) { + selected + .iter() + .cloned() + .partition(|id| { + let id = id.trim(); + !rows + .iter() + .any(|row| { + row["identity"].as_str().map(str::trim) == Some(id) && row["modified"] == true + }) + }) +} +pub(in crate::commands) fn loadable( + root: &Path, + bitmap: &Value, + ids: &[String], +) -> io::Result> { + ids.iter() + .filter_map(|id| match row(bitmap, id) { + Ok(row) => main_exists(root, row).then(|| Ok(id.clone())), + Err(error) => Some(Err(error)), + }) + .collect() +} +pub(in crate::commands) fn all(bitmap: &Value) -> Vec { + bitmap + .as_object() + .into_iter() + .flat_map(|rows| rows.iter()) + .filter_map(|(key, row)| { + if key.starts_with('$') || key == "_bit_lane" || key == "version" || !row.is_object() { + return None; + } + let removed = crate::commands::update_cmd::aspect_entry( + &row["config"], + "teambit.component/remove", + ) + .is_some_and(|(_, cfg)| cfg["removed"] == true); + if row["isAvailableOnCurrentLane"] == false || removed { + return None; + } + row_identity(key, row) + }) + .collect() +} +pub(in crate::commands) fn row<'a>(bitmap: &'a Value, id: &str) -> io::Result<&'a Value> { + let target = bare(id); + bitmap + .as_object() + .and_then(|rows| { + rows.iter() + .find_map(|(key, row)| { + (row_identity(key, row).as_deref() == Some(target)).then_some(row) + }) + }) + .ok_or_else(|| io::Error::other("remove component selection changed")) +} +pub(in crate::commands) fn is_new(bitmap: &Value, id: &str) -> bool { + let target = bare(id); + bitmap + .as_object() + .and_then(|rows| { + rows.iter() + .find(|(key, row)| row_identity(key, row).as_deref() == Some(target)) + }) + .is_some_and(|(key, row)| { + row["version"] + .as_str() + .map(str::trim) + .is_none_or(str::is_empty) + && key + .rsplit_once('@') + .is_none_or(|(_, ver)| ver.trim().is_empty()) + }) +} +fn row_identity(key: &str, row: &Value) -> Option { + let key_id = bare(key); + let explicit_name = row["name"] + .as_str() + .map(str::trim) + .filter(|name| !name.is_empty()); + let key_scope = match explicit_name { + Some(name) => key_id + .strip_suffix(name) + .and_then(|prefix| prefix.strip_suffix('/')), + None => key_id.split_once('/').map(|(scope, _)| scope), + } + .map(str::trim) + .filter(|scope| !scope.is_empty()); + let scope = row["scope"] + .as_str() + .map(str::trim) + .filter(|scope| !scope.is_empty()) + .or_else(|| { + row["defaultScope"] + .as_str() + .map(str::trim) + .filter(|scope| !scope.is_empty()) + }) + .or(key_scope)?; + let name = explicit_name.unwrap_or_else(|| { + key_id + .strip_prefix(scope) + .and_then(|rest| rest.strip_prefix('/')) + .unwrap_or(key_id) + .trim() + }); + (!name.is_empty()).then(|| format!("{scope}/{name}")) +} +fn main_exists(root: &Path, row: &Value) -> bool { + row["rootDir"] + .as_str() + .map(|dir| { + dir.trim() + .trim_start_matches("./") + .trim_start_matches(r".\") + }) + .filter(|dir| !dir.is_empty()) + .zip( + row["mainFile"] + .as_str() + .map(|main| { + main.trim() + .trim_start_matches("./") + .trim_start_matches(r".\") + }) + .filter(|main| !main.is_empty()), + ) + .is_some_and(|(directory, main)| { + root.join(directory) + .join(main) + .is_file() + }) +} +#[inline] +pub(in crate::commands) fn bitmap(root: &Path) -> io::Result { + bit_object_import::parse_jsonc(&fs::read(root.join(".bitmap"))?) +} +#[inline] +pub(in crate::commands) fn facts(rows: &[Value]) -> Vec { + rows.iter().map(|row| json!({"identity":row["identity"],"extensions":row["current"]["extensions"],"packageName":row["packageName"]})).collect() +} +#[inline] +fn bare(id: &str) -> &str { + let trimmed = id.trim().trim_matches('/'); + match trimmed.rsplit_once('@') { + Some((prefix, _)) if !prefix.trim().is_empty() => prefix.trim().trim_matches('/'), + _ => trimmed, + } +} diff --git a/native/cli/src/commands/remove/report.rs b/native/cli/src/commands/remove/report.rs new file mode 100644 index 000000000000..754a42eca53c --- /dev/null +++ b/native/cli/src/commands/remove/report.rs @@ -0,0 +1,78 @@ +//! The local sections of `RemoveTemplate`, including successful partial removals. +//! +//! [`render`] formats removed ([`removed_section`]), lane, and modified component sections using [`Formatter`]. +use crate::commands::workspace_command::format::Formatter; + +/// Renders the workspace, lane, and modified component sections of `RemoveTemplate`. +#[inline] +pub(super) fn render( + format: &Formatter, + removed: &[String], + lane: &[String], + modified: &[String], +) -> String { + Formatter::join(&[ + removed_section(format, removed, "workspace"), + removed_section(format, lane, "lane"), + modified_section(format, modified), + ]) +} +/// Formats the section listing components removed from `location` (`workspace` or `lane`). +fn removed_section(format: &Formatter, ids: &[String], location: &str) -> String { + let location = location.trim(); + if ids.is_empty() || location.is_empty() { + return String::new(); + } + let items: Vec = unique_ids(ids) + .into_iter() + .map(|id| format.item(id)) + .collect(); + if items.is_empty() { + return String::new(); + } + format!( + "{}\n{}", + format.success_summary(&format!( + "successfully removed components from the local {location}" + )), + items.join("\n"), + ) +} +fn modified_section(format: &Formatter, ids: &[String]) -> String { + if ids.is_empty() { + return String::new(); + } + let error_symbol = format.error_symbol(); + let items: Vec = unique_ids(ids) + .into_iter() + .map(|id| Formatter::item_with(id, &error_symbol)) + .collect(); + if items.is_empty() { + return String::new(); + } + format!( + "{error_symbol} {}\n{}", + format.title("unable to remove modified components (use --force to remove)"), + items.join("\n"), + ) +} +#[inline] +fn unique_ids(ids: &[String]) -> Vec<&str> { + let mut out = Vec::with_capacity(ids.len()); + for id in ids + .iter() + .map(|id| { + let trimmed = id.trim().trim_matches('/'); + match trimmed.rsplit_once('@') { + Some((prefix, _)) if !prefix.trim().is_empty() => prefix.trim().trim_matches('/'), + _ => trimmed, + } + }) + .filter(|id| !id.is_empty()) + { + if !out.contains(&id) { + out.push(id); + } + } + out +} diff --git a/native/cli/src/commands/rename.rs b/native/cli/src/commands/rename.rs new file mode 100644 index 000000000000..8375f36c700b --- /dev/null +++ b/native/cli/src/commands/rename.rs @@ -0,0 +1,84 @@ +//! Component rename keeps identity, source and bitmap mutation in the native engine. +//! +//! Coordinates [`COMMAND`], [`preparation`], [`execution`], [`ast`], [`options`], and [`ProjectedLinker`] into [`Outcome`]. +pub(super) mod execution; +pub(super) mod preparation; + +mod ast; +mod options; + +use super::{Command, add::ProjectedLinker, color::Chalk, workspace_command}; +use crate::{native::Outcome, workspace}; +use std::{env, ffi::OsString, io}; + +/// Descriptor for the native `bit rename` command. +pub const COMMAND: Command = Command { name: "rename", aliases: &[], run }; +/// Entry point for `bit rename`. +fn run(args: &[OsString]) -> io::Result { + let Some(argv) = workspace_command::utf8_args(args) else { + return Ok(Outcome::Declined("rename arguments are not UTF-8".into())); + }; + let Some(options) = options::parse(&argv[1..]) else { + return Ok(Outcome::Declined("outside native component rename grammar".into())); + }; + let cwd = env::current_dir()?; + let Some(root) = workspace::root(&cwd) else { + return run_outside_workspace(&cwd, args, &options); + }; + if let Err(error) = preparation::prepare(&root, &options) + && (error.kind() != io::ErrorKind::InvalidInput || is_ci_active()) + { + return Ok(Outcome::Declined(format!("component rename: {error}"))); + } + let mut linker = match ProjectedLinker::prepare(&root) { + Ok(linker) => linker, + Err(error) if !super::engine::is_canonical(&error) => { + return Ok(Outcome::Declined(format!("component rename: {error}"))); + } + Err(error) => return Err(error), + }; + let stdout = linker.take_stdout(); + if !stdout.is_empty() { + print!("{stdout}"); + } + let chalk = Chalk::detect(args); + workspace_command::execute_then(&root, args, "rename", |output| { + let prepared = preparation::prepare(&root, &options)?; + execution::run(&root, prepared, &options, &mut linker, chalk, output) + }) +} + +/// Returns `true` when `CI` is set to a non-empty, non-falsy environment value. +#[inline] +fn is_ci_active() -> bool { + env::var_os("CI") + .is_some_and(|value| { + let text = value.to_string_lossy(); + let trimmed = text + .trim() + .trim_matches(['"', '\'']) + .trim(); + !trimmed.is_empty() + && !trimmed.eq_ignore_ascii_case("0") + && !trimmed.eq_ignore_ascii_case("false") + && !trimmed.eq_ignore_ascii_case("no") + && !trimmed.eq_ignore_ascii_case("off") + }) +} + +#[inline] +fn run_outside_workspace( + cwd: &std::path::Path, + args: &[OsString], + options: &bit_object_import::component_rename::Options, +) -> io::Result { + if is_ci_active() { + return Ok(Outcome::Declined("component rename requires a workspace".into())); + } + let err = bit_object_import::component_rename::validate_name(options.target.trim()) + .err() + .unwrap_or_else(|| { + io::Error::other("Cannot read properties of undefined (reading 'resolveComponentId')") + }); + workspace_command::execute_failure_scope(cwd, args, "rename", false, "", err) +} diff --git a/native/cli/src/commands/rename/ast.rs b/native/cli/src/commands/rename/ast.rs new file mode 100644 index 000000000000..0fed9f2bb6a5 --- /dev/null +++ b/native/cli/src/commands/rename/ast.rs @@ -0,0 +1,99 @@ +//! Published `TypeScript` transformers use private spools; Rust retains every workspace path. +//! +//! [`transform`] invokes AST import rewrites through [`ProjectedLinker`] for a component rename [`Plan`]. +use crate::commands::add::ProjectedLinker; +use bit_object_import::component_rename::Plan; +use serde_json::json; +use std::{ + fs, io, + path::Path, + sync::atomic::{AtomicU64, Ordering}, +}; +/// Monotonic counter used to allocate unique temporary AST spool directory names. +static NEXT: AtomicU64 = AtomicU64::new(0); +/// RAII guard that removes a temporary AST transformation spool directory on drop. +struct Spool(std::path::PathBuf); +impl Drop for Spool { + #[inline] + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +/// Applies `TypeScript` AST import rewrites in a temporary spool directory via [`ProjectedLinker`]. +pub(super) fn transform( + root: &Path, + plan: &mut Plan, + linker: &mut ProjectedLinker, +) -> io::Result<()> { + let inputs = plan.ast_inputs(); + if inputs.is_empty() { + return Ok(()); + } + let (Some(source_name), Some(target_name)) = ( + short_component_name(&plan.identity.source_name), + short_component_name(&plan.identity.target_name), + ) else { + return Err(io::Error::other("rename AST component name is empty")); + }; + let path = std::env::temp_dir() + .join(format!( + "bit-rename-ast-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed), + )); + fs::create_dir_all(&path)?; + let spool = Spool(path); + let mut rows = Vec::with_capacity(inputs.len()); + for (index, (path, bytes)) in inputs.iter().enumerate() { + let stored = spool.0.join(index.to_string()); + fs::write(&stored, bytes)?; + rows.push(json!({"spool":stored,"filename":path})); + } + let result = linker.call( + root, + "renameAst", + &json!({ + "files": rows, + "sourceName": source_name, + "targetName": target_name, + }), + ); + let stdout = linker.take_stdout(); + if !stdout.is_empty() { + print!("{stdout}"); + } + result?; + read_transformed_inputs(&spool.0, &inputs, plan) +} + +#[inline] +fn read_transformed_inputs( + spool_dir: &Path, + inputs: &[(std::path::PathBuf, Vec)], + plan: &mut Plan, +) -> io::Result<()> { + for (index, (path, _)) in inputs.iter().enumerate() { + let source = spool_dir.join(index.to_string()); + let metadata = fs::symlink_metadata(&source)?; + if !metadata.is_file() || metadata.len() > 32 * 1024 * 1024 { + return Err(io::Error::other("rename AST spool is not a bounded regular file")); + } + plan.transformed(path, fs::read(source)?)?; + } + Ok(()) +} + +#[inline] +fn short_component_name(name: &str) -> Option<&str> { + let trimmed = name.trim(); + let bare = trimmed + .rsplit_once('@') + .filter(|(prefix, _)| !prefix.trim().is_empty()) + .map_or(trimmed, |(prefix, _)| prefix.trim()); + bare.trim_end_matches('/') + .rsplit('/') + .next() + .map(str::trim) + .filter(|segment| !segment.is_empty()) +} diff --git a/native/cli/src/commands/rename/execution.rs b/native/cli/src/commands/rename/execution.rs new file mode 100644 index 000000000000..3635dfd6d827 --- /dev/null +++ b/native/cli/src/commands/rename/execution.rs @@ -0,0 +1,308 @@ +//! Provider facts are retained while native sources, identities and configs are published. +//! +//! [`run`] executes [`Prepared`] rename [`Plan`] and [`Options`] with [`ProjectedLinker`] into a [`Report`]. +use super::preparation::Prepared; +use crate::commands::{ + add::ProjectedLinker, + color::Chalk, + workspace_command::{self, results::Report}, +}; +use bit_object_import::component_rename::{Options, Plan}; +use serde_json::{Value, json}; +use std::{borrow::Cow, io, path::Path}; + +/// Executes a [`Prepared`] component rename plan and returns the formatted [`Report`]. +pub(super) fn run( + root: &Path, + mut prepared: Prepared, + options: &Options, + linker: &mut ProjectedLinker, + chalk: Chalk, + output: &bit_install::output::Output, +) -> io::Result { + hydrate(root, &mut prepared, options, linker)?; + let source_package = prepared.facts["sourcePackage"] + .as_str() + .ok_or_else(|| io::Error::other("rename source package"))? + .to_owned(); + if !prepared.plan.identity.exported { + prepared.plan.finish_original(&workspace_command::file_id(), &source_package)?; + } + prepared.plan.begin(&workspace_command::file_id())?; + target_load(root, &mut prepared, linker)?; + if options.ast && !options.preserve && !prepared.plan.identity.exported { + super::ast::transform(root, &mut prepared.plan, linker)?; + } + + if options.refactor && !prepared.plan.identity.exported { + refactor_load(root, &mut prepared, linker, output)?; + } + prepared.plan.apply_files()?; + materialized(root, &prepared, linker)?; + finish_exported(root, &mut prepared, options, linker, chalk, output, &source_package)?; + if options.refactor && prepared.plan.identity.exported { + exported_refactor(root, &mut prepared, linker, output)?; + } + prepared.plan.commit(&workspace_command::file_id())?; + finish_link(root, &prepared, options, linker, chalk)?; + output.stop(); + Ok(report(&prepared, chalk)) +} + +/// Synchronizes `prepared.facts` component file lists and overlay paths with `captured` rows. +fn refreshed_files(prepared: &mut Prepared, captured: &[serde_json::Value]) -> io::Result<()> { + let components = prepared.facts["components"] + .as_array_mut() + .ok_or_else(|| io::Error::other("rename inventory"))?; + for row in components { + if let Some(current) = captured + .iter() + .find(|current| current["identity"] == row["identity"]) + { + row["sourceOverlayRoot"] = current["sourceOverlayRoot"].clone(); + row["sourceOverlays"] = current["sourceOverlays"].clone(); + row["files"] = json!( + current["files"] + .as_array() + .ok_or_else(|| io::Error::other("rename captured files"))? + .iter() + .map(relative_path) + .collect::>() + ); + } + } + Ok(()) +} +fn refactor_load( + root: &Path, + prepared: &mut Prepared, + linker: &mut ProjectedLinker, + output: &bit_install::output::Output, +) -> io::Result<()> { + let identities = refactor_identities(prepared)?; + output.status(&format!("loading {} component(s)", identities.len())); + let bitmap = refactor_bitmap(&prepared.plan)?; + let loaded = linker.capture_cached(root, &bitmap, &identities)?; + print!("{}", linker.take_stdout()); + let source = prepared.facts["sourcePackage"] + .as_str() + .ok_or_else(|| io::Error::other("rename source package"))?; + let target = prepared.facts["targetPackage"] + .as_str() + .ok_or_else(|| io::Error::other("rename target package"))?; + prepared.plan.refactor_loaded(&loaded, (source, target))?; + Ok(()) +} + +#[inline] +fn source_display(prepared: &Prepared) -> String { + let source = prepared.plan.identity.source.trim(); + if source.contains('@') { + return source.to_owned(); + } + prepared.facts["sourceVersion"] + .as_str() + .map(str::trim) + .filter(|version| !version.is_empty()) + .map_or_else(|| source.to_owned(), |version| format!("{source}@{version}")) +} + +pub(in crate::commands) fn hydrate( + root: &Path, + prepared: &mut Prepared, + options: &Options, + linker: &mut ProjectedLinker, +) -> io::Result<()> { + let captured = linker.capture( + root, + &bit_object_import::parse_jsonc(&std::fs::read(root.join(".bitmap"))?)?, + std::slice::from_ref(&prepared.plan.identity.source), + )?; + print!("{}", linker.take_stdout()); + let source = captured + .iter() + .find(|row| { + row["identity"].as_str().map(str::trim) == Some(prepared.plan.identity.source.trim()) + }) + .ok_or_else(|| io::Error::other("rename source component capture missing"))?; + prepared.facts["sourceExtensions"] = source["current"]["extensions"].clone(); + prepared.facts["sourceVersion"] = source["version"].clone(); + if prepared.plan.identity.exported { + let config_res = linker.call(root, "renameSourceConfig", &json!({"component":source})); + print!("{}", linker.take_stdout()); + prepared.facts["sourceConfigs"] = config_res?; + } + prepared.facts["preflight"] = json!(false); + refreshed_files(prepared, &captured)?; + prepared.plan.rehydrate(options, &prepared.facts)?; + Ok(()) +} + +fn finish_exported( + root: &Path, + prepared: &mut Prepared, + options: &Options, + linker: &mut ProjectedLinker, + chalk: Chalk, + output: &bit_install::output::Output, + source_package: &str, +) -> io::Result<()> { + if prepared.plan.identity.exported { + let added = vec![prepared.plan.identity.target.clone()]; + output.status(&format!("loading {} component(s)", added.len())); + linker.link(root, prepared.plan.added_bitmap(), &added, false, chalk)?; + print!("{}", linker.take_stdout()); + prepared.plan.publish_added(&workspace_command::file_id())?; + linker.call(root, "renameTooling", &json!({"action":"onAdd","ids":added}))?; + print!("{}", linker.take_stdout()); + if !options.deprecate { + output.status("loading 1 component(s)"); + } + prepared.plan.finish_original(&workspace_command::file_id(), source_package)?; + } + Ok(()) +} + +#[inline] +fn report(prepared: &Prepared, chalk: Chalk) -> Report { + let message = format!( + "renamed {} to {}", + chalk.bold(&source_display(prepared)), + chalk.bold(&prepared.plan.identity.target), + ); + Report { + data: workspace_command::format::Formatter::new(chalk).success_summary(&message), + code: 0, + details: None, + } +} + +#[inline] +fn relative_path(file: &serde_json::Value) -> serde_json::Value { + if file.is_string() { file.clone() } else { file["relativePath"].clone() } +} + +fn target_load( + root: &Path, + prepared: &mut Prepared, + linker: &mut ProjectedLinker, +) -> io::Result<()> { + if prepared.plan.identity.exported { + return Ok(()); + } + prepared.plan.publish_configuration(&workspace_command::file_id())?; + let target_id = &prepared.plan.identity.target; + let captured = + linker.capture_cached(root, prepared.plan.added_bitmap(), std::slice::from_ref(target_id))?; + print!("{}", linker.take_stdout()); + if let Some(target) = captured + .iter() + .find(|row| row["identity"] == *target_id) + { + let source = prepared.facts["sourcePackage"] + .as_str() + .ok_or_else(|| io::Error::other("rename source package"))?; + let destination = prepared.facts["targetPackage"] + .as_str() + .ok_or_else(|| io::Error::other("rename target package"))?; + prepared.plan.target_load(target, (source, destination))?; + } + Ok(()) +} + +#[inline] +fn materialized(root: &Path, prepared: &Prepared, linker: &mut ProjectedLinker) -> io::Result<()> { + if !prepared.plan.identity.exported { + linker.call(root, "identityMaterialized", &prepared.plan.materialized())?; + print!("{}", linker.take_stdout()); + } + Ok(()) +} + +fn refactor_identities(prepared: &Prepared) -> io::Result> { + let bitmap = refactor_bitmap(&prepared.plan)?; + let identities = bitmap + .as_object() + .ok_or_else(|| io::Error::other("rename projected bitmap"))? + .iter() + .filter_map(|(key, row)| listed_identity(key, row)) + .collect::>(); + Ok(identities) +} + +#[inline] +fn flush_stdout(linker: &mut ProjectedLinker) { + let stdout = linker.take_stdout(); + if !stdout.is_empty() { + print!("{stdout}"); + } +} + +fn exported_refactor( + root: &Path, + prepared: &mut Prepared, + linker: &mut ProjectedLinker, + output: &bit_install::output::Output, +) -> io::Result<()> { + // RenamingMain reloads after adding/removing identities, before target get/list. + prepared.plan.publish_configuration(&workspace_command::file_id())?; + linker.forget_loaded(); + // Workspace.get(target) is singular; only getMany/list starts a loading summary. + linker.capture( + root, + &prepared.plan.published_bitmap()?, + std::slice::from_ref(&prepared.plan.identity.target), + )?; + flush_stdout(linker); + refactor_load(root, prepared, linker, output)?; + prepared.plan.apply_files()?; + linker.call(root, "identityMaterialized", &prepared.plan.materialized())?; + flush_stdout(linker); + Ok(()) +} + +fn finish_link( + root: &Path, + prepared: &Prepared, + options: &Options, + linker: &mut ProjectedLinker, + chalk: Chalk, +) -> io::Result<()> { + let targets = vec![prepared.plan.identity.target.clone()]; + if prepared.plan.identity.exported && !options.refactor { + linker.link(root, prepared.plan.bitmap(), &targets, false, chalk)?; + } else { + linker.link_retained(root, prepared.plan.bitmap(), &targets, false, chalk)?; + } + flush_stdout(linker); + linker.call(root, "renameTooling", &json!({"action":"compile","ids":targets}))?; + flush_stdout(linker); + Ok(()) +} + +#[inline] +fn refactor_bitmap(plan: &Plan) -> io::Result> { + if plan.identity.exported { + plan.published_bitmap().map(Cow::Owned) + } else { + Ok(Cow::Borrowed(plan.added_bitmap())) + } +} +#[inline] +fn listed_identity(key: &str, row: &Value) -> Option { + if key.starts_with('$') || key == "_bit_lane" || key == "version" || !row.is_object() { + return None; + } + let removed = + crate::commands::update_cmd::aspect_entry(&row["config"], "teambit.component/remove") + .is_some_and(|(_, val)| val["removed"] == true); + if removed || row["isAvailableOnCurrentLane"] == false { + return None; + } + row["rootDir"].as_str()?; + let (scope, name) = crate::commands::update_cmd::bitmap_row_scope_and_name(key, row); + if scope.is_empty() || name.is_empty() { + return None; + } + Some(format!("{scope}/{name}")) +} diff --git a/native/cli/src/commands/rename/options.rs b/native/cli/src/commands/rename/options.rs new file mode 100644 index 000000000000..580ae303f5d0 --- /dev/null +++ b/native/cli/src/commands/rename/options.rs @@ -0,0 +1,97 @@ +//! Command-line flag and positional argument parser for `bit rename`. +//! +//! [`parse`] and [`consume_trailing_positions`] validate `bit rename ` flags into [`Options`]. +use bit_object_import::component_rename::Options; + +/// Parses `bit rename ` CLI tokens into [`Options`]. +pub(super) fn parse(argv: &[&str]) -> Option { + let mut options = Options::default(); + let mut positions = Vec::with_capacity(2); + let mut arguments = argv.iter().copied(); + while let Some(raw_argument) = arguments.next() { + let argument = raw_argument.trim(); + if argument == "--" { + consume_trailing_positions(arguments, &mut positions)?; + break; + } + apply_rename_arg(argument, &mut arguments, &mut options, &mut positions)?; + } + let [source, target]: [String; 2] = positions.try_into().ok()?; + options.source = source; + options.target = target; + Some(options) +} + +/// Appends post-`--` positional arguments to `positions` after validating each token. +#[inline] +fn consume_trailing_positions<'a>( + arguments: impl Iterator, + positions: &mut Vec, +) -> Option<()> { + for rest in arguments { + positions.push(take_val(rest)?); + } + Some(()) +} + +fn apply_rename_arg<'a>( + argument: &'a str, + arguments: &mut impl Iterator, + options: &mut Options, + positions: &mut Vec, +) -> Option<()> { + match argument { + "--no-color" | "--no-color=true" | "--no-color=false" | "--color" | "--color=true" + | "--color=false" | "--color=always" | "--color=never" | "-d" | "--delete" + | "--delete=true" | "--delete=false" => {} + "-s" | "--scope" => options.scope = Some(take_val(arguments.next()?)?), + "-p" | "--path" => options.path = Some(take_val(arguments.next()?)?), + "-r" | "--refactor" | "--refactor=true" => options.refactor = true, + "--refactor=false" => options.refactor = false, + "--preserve" | "--preserve=true" => options.preserve = true, + "--preserve=false" => options.preserve = false, + "--deprecate" | "--deprecate=true" => options.deprecate = true, + "--deprecate=false" => options.deprecate = false, + "--ast" | "--ast=true" => options.ast = true, + "--ast=false" => options.ast = false, + _ => apply_inline_or_positional(argument, options, positions)?, + } + Some(()) +} + +#[inline] +fn apply_inline_or_positional( + argument: &str, + options: &mut Options, + positions: &mut Vec, +) -> Option<()> { + if let Some(val) = argument + .strip_prefix("--scope=") + .or_else(|| argument.strip_prefix("-s=")) + { + options.scope = Some(take_val(val)?); + } else if let Some(val) = argument + .strip_prefix("--path=") + .or_else(|| argument.strip_prefix("-p=")) + { + options.path = Some(take_val(val)?); + } else if !argument.starts_with('-') { + positions.push(take_val(argument)?); + } else { + return None; + } + Some(()) +} + +#[inline] +fn take_val(val: &str) -> Option { + let trimmed = val + .trim() + .trim_matches(['"', '\'']) + .trim(); + (!trimmed.is_empty() + && trimmed.len() <= 4096 + && !trimmed.starts_with('-') + && !trimmed.contains('\0')) + .then(|| trimmed.to_owned()) +} diff --git a/native/cli/src/commands/rename/preparation.rs b/native/cli/src/commands/rename/preparation.rs new file mode 100644 index 000000000000..6fefbfa3e6d2 --- /dev/null +++ b/native/cli/src/commands/rename/preparation.rs @@ -0,0 +1,118 @@ +//! Pure source inventory and eligibility precede Harmony providers and publication. +//! +//! [`prepare`] and [`prepare_many`] build [`Prepared`] from [`Plan`], [`Batch`], and [`Engine`] facts for [`Options`]. +use crate::commands::{engine::Engine, workspace_command}; +use bit_install::{config::WorkspaceConfig, workspace::Workspace}; +use bit_object_import::component_rename::{Options, Plan, batch::Batch}; +use serde_json::{Value, json}; +use std::{fs, io, path::Path}; + +/// Prepared component rename state pairing the native [`Plan`] with [`Engine`] inventory facts. +pub(in crate::commands) struct Prepared { + pub(in crate::commands) plan: Plan, + pub(in crate::commands) facts: Value, +} +/// Validates target naming and prepares a [`Prepared`] component rename plan for `options`. +#[inline] +pub(in crate::commands) fn prepare(root: &Path, options: &Options) -> io::Result { + bit_object_import::component_rename::validate_name(&options.target)?; + let (workspace, mut facts, mut engine) = inventory(root)?; + packages(&workspace, options, &mut facts)?; + let plan = Plan::prepare(root, options, &facts)?; + validate_inventory(&mut engine, &facts)?; + Ok(Prepared { plan, facts }) +} +/// Share pure native inventory while retaining per-entry planning and error order. +pub(in crate::commands) fn prepare_many( + root: &Path, + options: &[Options], +) -> io::Result> { + if options.is_empty() { + return Ok(Vec::new()); + } + let (workspace, shared, mut engine) = inventory(root)?; + let mut facts = Vec::with_capacity(options.len()); + let mut package_error = None; + for option in options { + bit_object_import::component_rename::validate_name(&option.target)?; + let mut component = shared.clone(); + if let Err(error) = packages(&workspace, option, &mut component) { + package_error = Some(error); + break; + } + facts.push(component); + } + // An earlier plan failure precedes a later package lookup failure, matching + // the former serial prepare loop. Parallel plan results are ordered too. + let plans = Batch::prepare_plans(root, &options[..facts.len()], &facts)?; + if let Some(error) = package_error { + return Err(error); + } + validate_inventory(&mut engine, &shared)?; + Ok(plans + .into_iter() + .zip(facts) + .map(|(plan, facts)| Prepared { plan, facts }) + .collect()) +} +/// Loads the projected [`Workspace`] and `trackingContext` inventory facts from [`Engine`]. +fn inventory(root: &Path) -> io::Result<(Workspace, Value, Engine)> { + let bitmap = bit_object_import::parse_jsonc(&fs::read(root.join(".bitmap"))?)?; + let mut engine = Engine::new()?; + let mut context: Value = engine.request(&json!({"kind":"trackingContext","root":root,"home":workspace_command::home(),"bitmap":bitmap}))?; + let config = + WorkspaceConfig::from_value(root.join("workspace.jsonc"), context["workspace"].take())?; + let core: Vec = serde_json::from_str(include_str!( + "../../../../../scopes/harmony/testing/load-aspect/core-aspects-ids.json" + ))?; + let workspace = Workspace::load_projected(root, &config, &core, &bitmap)?; + let components: Vec = workspace.components.iter().map(|component| json!({"identity":component.id.to_string_without_version(),"rootDir":component.root_dir,"files":component.files,"packageName":component.package_name})).collect(); + Ok(( + workspace, + json!({"components":components,"preflight":true,"inventoryReadSet":context["controlReadSet"].take()}), + engine, + )) +} + +#[inline] +fn validate_inventory(engine: &mut Engine, facts: &Value) -> io::Result<()> { + let reads = facts["inventoryReadSet"] + .as_array() + .ok_or_else(|| io::Error::other("rename inventory control proofs"))?; + engine.validate_read_set(reads, "rename inventory controls changed before component loading") +} + +fn packages(workspace: &Workspace, options: &Options, facts: &mut Value) -> io::Result<()> { + let source = options.source.trim(); + let bare = source + .rsplit_once('@') + .filter(|(id, _)| !id.trim().is_empty()) + .map_or(source, |(id, _)| id.trim()) + .trim_matches('/'); + let component = workspace.components + .iter() + .find(|component| { + !bare.is_empty() + && (component.id.name == bare || component.id.to_string_without_version() == bare) + }) + .ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidInput, format!("component {source} was not found")) + })?; + facts["sourcePackage"] = json!(component.package_name.trim()); + let mut id = component.id.clone(); + options.target + .trim() + .trim_matches('/') + .clone_into(&mut id.name); + id.scope = None; + id.version = None; + id.default_scope = options.scope + .as_deref() + .map(|scope| scope.trim().trim_matches('/')) + .filter(|scope| !scope.is_empty()) + .map(str::to_owned) + .or_else(|| component.id.effective_scope().map(str::to_owned)); + facts["targetPackage"] = + json!(bit_install::workspace::component_package_name(&id, None, &component.config)); + Ok(()) +} diff --git a/native/cli/src/commands/reset.rs b/native/cli/src/commands/reset.rs new file mode 100644 index 000000000000..8835ce29b5e8 --- /dev/null +++ b/native/cli/src/commands/reset.rs @@ -0,0 +1,404 @@ +//! Native reset ([`COMMAND`], [`Options`]) of local main ([`Batch`], [`Plan`]) and lane histories ([`LaneBatch`]) while preserving working-tree bytes. + +mod main_ext; +mod reports; +mod selection; + +use super::{list::bitmap, workspace_command}; +use crate::{commands::Command, native::Outcome, workspace}; +use bit_object_import::{ + reset_lane::Batch as LaneBatch, + reset_main::{Batch, Plan}, + reset_never_exported::Plan as NeverPlan, + reset_soft::Plan as SoftPlan, +}; +use reports::{batch_report, never_exported_report, report, soft_report}; +use selection::LANE_CI_DIAGNOSTIC; +use std::{env, ffi::OsString, io, path::Path}; + +/// Descriptor for the native `bit reset` command. +pub const COMMAND: Command = Command { name: "reset", aliases: &[], run }; +/// Parsed command-line options for `bit reset`. +#[derive(Default)] +struct Options<'a> { + pattern: Option<&'a str>, + head: bool, + force: bool, + soft: bool, + silent: bool, + never_exported: bool, + selected: Option, +} + +fn parse<'a>(args: &[&'a str]) -> Option> { + let mut options = Options::default(); + let mut iter = args.iter().copied().map(str::trim); + while let Some(arg) = iter.next() { + if arg == "--" { + for rest in iter { + (!rest.is_empty() && !rest.starts_with('-')).then_some(())?; + apply_reset_arg(rest, &mut options)?; + } + break; + } + apply_reset_arg(arg, &mut options)?; + } + Some(options) +} + +fn apply_reset_arg<'a>(arg: &'a str, options: &mut Options<'a>) -> Option<()> { + match arg { + "--no-color" | "--no-color=true" | "--no-color=false" | "--color" | "--color=true" + | "--color=false" | "--color=always" | "--color=never" => {} + "--head" | "--head=true" => options.head = true, + "--head=false" => options.head = false, + "--force" | "-f" | "--force=true" => options.force = true, + "--force=false" => options.force = false, + "--soft" | "--soft=true" => options.soft = true, + "--soft=false" => options.soft = false, + "--never-exported" | "--never-exported=true" => options.never_exported = true, + "--never-exported=false" => options.never_exported = false, + "--silent" | "-s" | "--silent=true" | "--all" | "-a" | "--all=true" => { + options.silent = true; + } + "--silent=false" | "--all=false" => options.silent = false, + arg if is_combined_reset_short(arg) => apply_combined_reset_short(arg, options), + arg if !arg.starts_with('-') + && !arg.is_empty() + && options.pattern.is_none() + && arg.len() <= 4096 => + { + options.pattern = Some(arg); + } + _ => return None, + } + Some(()) +} + +#[inline] +fn is_combined_reset_short(arg: &str) -> bool { + arg.starts_with('-') + && !arg.starts_with("--") + && arg.len() > 1 + && arg[1..] + .bytes() + .all(|byte| matches!(byte, b'f' | b's' | b'a')) +} + +#[inline] +fn apply_combined_reset_short(arg: &str, options: &mut Options<'_>) { + for byte in arg[1..].bytes() { + if byte == b'f' { + options.force = true; + } else { + options.silent = true; + } + } +} + +fn run(args: &[OsString]) -> io::Result { + let Some(argv) = workspace_command::utf8_args(args) else { + return Ok(Outcome::Declined("reset arguments are not UTF-8".into())); + }; + let Some(mut options) = parse(&argv[1..]) else { + return Ok(Outcome::Declined("outside the native reset grammar".into())); + }; + let cwd = env::current_dir()?; + let Some(root) = workspace::root(&cwd) else { + return run_without_workspace(&cwd, args, &options); + }; + if needs_lane_ci_decline(&root, &options)? { + return Ok(Outcome::Declined(LANE_CI_DIAGNOSTIC.into())); + } + let mut startup = super::status::autosync::Startup::default(); + if !(options.never_exported || options.soft && options.head) + && let Some(pattern) = options.pattern.filter(|pattern| selection::stateful(pattern)) + { + match selection::states(&root, pattern, &mut startup) { + Ok(selected) => options.selected = Some(selected), + Err(error) => return selection_failure(args, &root, &startup, error), + } + } + run_prepared(args, &root, &options, &startup) +} + +#[inline] +fn needs_lane_ci_decline(root: &Path, options: &Options<'_>) -> io::Result { + if options.soft + || options.never_exported + || !options.pattern.is_some_and(selection::stateful) + || !selection::ci_active() + { + return Ok(false); + } + Ok(bitmap::read(root)?.lane.is_some()) +} + +fn run_without_workspace( + cwd: &Path, + args: &[OsString], + options: &Options<'_>, +) -> io::Result { + if selection::ci_active() { + return Ok(Outcome::Declined("not inside a workspace".into())); + } + let msg = if options.never_exported { + "Cannot read properties of undefined (reading 'consumer')" + } else if options.soft && options.head { + "please specify either --soft or --head flag, not both" + } else if options.pattern.is_some() || options.silent { + super::log::OUTSIDE_WORKSPACE_ERR + } else { + return Ok(Outcome::Declined("not inside a workspace".into())); + }; + workspace_command::execute_failure_scope(cwd, args, "reset", false, "", io::Error::other(msg)) +} + +fn run_prepared( + args: &[OsString], + root: &Path, + options: &Options<'_>, + startup: &super::status::autosync::Startup, +) -> io::Result { + workspace_command::checkout_lifecycle::before(startup)?; + match dispatch(args, root, options)? { + Outcome::Declined(reason) if startup.committed => { + workspace_command::execute_then(root, args, "reset", |output| { + if let Some(selected) = &options.selected { + selected.render(output); + } + Err(io::Error::other(reason)) + }) + } + outcome => Ok(outcome), + } +} + +#[inline] +fn selection_failure( + args: &[OsString], + root: &Path, + startup: &super::status::autosync::Startup, + error: io::Error, +) -> io::Result { + if startup.committed { + workspace_command::checkout_lifecycle::failure(root, args, "reset", startup, error) + } else { + Ok(Outcome::Declined(format!("reset selection: {error}"))) + } +} + +#[inline] +fn dispatch(args: &[OsString], root: &Path, options: &Options<'_>) -> io::Result { + if options.never_exported { + return run_never_exported(args, root); + } + if options.soft { + return route_soft(args, root, options); + } + route_hard(args, root, options) +} + +fn route_soft(args: &[OsString], root: &Path, options: &Options<'_>) -> io::Result { + if options.head { + if selection::ci_active() { + return Ok(Outcome::Declined("CI reset soft+head".into())); + } + return workspace_command::execute_then(root, args, "reset", |_| { + Err(io::Error::other("please specify either --soft or --head flag, not both")) + }); + } + if options.pattern.is_none() && !options.silent { + return Ok(Outcome::Declined("outside the native soft reset grammar".into())); + } + run_soft(args, root, options) +} + +fn route_hard(args: &[OsString], root: &Path, options: &Options<'_>) -> io::Result { + let Some(pattern) = options.pattern else { + if !options.silent { + return Ok(Outcome::Declined("reset all components requires confirmation".into())); + } + return run_batch(args, root, options.head, None, true); + }; + if selection::complex(pattern) { + return route_complex_hard(args, root, options, pattern); + } + let (identity, on_lane) = match selection::identity(root, pattern) { + Ok(identity) => identity, + Err(_) if main_ext::missing_component(root, pattern) => { + return workspace_command::execute_then(root, args, "reset", |_| { + Err(io::Error::other(format!( + r#"error: component "{pattern}" was not found on your local workspace."#, + ))) + }); + } + Err(error) => return Ok(Outcome::Declined(format!("reset identity: {error}"))), + }; + if on_lane { + let selected = selection::Selected::ordinary(vec![identity]); + return run_lane_batch(args, root, options.head, Some(&selected), options.force); + } + run_main(args, root, &identity, options.head, options.force) +} + +fn route_complex_hard( + args: &[OsString], + root: &Path, + options: &Options<'_>, + pattern: &str, +) -> io::Result { + let owned; + let selected = if let Some(selected) = &options.selected { + selected + } else { + match selection::identities(root, pattern) { + Ok(ids) => { + owned = selection::Selected::ordinary(ids); + &owned + } + Err(error) => return Ok(Outcome::Declined(format!("reset selection: {error}"))), + } + }; + run_batch(args, root, options.head, Some(selected), options.force) +} + +fn run_main( + args: &[OsString], + root: &Path, + identity: &str, + head: bool, + force: bool, +) -> io::Result { + let plan = match Plan::prepare(root, identity, head, force) { + Ok(plan) => plan, + Err(error) if !force && error.to_string() == "reset version has a dependent" => { + if let Some(message) = main_ext::dependent_error(root, identity, head) { + return workspace_command::execute_then(root, args, "reset", |_| { + Err(io::Error::other(message)) + }); + } + return Ok(Outcome::Declined(format!("reset: {error}"))); + } + Err(error) => return Ok(Outcome::Declined(format!("reset: {error}"))), + }; + let report = report(args, identity, &plan.selection.labels); + workspace_command::execute_with(root, args, "reset", &report, |_| { + plan.commit(&workspace_command::file_id()) + }) +} + +fn run_never_exported(args: &[OsString], root: &Path) -> io::Result { + let plan = match NeverPlan::prepare(root) { + Ok(plan) => plan, + Err(error) => return Ok(Outcome::Declined(format!("never-exported reset: {error}"))), + }; + let report = never_exported_report(args, &plan.labels); + workspace_command::execute_with(root, args, "reset", &report, |_| { + plan.commit(&workspace_command::file_id()) + }) +} + +fn run_batch( + args: &[OsString], + root: &Path, + head_only: bool, + selected: Option<&selection::Selected>, + force: bool, +) -> io::Result { + if bitmap::read(root)?.lane.is_some() { + return run_lane_batch(args, root, head_only, selected, force); + } + let batch = match selected.map_or_else( + || Batch::prepare(root, head_only), + |ids| Batch::prepare_selected(root, &ids.identities, head_only, force), + ) { + Ok(batch) => batch, + Err(_) if !head_only && selected.is_none() => { + if let Ok(detached) = main_ext::DetachedBatch::prepare(root) { + return run_detached_batch(args, root, &detached); + } + return Ok(Outcome::Declined("reset batch: detached fallback failed".into())); + } + Err(error) => return Ok(Outcome::Declined(format!("reset batch: {error}"))), + }; + let report = batch_report(args, batch.results()); + workspace_command::execute_with(root, args, "reset", &report, |output| { + if let Some(selected) = selected { + selected.render(output); + selected.verify()?; + } + batch.commit(&workspace_command::file_id()) + }) +} + +/// Format and commit a prepared detached-head main-lane batch reset. +#[inline] +fn run_detached_batch( + args: &[OsString], + root: &Path, + batch: &main_ext::DetachedBatch, +) -> io::Result { + let report = batch_report(args, batch.results()); + workspace_command::execute_with(root, args, "reset", &report, |_| { + batch.commit(&workspace_command::file_id()) + }) +} + +fn run_lane_batch( + args: &[OsString], + root: &Path, + head_only: bool, + selected: Option<&selection::Selected>, + force: bool, +) -> io::Result { + let batch = match selected.map_or_else( + || LaneBatch::prepare(root, head_only), + |ids| LaneBatch::prepare_selected(root, &ids.identities, head_only, force), + ) { + Ok(batch) => batch, + Err(error) if bit_object_import::reset_lane::diagnostic(&error).is_some() => { + return selection::lane_failure(args, root, selected, &error); + } + Err(error) => return Ok(Outcome::Declined(format!("lane reset batch: {error}"))), + }; + let report = batch_report(args, batch.results()); + workspace_command::execute_with(root, args, "reset", &report, |output| { + if let Some(selected) = selected { + selected.render(output); + selected.verify()?; + } + batch.commit(&workspace_command::file_id()) + }) +} + +fn run_soft(args: &[OsString], root: &Path, options: &Options<'_>) -> io::Result { + let owned; + let selected = match (&options.selected, options.pattern) { + (Some(selected), _) => Some(selected), + (None, Some(pattern)) if selection::complex(pattern) => { + let Ok(ids) = selection::identities(root, pattern) else { + return Ok(Outcome::Declined("soft reset selection".into())); + }; + owned = selection::Selected::ordinary(ids); + Some(&owned) + } + (None, _) => None, + }; + let prepared = match selected { + Some(ids) => SoftPlan::prepare_selected(root, &ids.identities), + None => SoftPlan::prepare(root, options.pattern), + }; + let plan = match prepared { + Ok(plan) => plan, + Err(error) => return Ok(Outcome::Declined(format!("soft reset: {error}"))), + }; + let report = soft_report(args, &plan); + workspace_command::execute_with(root, args, "reset", &report, |output| { + if let Some(selected) = selected { + selected.render(output); + selected.verify()?; + } + plan.commit(&workspace_command::file_id()) + }) +} diff --git a/native/cli/src/commands/reset/main_ext.rs b/native/cli/src/commands/reset/main_ext.rs new file mode 100644 index 000000000000..f0fb78879847 --- /dev/null +++ b/native/cli/src/commands/reset/main_ext.rs @@ -0,0 +1,390 @@ +//! Main-lane reset diagnostics and detached-head recovery. +//! +//! [`missing_component`] and [`dependent_error`] evaluate [`Plan`] and `.bitmap` ([`bitmap`]) state via [`read_scope_object`] and [`render_workspace_bitmap`]. + +use crate::commands::list::bitmap; +use bit_object_import::{ + parse_jsonc, read_scope_object, render_workspace_bitmap, + reset_main::{self, Plan}, + scope_path, +}; +use serde_json::{Map, Value, json}; +use sha1::{Digest, Sha1}; +use std::{ + fs, io, + path::{Path, PathBuf}, +}; + +/// Checks whether a non-glob component `pattern` is absent from a main-lane `.bitmap`. +pub(super) fn missing_component(root: &Path, pattern: &str) -> bool { + let pattern = pattern.trim().trim_end_matches('/'); + if pattern.is_empty() || pattern.contains('@') { + return false; + } + let Ok(bitmap) = bitmap::read(root) else { return false }; + if bitmap.lane.is_some() { + return false; + } + let dir = pattern.trim_start_matches("./").trim_start_matches(r".\"); + !bitmap.entries + .iter() + .any(|entry| { + entry.root_dir.trim_end_matches('/') == dir + || entry.identity == pattern + || entry.identity + .split_once('/') + .is_some_and(|(_, name)| name == pattern) + }) +} + +/// Format the dependent blocker message when resetting a main-lane component without `--force`. +pub(super) fn dependent_error(root: &Path, identity: &str, head_only: bool) -> Option { + let plan = Plan::prepare(root, identity, head_only, true).ok()?; + let scope = scope_path(root); + let references = load_all_version_refs(&scope).ok()?; + for label in &plan.selection.labels { + let target = format!("{identity}@{label}"); + let dependents = immediate_dependents(&scope, &references, &target).ok()?; + if !dependents.is_empty() { + return Some(format!( + "unable to reset {identity}, the version {label} has the following dependent(s) {}", + dependents.join(", "), + )); + } + } + None +} + +/// Collect `(version_hash, "scope/name@version")` across all indexed components. +fn load_all_version_refs(scope: &Path) -> io::Result> { + let index = parse_jsonc(&fs::read(scope.join("index.json"))?)?; + let rows = index["components"] + .as_array() + .or_else(|| index.as_array()) + .ok_or_else(|| invalid("reset index"))?; + let mut refs = Vec::with_capacity(rows.len()); + for row in rows { + if row["isSymlink"] == true { + continue; + } + let hash = row["hash"].as_str().ok_or_else(|| invalid("reset model hash"))?; + if let Some((_, model)) = read_scope_object(scope, hash)? { + append_model_version_refs(&model, row, &mut refs)?; + } + } + Ok(refs) +} + +/// Append `(version_hash, "scope/name@version")` entries for one `Component` model. +fn append_model_version_refs( + model: &Value, + row: &Value, + refs: &mut Vec<(String, String)>, +) -> io::Result<()> { + let (comp_scope, comp_name) = model_scope_and_name(model, row); + if comp_name.is_empty() { + return Err(invalid("reset name")); + } + let identity = + if comp_scope.is_empty() { comp_name } else { format!("{comp_scope}/{comp_name}") }; + for field in ["versions", "orphanedVersions"] { + if let Some(versions) = model[field].as_object() { + append_version_map(&identity, versions, refs)?; + } + } + Ok(()) +} + +fn model_scope_and_name(model: &Value, row: &Value) -> (String, String) { + let base = row["id"] + .as_str() + .map_or("", |id| id.split('@').next().unwrap_or(id)); + let (id_scope, id_name) = base + .split_once('/') + .unwrap_or(("", base)); + ( + non_empty_field(&model["scope"], &row["id"]["scope"], id_scope), + non_empty_field(&model["name"], &row["id"]["name"], id_name), + ) +} + +fn non_empty_field(primary: &Value, secondary: &Value, fallback: &str) -> String { + for candidate in [primary.as_str(), secondary.as_str(), Some(fallback)] { + if let Some(trimmed) = candidate + .map(str::trim) + .filter(|val| !val.is_empty()) + { + return trimmed.to_owned(); + } + } + String::new() +} + +/// Append version entries from a single version map (`versions` or `orphanedVersions`). +fn append_version_map( + identity: &str, + versions: &Map, + refs: &mut Vec<(String, String)>, +) -> io::Result<()> { + for (label, ver_hash) in versions { + let ver_hash = ver_hash.as_str().ok_or_else(|| invalid("reset version hash"))?; + refs.push((ver_hash.to_owned(), format!("{identity}@{label}"))); + } + Ok(()) +} + +/// Find versioned component IDs whose `flattenedDependencies` include `target`. +fn immediate_dependents( + scope: &Path, + references: &[(String, String)], + target: &str, +) -> io::Result> { + let mut found = Vec::new(); + for (reference, id) in references { + let Some((_, version)) = read_scope_object(scope, reference)? else { + continue; + }; + if version_depends_on(&version, target) && !found.contains(id) { + found.push(id.clone()); + } + } + Ok(found) +} + +/// Check whether `version["flattenedDependencies"]` or `version["flattenedDevDependencies"]` references `target`. +fn version_depends_on(version: &Value, target: &str) -> bool { + ["flattenedDependencies", "flattenedDevDependencies"] + .into_iter() + .any(|field| { + version[field] + .as_array() + .is_some_and(|rows| { + rows.iter() + .any(|row| { + crate::commands::graph_cmd::builder::format_dep_id(row).as_deref() + == Some(target) + }) + }) + }) +} + +/// Prepared reset batch for main-lane components whose local snaps are detached heads. +pub(super) struct DetachedBatch { + root: PathBuf, + items: Vec, + bitmap_after: Vec, +} + +struct DetachedItem { + identity: String, + labels: Vec, + model_hash: String, + model_after: Vec, + history: Option<(String, Vec)>, + bitmap_version: String, +} + +impl DetachedBatch { + /// Prepare a batch reset of main-lane components with detached heads. + pub(super) fn prepare(root: &Path) -> io::Result { + let scope = scope_path(root); + let index = parse_jsonc(&fs::read(scope.join("index.json"))?)?; + let rows = index["components"] + .as_array() + .or_else(|| index.as_array()) + .ok_or_else(|| invalid("detached index"))?; + let mut items = Vec::new(); + for row in rows { + if row["isSymlink"] != true + && let Some(item) = prepare_item(&scope, row)? + { + items.push(item); + } + } + if items.is_empty() { + return Err(invalid("no detached components to reset")); + } + let bitmap_after = project_bitmap(root, &items)?; + Ok(Self { root: root.to_owned(), items, bitmap_after }) + } + + /// Yield `(identity, labels)` for the reset report. + #[inline] + pub(super) fn results(&self) -> impl Iterator { + self.items + .iter() + .map(|item| (item.identity.as_str(), item.labels.as_slice())) + } + + /// Persist the updated Component, `VersionHistory`, and `.bitmap` files. + pub(super) fn commit(&self, file_id: &str) -> io::Result<()> { + let scope = scope_path(&self.root); + for item in &self.items { + super::reports::write_object(&scope, &item.model_hash, &item.model_after)?; + if let Some((hash, bytes)) = &item.history { + super::reports::write_object(&scope, hash, bytes)?; + } + } + reset_main::backup_bitmap(&self.root, file_id)?; + fs::write(self.root.join(".bitmap"), &self.bitmap_after) + } +} + +/// Prepare one component if it has local versions on a detached head. +fn prepare_item(scope: &Path, row: &Value) -> io::Result> { + let model_hash = row["hash"].as_str().ok_or_else(|| invalid("detached hash"))?; + let (_, mut model) = + read_scope_object(scope, model_hash)?.ok_or_else(|| invalid("detached model absent"))?; + let local = local_versions(&model); + if local.is_empty() { + return Ok(None); + } + let current = detached_current_snap(&model, &local)?; + let (parents, bitmap_version) = detached_parent_info(scope, &model, ¤t)?; + let (comp_scope, comp_name) = model_scope_and_name(&model, row); + if comp_scope.is_empty() || comp_name.is_empty() { + return Err(invalid("detached scope or name")); + } + update_detached_model(&mut model, ¤t)?; + let model_after = super::reports::encode_object("Component", model_hash, &model)?; + let history = prepare_history(scope, &comp_scope, &comp_name, ¤t, &parents)?; + Ok(Some(DetachedItem { + identity: format!("{comp_scope}/{comp_name}"), + labels: vec![current], + model_hash: model_hash.to_owned(), + model_after, + history, + bitmap_version, + })) +} + +/// Validate that the component's only local snap is its detached current head. +fn detached_current_snap(model: &Value, local: &[String]) -> io::Result { + let head = model["head"] + .as_str() + .ok_or_else(|| invalid("detached main head"))? + .trim(); + let current = model["detachedHeads"]["current"] + .as_str() + .ok_or_else(|| invalid("detached current head"))? + .trim(); + if head.is_empty() || current.is_empty() || local != [current] || head == current { + return Err(invalid("unsupported detached history")); + } + Ok(current.to_owned()) +} + +/// Load the detached `Version` object and resolve its parent list and `.bitmap` version label. +fn detached_parent_info(scope: &Path, model: &Value, current: &str) -> io::Result<(Value, String)> { + let (_, mut version) = + read_scope_object(scope, current)?.ok_or_else(|| invalid("detached version absent"))?; + let parents_val = version["parents"].take(); + let parents = parents_val.as_array().ok_or_else(|| invalid("detached parents"))?; + let [parent] = parents.as_slice() else { + return Err(invalid("detached parent count")); + }; + let parent_hash = parent.as_str().ok_or_else(|| invalid("detached parent hash"))?; + let label = crate::commands::show_cmd::remote_show::find_tag_for_hash(model, parent_hash) + .unwrap_or_else(|| parent_hash.to_owned()); + Ok((parents_val, label)) +} + +/// Extract keys marked `local: true` in `state.versions`. +#[inline] +fn local_versions(model: &Value) -> Vec { + model["state"]["versions"] + .as_object() + .into_iter() + .flatten() + .filter(|(_, entry)| entry["local"] == true) + .map(|(label, _)| label.clone()) + .collect() +} + +/// Remove the detached version from `versions`, `state.versions`, and `detachedHeads`. +fn update_detached_model(model: &mut Value, removed: &str) -> io::Result<()> { + if let Some(versions) = model["versions"].as_object_mut() { + versions.shift_remove(removed); + } + if let Some(state_versions) = model["state"]["versions"].as_object_mut() { + state_versions.shift_remove(removed); + } + let fields = model.as_object_mut().ok_or_else(|| invalid("detached model object"))?; + if let Some(detached) = fields.get_mut("detachedHeads").and_then(Value::as_object_mut) { + prune_detached_heads(detached, removed); + if Map::is_empty(detached) { + fields.shift_remove("detachedHeads"); + } + } + Ok(()) +} + +/// Prune `heads`, `current`, and empty `deleted` from `detachedHeads`. +fn prune_detached_heads(detached: &mut Map, removed: &str) { + if let Some(heads) = detached.get_mut("heads").and_then(Value::as_array_mut) { + heads.retain(|hash| hash.as_str() != Some(removed)); + if heads.is_empty() { + detached.shift_remove("heads"); + } + } + if detached.get("current").and_then(Value::as_str) == Some(removed) { + detached.shift_remove("current"); + } + if detached + .get("deleted") + .and_then(Value::as_array) + .is_some_and(Vec::is_empty) + { + detached.shift_remove("deleted"); + } +} + +/// Append the removed detached snap to `VersionHistory` if not already recorded. +fn prepare_history( + scope: &Path, + comp_scope: &str, + comp_name: &str, + removed: &str, + parents: &Value, +) -> io::Result)>> { + let key = format!("{comp_scope}/{comp_name}:VersionHistory"); + let hash = format!("{:x}", Sha1::digest(key.as_bytes())); + let mut history = match read_scope_object(scope, &hash)? { + Some((_, existing)) => existing, + None => { + json!({ "name": comp_name, "scope": comp_scope, "versions": [], "graphCompleteRefs": [] }) + } + }; + let versions = + history["versions"].as_array_mut().ok_or_else(|| invalid("detached history versions"))?; + if versions + .iter() + .any(|entry| entry["hash"] == removed) + { + return Ok(None); + } + versions.push(json!({"hash": removed, "parents": parents})); + let bytes = super::reports::encode_object("VersionHistory", &hash, &history)?; + Ok(Some((hash, bytes))) +} + +/// Project `.bitmap` with the pre-detachment version for each reset component. +fn project_bitmap(root: &Path, items: &[DetachedItem]) -> io::Result> { + let bytes = fs::read(root.join(".bitmap"))?; + let mut document = parse_jsonc(&bytes)?; + let fields = document.as_object_mut().ok_or_else(|| invalid("detached bitmap object"))?; + for item in items { + let (scope, name) = + item.identity.split_once('/').ok_or_else(|| invalid("detached item identity"))?; + let key = super::reports::find_bitmap_key(fields, scope, name)?; + let row = fields[&key].as_object_mut().ok_or_else(|| invalid("detached bitmap row"))?; + row.insert("version".into(), Value::String(item.bitmap_version.clone())); + row.shift_remove("nextVersion"); + } + render_workspace_bitmap(&document) +} + +#[inline] +fn invalid(message: &str) -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, message) +} diff --git a/native/cli/src/commands/reset/reports.rs b/native/cli/src/commands/reset/reports.rs new file mode 100644 index 000000000000..4724835166f4 --- /dev/null +++ b/native/cli/src/commands/reset/reports.rs @@ -0,0 +1,183 @@ +//! Canonical reset summaries and detached-reset object helpers. +//! +//! [`soft_report`] and related formatters render [`SoftPlan`] results into a [`Report`] with [`Formatter`] and [`Chalk`]. +use crate::commands::{ + color::Chalk, + workspace_command::{format::Formatter, results::Report}, +}; +use bit_object_import::reset_soft::Plan as SoftPlan; +use serde_json::{Map, Value}; +use std::{ffi::OsString, fs, io, path::Path}; + +/// Formats the summary [`Report`] for a `--soft` reset [`SoftPlan`]. +pub(super) fn soft_report(args: &[OsString], plan: &SoftPlan) -> Report { + let formatter = Formatter::new(Chalk::detect(args)); + let items: Vec<_> = plan.entries + .iter() + .map(|entry| { + formatter.item(&format!( + "{} - version(s): {}", + formatter.chalk.cyan(&entry.identity), + entry.version, + )) + }) + .collect(); + let count = items.len(); + Report { + data: Formatter::join(&[ + formatter.section( + "soft-untagged components", + "soft-untagged versions are no longer candidates for tagging", + &items, + ), + formatter.success_summary(&format!("{count} component(s) soft-untagged successfully")), + ]), + code: 0, + details: None, + } +} + +/// Formats the summary [`Report`] for a single-component reset of `identity` across `labels`. +#[inline] +pub(super) fn report(args: &[OsString], identity: &str, labels: &[String]) -> Report { + let formatter = Formatter::new(Chalk::detect(args)); + let item = formatter.item(&format!( + "{} - version(s): {}", + formatter.chalk.cyan(identity.trim()), + labels.join(", "), + )); + Report { + data: Formatter::join(&[ + formatter.section("reset components", "", &[item]), + formatter.success_summary("1 component(s) reset successfully"), + ]), + code: 0, + details: None, + } +} + +/// Format a multi-component reset report from `(identity, version_labels)` pairs. +pub(super) fn batch_report<'a>( + args: &[OsString], + results: impl Iterator, +) -> Report { + let formatter = Formatter::new(Chalk::detect(args)); + let items: Vec<_> = results + .map(|(identity, labels)| { + formatter.item(&format!( + "{} - version(s): {}", + formatter.chalk.cyan(identity.trim()), + labels.join(", "), + )) + }) + .collect(); + let count = items.len(); + Report { + data: Formatter::join(&[ + formatter.section("reset components", "", &items), + formatter.success_summary(&format!("{count} component(s) reset successfully")), + ]), + code: 0, + details: None, + } +} + +pub(super) fn never_exported_report(args: &[OsString], labels: &[String]) -> Report { + let formatter = Formatter::new(Chalk::detect(args)); + let items = labels + .iter() + .map(|label| label.trim()) + .filter(|label| !label.is_empty()) + .map(|label| formatter.item(label)) + .collect::>(); + let count = items.len(); + Report { + data: Formatter::join(&[ + formatter.section("reset never-exported components", "", &items), + formatter.success_summary(&format!( + "successfully reset {count} never-exported component(s)", + )), + ]), + code: 0, + details: None, + } +} + +/// Locate the `.bitmap` key for `scope/name`. +pub(super) fn find_bitmap_key( + fields: &Map, + scope: &str, + name: &str, +) -> io::Result { + let mut fallback = None; + for (key, row) in fields { + if key.starts_with('$') || key == "_bit_lane" || key == "version" || !row.is_object() { + continue; + } + let (row_scope, row_name) = + crate::commands::update_cmd::bitmap_row_scope_and_name(key, row); + if row_name == name && row_scope == scope { + return Ok(key.clone()); + } + if row_name == name && row_scope.is_empty() && fallback.is_none() { + fallback = Some(key); + } + } + fallback + .cloned() + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "detached bitmap key absent")) +} + +/// Encode a Bit scope object into a valid RFC 1950 zlib stream using stored deflate blocks. +pub(super) fn encode_object(kind: &str, hash: &str, value: &Value) -> io::Result> { + let body = serde_json::to_string(value).map_err(io::Error::other)?; + let raw = format!("{kind} {hash} {}\0{body}", body.encode_utf16().count()).into_bytes(); + let mut out = Vec::with_capacity(raw.len() + 16); + out.extend_from_slice(&[0x78, 0x01]); + if raw.is_empty() { + out.extend_from_slice(&[1, 0, 0, 0xff, 0xff]); + } else { + let chunks = raw.chunks(65535); + let total = chunks.len(); + for (index, chunk) in chunks.enumerate() { + let last = u8::from(index + 1 == total); + let len = u16::try_from(chunk.len()).unwrap_or(0); + out.push(last); + out.extend_from_slice(&len.to_le_bytes()); + out.extend_from_slice(&(!len).to_le_bytes()); + out.extend_from_slice(chunk); + } + } + out.extend_from_slice(&adler32(&raw).to_be_bytes()); + Ok(out) +} + +#[inline] +fn adler32(bytes: &[u8]) -> u32 { + let mut sum_low: u32 = 1; + let mut sum_high: u32 = 0; + for &byte in bytes { + sum_low = (sum_low + u32::from(byte)) % 65521; + sum_high = (sum_high + sum_low) % 65521; + } + (sum_high << 16) | sum_low +} + +/// Write compressed object bytes to `/objects/<2>/<38>`. +pub(super) fn write_object(scope: &Path, hash: &str, bytes: &[u8]) -> io::Result<()> { + let trimmed = hash.trim(); + if trimmed.len() != 40 || !trimmed.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err(io::Error::new(io::ErrorKind::InvalidInput, "invalid object hash")); + } + let path = scope + .join("objects") + .join(&trimmed[..2]) + .join(&trimmed[2..]); + if let Some(parent) = path + .parent() + .filter(|dir| !dir.as_os_str().is_empty()) + { + fs::create_dir_all(parent)?; + } + fs::write(path, bytes) +} diff --git a/native/cli/src/commands/reset/selection.rs b/native/cli/src/commands/reset/selection.rs new file mode 100644 index 000000000000..c1cd28a2105d --- /dev/null +++ b/native/cli/src/commands/reset/selection.rs @@ -0,0 +1,248 @@ +//! Resolve reset selectors before preparing one main or lane batch. +//! +//! [`identity`] and [`complex`] coordinate [`Engine`], [`bitmap`], [`patterns`], and [`Startup`] autosync for reset target resolution. +use crate::commands::{ + diff::selection, + engine::Engine, + list::bitmap, + patterns, + status::{ + autosync::Startup, + capture::{self, CaptureOptions}, + environments, + js::{array, decline}, + }, + workspace_command, +}; +use bit_install::{output::Output, policy::scope::read_proof}; +use serde_json::{Value, json}; +use std::{io, path::Path, sync::Arc}; + +/// Decline reason when a lane reset requires published CI stack diagnostics. +pub(super) const LANE_CI_DIAGNOSTIC: &str = "CI lane reset diagnostics require the published stack"; + +/// Returns `true` when `pattern` contains glob, negation, comma, or whitespace meta-characters. +#[inline] +pub(super) fn complex(pattern: &str) -> bool { + pattern.contains(['*', '?', '[', ']', '{', '}', '!', ',', '$', ' ']) +} + +/// Resolves a single main or lane component identity and lane presence flag from `pattern`. +pub(super) fn identity(root: &Path, pattern: &str) -> io::Result<(String, bool)> { + let pattern = pattern.trim(); + let bitmap = bitmap::read(root)?; + let mut matched = bitmap.entries + .iter() + .filter(|entry| { + entry.identity == pattern + || entry.identity + .split_once('/') + .is_some_and(|(_, name)| name == pattern) + }); + let Some(entry) = matched.next() else { + return Err(io::Error::other("reset identity absent or ambiguous")); + }; + if matched.next().is_some() { + return Err(io::Error::other("reset identity absent or ambiguous")); + } + if !entry.on_current_lane_including_removed(&[]) || entry.staged_deprecation { + return Err(io::Error::other("reset component unavailable on main")); + } + Ok((entry.identity.clone(), bitmap.lane.is_some())) +} + +pub(super) fn identities(root: &Path, pattern: &str) -> io::Result> { + let pattern = pattern.trim(); + let bitmap = bitmap::read(root)?; + let pool = bitmap.entries + .iter() + .filter(|entry| entry.on_current_lane_including_removed(&[])) + .map(|entry| entry.identity.as_str()) + .collect::>(); + let selected = if complex(pattern) { + if pattern.contains('$') || pattern.contains(" AND ") { + return Err(decline("reset state selector requires workspace facts")); + } + patterns::extended::matching(&pool, &patterns::split(pattern))? + } else { + pool.iter() + .copied() + .filter(|identity| { + *identity == pattern + || identity + .split_once('/') + .is_some_and(|(_, name)| name == pattern) + }) + .collect() + }; + if selected.is_empty() { + return Err(decline("reset component pattern has no matches")); + } + Ok(selected + .into_iter() + .map(str::to_string) + .collect()) +} + +#[inline] +pub(super) fn ci_active() -> bool { + std::env::var_os("CI") + .is_some_and(|value| { + let text = value.to_string_lossy(); + let trimmed = text + .trim() + .trim_matches(['"', '\'']) + .trim(); + !trimmed.is_empty() + && !trimmed.eq_ignore_ascii_case("0") + && !trimmed.eq_ignore_ascii_case("false") + && !trimmed.eq_ignore_ascii_case("no") + && !trimmed.eq_ignore_ascii_case("off") + }) +} + +#[derive(Clone)] +pub(super) struct Selected { + pub identities: Vec, + loads: Vec, + stdout: String, + proofs: Vec, + _capture: Option>, +} + +impl Selected { + #[inline] + pub fn ordinary(identities: Vec) -> Self { + Self { + identities, + loads: Vec::new(), + stdout: String::new(), + proofs: Vec::new(), + _capture: None, + } + } + #[inline] + pub fn render(&self, output: &Output) { + if !self.stdout.is_empty() { + output.raw(&self.stdout); + } + for count in &self.loads { + output.status(&format!("loading {count} component(s)")); + } + } + pub fn verify(&self) -> io::Result<()> { + if self.proofs.is_empty() { + return Ok(()); + } + let mut engine = Engine::new()?; + let _: Value = engine.request(&json!({"kind":"readSetValidate","proofs":self.proofs}))?; + Ok(()) + } +} + +#[inline] +pub(super) fn stateful(pattern: &str) -> bool { + selection::stateful(pattern) +} + +pub(super) fn states(root: &Path, pattern: &str, startup: &mut Startup) -> io::Result { + let home = workspace_command::home(); + let mut engine = Engine::new()?; + let (context, environments) = + environments::read_context(&mut engine, root, &home, false, None, startup)?; + let targets = array(context.get("targets"), "reset selector targets")?; + let options = CaptureOptions { + environments: Some(environments.for_checkout()), + missing_status_invalid: true, + allow_special_states: true, + ..CaptureOptions::default() + }; + let capture = capture::capture_with(&mut engine, root, &home, &context, targets, options)?; + let mut proofs = source_proofs(root, &capture)?; + let result = + selection::select_capture(&mut engine, root, &context, targets, pattern, &capture)?; + if proofs != source_proofs(root, &capture)? { + return Err(decline("reset predicate sources changed")); + } + proofs.extend(capture.dependency_read_set()); + proofs.extend( + context["controlReadSet"] + .as_array() + .into_iter() + .flatten() + .cloned(), + ); + startup.stdout.push_str(&capture::callback_startup_stdout(&capture)); + let selected = Selected { + identities: target_identities(&result.0), + loads: result.1, + stdout: capture::callback_load_stdout(&capture), + proofs, + _capture: Some(Arc::new(capture)), + }; + selected.verify()?; + Ok(selected) +} + +#[inline] +fn target_identities(targets: &[Value]) -> Vec { + targets + .iter() + .filter_map(|target| { + target["identity"] + .as_str() + .or_else(|| target["id"].as_str()) + .map(str::trim) + .filter(|identity| !identity.is_empty()) + .map(str::to_owned) + }) + .collect() +} + +fn source_proofs(root: &Path, capture: &capture::Capture) -> io::Result> { + capture.captured + .iter() + .flat_map(|component| { + let directory = root.join( + component["directory"] + .as_str() + .or_else(|| component["rootDir"].as_str()) + .unwrap_or_default(), + ); + component["files"] + .as_array() + .into_iter() + .flatten() + .filter_map(|file| { + file.as_str() + .or_else(|| file["relativePath"].as_str()) + .or_else(|| file["path"].as_str()) + }) + .map(move |file| directory.join(file)) + }) + .map(|path| read_proof(&path)) + .collect() +} + +pub(super) fn lane_failure( + args: &[std::ffi::OsString], + root: &Path, + selected: Option<&Selected>, + error: &io::Error, +) -> io::Result { + if ci_active() { + return Ok(crate::native::Outcome::Declined(LANE_CI_DIAGNOSTIC.into())); + } + crate::commands::workspace_command::execute_then(root, args, "reset", |output| { + if let Some(selected) = selected { + selected.render(output); + selected.verify()?; + } + bit_object_import::reset_lane::apply_error_prefix(error)?; + // defaultErrorHandler colors a non-BitError message before its outer red wrapper. + Err(io::Error::new( + error.kind(), + crate::commands::color::Chalk::detect(args).red(&error.to_string()), + )) + }) +} diff --git a/native/cli/src/commands/resume_export.rs b/native/cli/src/commands/resume_export.rs new file mode 100644 index 000000000000..de20954506f4 --- /dev/null +++ b/native/cli/src/commands/resume_export.rs @@ -0,0 +1,100 @@ +//! Native `bit resume-export ` (`scopes/scope/export/resume-export.cmd.ts`). +//! +//! Coordinates [`COMMAND`], [`Options`], [`deletion`], [`deletion_report`], and [`execute`] to finish or clean up pending exports into [`Outcome`]. + +mod deletion; +mod deletion_report; +mod execute; +#[cfg(test)] +mod tests; + +use super::{Command, workspace_command}; +use crate::native::Outcome; +use std::{ffi::OsString, io}; + +/// Descriptor for the native `bit resume-export` command. +pub const COMMAND: Command = Command { name: "resume-export", aliases: &[], run }; + +/// Parsed CLI options for `bit resume-export `. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct Options { + pub export_id: String, + pub remotes: Vec, + pub delete: bool, + pub force: bool, +} + +/// Entry point for `bit resume-export`. +fn run(args: &[OsString]) -> io::Result { + let Some(argv) = workspace_command::utf8_args(args) else { + return Ok(Outcome::Declined("resume-export arguments are not UTF-8".into())); + }; + let Some(options) = parse_options(&argv[1..]) else { + return Ok(Outcome::Declined("outside native resume-export grammar".into())); + }; + execute::run(args, &options) +} + +pub(super) fn parse_options(args: &[&str]) -> Option { + let mut export_id = None; + let mut remotes = Vec::with_capacity(args.len()); + let mut delete = false; + let mut force = false; + let mut iter = args.iter().copied(); + while let Some(raw_arg) = iter.next() { + match raw_arg.trim() { + "--" => { + consume_trailing_resume_args(iter, &mut export_id, &mut remotes)?; + break; + } + "--no-color" | "--no-color=true" | "--no-color=false" | "--color" | "--color=true" + | "--color=false" | "--color=always" | "--color=never" => {} + "--delete" | "--delete=true" => delete = true, + "--delete=false" => delete = false, + "--force" | "--force=true" => force = true, + "--force=false" => force = false, + val if is_valid_resume_token(val) => { + push_resume_arg(val, &mut export_id, &mut remotes); + } + _ => return None, + } + } + (!remotes.is_empty()).then_some(())?; + Some(Options { export_id: export_id?, remotes, delete, force }) +} + +#[inline] +fn consume_trailing_resume_args<'a>( + iter: impl Iterator, + export_id: &mut Option, + remotes: &mut Vec, +) -> Option<()> { + for rest in iter { + let trimmed = rest.trim(); + if !is_valid_resume_token(trimmed) { + return None; + } + push_resume_arg(trimmed, export_id, remotes); + } + Some(()) +} + +#[inline] +fn is_valid_resume_token(token: &str) -> bool { + !token.is_empty() + && !token.starts_with('-') + && !token.contains(char::is_whitespace) + && !token.contains('\0') +} + +#[inline] +fn push_resume_arg(val: &str, export_id: &mut Option, remotes: &mut Vec) { + if export_id.is_none() { + *export_id = Some(val.to_owned()); + } else if !remotes + .iter() + .any(|existing| existing == val) + { + remotes.push(val.to_owned()); + } +} diff --git a/native/cli/src/commands/resume_export/deletion.rs b/native/cli/src/commands/resume_export/deletion.rs new file mode 100644 index 000000000000..6a35661a1d1c --- /dev/null +++ b/native/cli/src/commands/resume_export/deletion.rs @@ -0,0 +1,168 @@ +//! Probe pending exports before deleting, retaining per-scope failures and old-server answers. +//! +//! [`run`] uses [`Answers`], [`Plan`], [`install`], and [`deletion_report`] to delete pending export directories for [`Options`]. +use super::{Options, deletion_report}; +use crate::commands::{ + color::Chalk, install, remote_action::Plan, workspace_command::format::Formatter, +}; +use bit_install::output::Output; +use indexmap::IndexMap; +use serde_json::json; +use std::{ + env, + ffi::OsString, + io::{self, IsTerminal}, + sync::Arc, +}; +use tokio::task::JoinSet; + +/// Partitioned remote answers from probing or removing pending export directories. +#[derive(Default)] +pub(super) struct Answers { + pub present: Vec, + pub absent: Vec, + pub unknown: Vec, + pub failed: IndexMap, +} + +/// Probes and removes pending export directories across `plans` for `options.export_id`. +pub(super) async fn run( + plans: &[Arc], + names: &[String], + options: &Options, + args: &[OsString], + output: &Output, +) -> io::Result { + let formatter = Formatter::new(Chalk::detect(args)); + let probe = ask(plans, names, &options.export_id, false).await?; + if probe.failed.len() == names.len() { + return Err(io::Error::other(format!( + "failed reaching all the given scopes:\n{}", + deletion_report::failed_lines(&probe.failed).join("\n"), + ))); + } + let to_clear: Vec = probe.present + .iter() + .chain(&probe.unknown) + .cloned() + .collect(); + if to_clear.is_empty() { + return Ok(deletion_report::nothing(&formatter, &options.export_id, &probe)); + } + output.console(&deletion_report::probe(&formatter, &options.export_id, &probe, &to_clear)); + if !options.force { + confirm(&formatter, &options.export_id, to_clear.len())?; + } + let clear_plans: Vec<_> = to_clear + .iter() + .filter_map(|name| { + names + .iter() + .position(|candidate| candidate == name) + .map(|index| Arc::clone(&plans[index])) + }) + .collect(); + let result = ask(&clear_plans, &to_clear, &options.export_id, true).await?; + deletion_report::result(&formatter, &options.export_id, &probe, result) +} + +/// Prompts the interactive terminal for confirmation before deleting pending export objects. +fn confirm(formatter: &Formatter, export_id: &str, count: usize) -> io::Result<()> { + let export_id = export_id.trim(); + if !io::stdout().is_terminal() + || !io::stdin().is_terminal() + || ["CI", "CLAUDECODE", "CLAUDE_CODE", "CURSOR_AGENT"].iter().any(|key| is_truthy_env(key)) + { + return Err(io::Error::other( + "unable to prompt for confirmation in a non-interactive terminal.\nreview the list above and re-run with \"--force\" to delete", + )); + } + let question = format!( + "deleting the pending objects of \"{export_id}\" from {count} scope(s) cannot be undone.\n{}", + formatter.chalk.bold("Would you like to proceed? [yes(y)/no(n)]"), + ); + if !install::confirm(&question)? { + return Err(io::Error::other("aborted, nothing was deleted")); + } + Ok(()) +} + +#[inline] +fn is_truthy_env(key: &str) -> bool { + env::var_os(key) + .is_some_and(|value| { + let text = value.to_string_lossy(); + let trimmed = text + .trim() + .trim_matches(['"', '\'']) + .trim(); + !trimmed.is_empty() + && !trimmed.eq_ignore_ascii_case("0") + && !trimmed.eq_ignore_ascii_case("false") + && !trimmed.eq_ignore_ascii_case("no") + && !trimmed.eq_ignore_ascii_case("off") + }) +} + +async fn ask( + plans: &[Arc], + names: &[String], + export_id: &str, + delete: bool, +) -> io::Result { + let export_id = export_id.trim(); + let action = if delete { "RemovePendingDir" } else { "PendingDirStatus" }; + let mut answers = Answers::default(); + let limit = plans + .first() + .map_or(15, |plan| plan.concurrency.max(1)); + let mut pending = plans.iter().zip(names); + let mut tasks = JoinSet::new(); + loop { + while tasks.len() < limit { + let Some((plan, name)) = pending.next() else { break }; + let plan = Arc::clone(plan); + let name = name.clone(); + let payload = json!({"clientId":export_id}); + tasks.spawn(async move { (name, plan.action(action, &payload).await) }); + } + let Some(result) = tasks.join_next().await else { break }; + let (name, result) = result.map_err(io::Error::other)?; + answers.record(name, result, delete); + } + answers.order_by(names); + Ok(answers) +} + +impl Answers { + #[inline] + fn order_by(&mut self, names: &[String]) { + let pos = |target: &str| { + names + .iter() + .position(|name| name == target) + .unwrap_or(usize::MAX) + }; + self.present.sort_by_key(|name| pos(name)); + self.absent.sort_by_key(|name| pos(name)); + self.unknown.sort_by_key(|name| pos(name)); + self.failed.sort_by(|left, _, right, _| pos(left).cmp(&pos(right))); + } + + #[inline] + fn record(&mut self, name: String, result: io::Result, delete: bool) { + match result { + Ok(value) => match value[if delete { "existed" } else { "exists" }].as_bool() { + Some(true) => self.present.push(name), + Some(false) => self.absent.push(name), + None => self.unknown.push(name), + }, + Err(error) if !delete && bit_client_transfer::http::is_action_not_found(&error) => { + self.unknown.push(name); + } + Err(error) => { + self.failed.insert(name, error.to_string().trim().to_owned()); + } + } + } +} diff --git a/native/cli/src/commands/resume_export/deletion_report.rs b/native/cli/src/commands/resume_export/deletion_report.rs new file mode 100644 index 000000000000..3693f9a66d5a --- /dev/null +++ b/native/cli/src/commands/resume_export/deletion_report.rs @@ -0,0 +1,119 @@ +//! `ResumeExportCmd` pending-object probe, deletion, race and failure reports. +//! +//! Formats [`Answers`], [`failed_lines`], and [`failed_section`] summaries with [`Formatter`]. +use super::deletion::Answers; +use crate::commands::workspace_command::format::Formatter; +use indexmap::IndexMap; +use std::io; + +/// Formats `(remote_name, error_message)` entries from `failed` into `"name - message"` lines. +#[inline] +pub(super) fn failed_lines(failed: &IndexMap) -> Vec { + failed + .iter() + .map(|(name, message)| { + let trimmed_name = name.trim(); + let trimmed_msg = message.trim(); + if trimmed_msg.is_empty() { + trimmed_name.to_owned() + } else { + format!("{trimmed_name} - {trimmed_msg}") + } + }) + .collect() +} + +/// Formats the `"failed scopes"` section for scopes that could not be reached. +fn failed_section(formatter: &Formatter, failed: &IndexMap) -> String { + if failed.is_empty() { + return String::new(); + } + let error_symbol = formatter.error_symbol(); + let items: Vec<_> = failed_lines(failed) + .into_iter() + .map(|line| Formatter::item_with(&line, &error_symbol)) + .collect(); + formatter.section( + &format!("{error_symbol} failed scopes"), + "(these scopes could not be reached, nothing was deleted from them)", + &items, + ) +} + +#[inline] +fn warning(formatter: &Formatter, message: &str) -> String { + format!("{} {}", formatter.warn_symbol(), formatter.chalk.yellow(message)) +} + +#[inline] +pub(super) fn nothing(formatter: &Formatter, export_id: &str, probe: &Answers) -> String { + let export_id = export_id.trim(); + let message = format!(r#"no pending objects of "{export_id}" were found, nothing to delete"#); + Formatter::join(&[ + failed_section(formatter, &probe.failed), + if probe.failed.is_empty() { + formatter.success_summary(&message) + } else { + warning(formatter, &format!("{message} on the scopes that answered")) + }, + ]) +} + +pub(super) fn probe( + formatter: &Formatter, + export_id: &str, + probe: &Answers, + to_clear: &[String], +) -> String { + let export_id = export_id.trim(); + let warn_symbol = formatter.warn_symbol(); + Formatter::join(&[ + formatter.section("pending objects to delete", &format!(r#"(of the export-id "{export_id}")"#), &to_clear.iter().map(|name| formatter.item(name.trim())).collect::>()), + formatter.section(&format!("{warn_symbol} scopes that no longer hold these objects"), + "a scope drops the pending objects as soon as it persisted them, so this export may be\npartially persisted already. if it is, deleting the rest leaves these scopes with\ndependencies that were never exported. run without \"--delete\" to persist instead.", + &probe.absent.iter().map(|name| Formatter::item_with(name.trim(), &warn_symbol)).collect::>()), + if probe.unknown.is_empty() { String::new() } else { + warning(formatter, &format!("{} scope(s) run a server that can't report whether anything is pending, so the check above skipped them", probe.unknown.len())) + }, + failed_section(formatter, &probe.failed), + ]) +} + +pub(super) fn result( + formatter: &Formatter, + export_id: &str, + probe: &Answers, + result: Answers, +) -> io::Result { + let export_id = export_id.trim(); + let mut failed = probe.failed.clone(); + failed.extend(result.failed); + let cleared = result.present.len() + result.unknown.len(); + if cleared == 0 && result.absent.is_empty() { + return Err(io::Error::other(format!( + "failed removing the pending objects of \"{export_id}\" from all the given scopes:\n{}", + failed_lines(&failed).join("\n"), + ))); + } + let message = + format!(r#"deleted the pending objects of "{export_id}" from {cleared} scope(s)"#); + let summary = if !failed.is_empty() { + warning(formatter, &format!("{message}, {} failed", failed.len())) + } else if cleared == 0 { + formatter.success_summary("nothing was pending anymore, no objects were deleted") + } else { + formatter.success_summary(&message) + }; + let untouched = probe.absent.len() + result.absent.len(); + let changed: Vec<_> = probe.present + .iter() + .filter(|name| result.absent.contains(name)) + .map(|name| Formatter::item_with(name.trim(), &formatter.warn_symbol())) + .collect(); + Ok(Formatter::join(&[ + formatter.section(&format!("{} scopes that changed while this command ran", formatter.warn_symbol()), + "these scopes held the pending objects during the check but not during the deletion.\nsomething else persisted or removed them in between, so this export may now be\npartially persisted. verify it before relying on the result above.", &changed), + failed_section(formatter, &failed), summary, + if untouched == 0 { String::new() } else { formatter.hint(&format!("({untouched} scope(s) had nothing pending and were left untouched)")) }, + ])) +} diff --git a/native/cli/src/commands/resume_export/execute.rs b/native/cli/src/commands/resume_export/execute.rs new file mode 100644 index 000000000000..8778a7855fbf --- /dev/null +++ b/native/cli/src/commands/resume_export/execute.rs @@ -0,0 +1,167 @@ +//! Validate every configured remote before serial, retryable export persistence. +//! +//! [`run`] executes [`Plan`] remote actions for [`Options`] and formats the resulting [`Report`]. +use super::Options; +use crate::{ + commands::{ + color::Chalk, + remote_action::{self, Plan}, + workspace_command::results::Report, + }, + native::Outcome, +}; +use bit_install::output::Output; +use serde_json::{Value, json}; +use std::{env, ffi::OsString, io, sync::Arc}; +use tokio::task::JoinSet; + +/// Validates configured remote action plans and executes `bit resume-export` for `options`. +pub(super) fn run(args: &[OsString], options: &Options) -> io::Result { + let cwd = env::current_dir()?; + let mut plans = Vec::with_capacity(options.remotes.len()); + let mut names = Vec::with_capacity(options.remotes.len()); + for raw_name in &options.remotes { + let name = raw_name.trim(); + if name.is_empty() + || names + .iter() + .any(|existing| existing == name) + { + continue; + } + names.push(name.to_owned()); + match remote_action::prepare(&cwd, name, true, options.delete) { + Ok(Some(plan)) => plans.push(Arc::new(plan)), + Ok(None) => { + return Ok(Outcome::Declined( + "export recovery requires an unported scope or resolver".into(), + )); + } + Err(error) => { + return Ok(Outcome::Declined(format!("export recovery preflight: {error}"))); + } + } + } + let Some(first) = plans.first() else { + return Ok(Outcome::Declined("export recovery requires remotes".into())); + }; + first.execute(args, "resume-export", |output| report(args, options, &plans, &names, output)) +} + +/// Runs either the `--delete` cleanup flow or export persistence and formats the [`Report`]. +fn report( + args: &[OsString], + options: &Options, + plans: &[Arc], + names: &[String], + output: &Output, +) -> io::Result { + let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build()?; + if options.delete { + let data = runtime.block_on(super::deletion::run(plans, names, options, args, output))?; + return Ok(Report { data, code: 0, details: None }); + } + let ids = runtime.block_on(persist(plans, names, options, output))?; + let data = if ids.is_empty() { + Chalk::detect(args).yellow("no components were left to persist for this export-id") + } else { + format!("the following components were persisted successfully:\n{}", ids.join("\n")) + }; + Ok(Report { data, code: 0, details: None }) +} + +async fn persist( + plans: &[Arc], + names: &[String], + options: &Options, + output: &Output, +) -> io::Result> { + let export_id = options.export_id.trim(); + output.restart("verifying that objects can be merged on the remotes..."); + validate(plans, export_id).await?; + let mut ids = Vec::new(); + for (plan, name) in plans.iter().zip(names) { + output.restart(&format!(r#"persisting data on the remote "{name}"..."#)); + let value = persist_one(plan, name, export_id).await?; + append_ids(&mut ids, value)?; + } + Ok(ids) +} + +async fn validate(plans: &[Arc], client_id: &str) -> io::Result<()> { + let mut tasks = JoinSet::new(); + for plan in plans { + let plan = Arc::clone(plan); + let payload = json!({"clientId":client_id,"isResumingExport":true}); + tasks.spawn(async move { plan.action("ExportValidate", &payload).await }); + } + while let Some(result) = tasks.join_next().await { + result.map_err(io::Error::other)??; + } + Ok(()) +} + +async fn persist_one(plan: &Plan, name: &str, client_id: &str) -> io::Result { + let mut error = String::new(); + for _ in 0..3 { + match plan.action("ExportPersist", &json!({"clientId":client_id})).await { + Ok(value) => return Ok(value), + Err(last) => last + .to_string() + .trim() + .clone_into(&mut error), + } + } + let reason = if error.is_empty() { "remote export persist failed" } else { &error }; + Err(io::Error::other(format!( + "failed persisting to the following scopes: {name}\n\ + errors:\n\ + {name} - {reason}\n\n\ + the remote bit.dev will try to re-persist the data for the failed scopes.\n ", + ))) +} + +fn append_ids(ids: &mut Vec, value: Value) -> io::Result<()> { + match value { + Value::Null => {} + Value::Object(ref map) if map.is_empty() => {} + Value::Array(values) => { + for id in values.iter().filter_map(extract_export_id) { + push_unique_id(ids, id); + } + } + Value::String(_) | Value::Object(_) => { + if let Some(nested) = value + .get("exportedIds") + .or_else(|| value.get("ids")) + .cloned() + { + return append_ids(ids, nested); + } + let id = extract_export_id(&value) + .ok_or_else(|| io::Error::other("invalid remote export result"))?; + push_unique_id(ids, id); + } + _ => return Err(io::Error::other("invalid remote export result")), + } + Ok(()) +} + +#[inline] +fn push_unique_id(ids: &mut Vec, id: String) { + if !ids.contains(&id) { + ids.push(id); + } +} + +#[inline] +fn extract_export_id(value: &Value) -> Option { + value + .as_str() + .or_else(|| value.get("id").and_then(Value::as_str)) + .or_else(|| value.get("componentId").and_then(Value::as_str)) + .or_else(|| value.get("name").and_then(Value::as_str)) + .map(|text| text.trim().trim_matches('/')) + .filter(|text| !text.is_empty()) + .map(str::to_owned) +} diff --git a/native/cli/src/commands/resume_export/tests.rs b/native/cli/src/commands/resume_export/tests.rs new file mode 100644 index 000000000000..e055d0f05249 --- /dev/null +++ b/native/cli/src/commands/resume_export/tests.rs @@ -0,0 +1,37 @@ +//! Unit tests for `bit resume-export` [`Options`] CLI parsing ([`parse_options`]). +//! +//! Verifies export ID, remote scope lists, and `--delete` / `--force` flag combinations. + +use super::{Options, parse_options}; + +/// Verifies `bit resume-export` [`Options`] parsing via [`parse_options`]. +#[test] +fn parses_resume_export_options() { + assert_eq!(parse_options(&["id-1"]), None); + assert_eq!(parse_options(&["id-1", "fixture.scope", "-d"]), None); + assert_eq!(parse_options(&["id-1", "fixture.scope", "-f"]), None); + assert_eq!( + parse_options(&["id-1", "scope-a", "scope-b", "--delete", "--force"]), + Some(Options { + export_id: "id-1".into(), + remotes: vec!["scope-a".into(), "scope-b".into()], + delete: true, + force: true, + }), + ); + assert_eq!( + parse_options(&["id-1", "scope-a", "--delete=true", "--force=false"]), + Some(Options { + export_id: "id-1".into(), + remotes: vec!["scope-a".into()], + delete: true, + force: false, + }), + ); +} + +/// Verifies that [`parse_options`] returns `None` when given an empty argument slice. +#[test] +fn empty_resume_export_args_return_none() { + assert_eq!(parse_options(&[]), None); +} diff --git a/native/cli/src/commands/ripple.rs b/native/cli/src/commands/ripple.rs new file mode 100644 index 000000000000..2b4af5397c7b --- /dev/null +++ b/native/cli/src/commands/ripple.rs @@ -0,0 +1,403 @@ +//! Native `bit ripple ` (`scopes/cloud/ripple/ripple.cmd.ts`, [`COMMAND`], [`Subcommand`], [`BARE_RIPPLE_MSG`], [`NO_JOB_MSG`]) producing a [`Report`]. + +#[cfg(test)] +mod tests; + +use super::{ + Command, + color::Chalk, + workspace_command::{self, results::Report}, +}; +use crate::{native::Outcome, workspace}; +use bit_object_import::parse_jsonc; +use serde_json::{Value, json}; +use std::{ + env, + ffi::OsString, + fs, + io::{self, Write}, + path::Path, + process::ExitCode, +}; + +/// Descriptor for the native `bit ripple` command. +pub const COMMAND: Command = Command { name: "ripple", aliases: &[], run }; +/// Diagnostic emitted when `bit ripple` is invoked without a subcommand. +const BARE_RIPPLE_MSG: &str = + "[ripple] please specify a subcommand. See --help for available commands."; +/// Diagnostic emitted when a `bit ripple` job operation cannot resolve a Ripple CI job ID. +const NO_JOB_MSG: &str = "Could not find a Ripple CI job. Provide a job ID, use --lane, or run from a workspace with a recent export."; +/// Native-handled `bit ripple` subcommand variants. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) enum Subcommand { + Bare, + ListInvalidLimit { raw: String, json: bool }, + Simulate { lane: Option, json: bool }, + NoArgJobOp { sub: &'static str, json: bool }, +} + +fn run(args: &[OsString]) -> io::Result { + let Some(argv) = workspace_command::utf8_args(args) else { + return Ok(Outcome::Declined("ripple arguments are not UTF-8".into())); + }; + let Some(subcommand) = parse_subcommand(&argv[1..]) else { + return Ok(Outcome::Declined("ripple remote subcommand delegated".into())); + }; + let cwd = env::current_dir()?; + let root_opt = workspace::root(&cwd); + match subcommand { + Subcommand::Bare => run_bare(&cwd, root_opt.as_deref(), args), + Subcommand::ListInvalidLimit { raw, json } => { + emit_invalid_limit(&cwd, root_opt.as_deref(), args, &raw, json) + } + Subcommand::Simulate { lane, json } => { + run_simulate(&cwd, root_opt.as_deref(), args, lane.as_deref(), json) + } + Subcommand::NoArgJobOp { sub, json } => { + run_no_arg_job_op(&cwd, root_opt.as_deref(), args, sub, json) + } + } +} + +#[inline] +fn run_bare(cwd: &Path, root_opt: Option<&Path>, args: &[OsString]) -> io::Result { + let report = Report { data: BARE_RIPPLE_MSG.into(), code: 1, details: None }; + if let Some(root) = root_opt { + workspace_command::execute_then(root, args, "ripple", |_| Ok(report)) + } else { + workspace_command::execute_with_scope(cwd, args, "ripple", &report) + } +} + +#[inline] +fn ci_enabled() -> bool { + env::var_os("CI") + .is_some_and(|val| { + let text = val.to_string_lossy(); + let trimmed = text + .trim() + .trim_matches(['"', '\'']) + .trim(); + !matches!(trimmed.to_ascii_lowercase().as_str(), "" | "0" | "false" | "no" | "off") + }) +} + +fn emit_invalid_limit( + cwd: &Path, + root_opt: Option<&Path>, + args: &[OsString], + raw: &str, + json_flag: bool, +) -> io::Result { + if ci_enabled() { + return Ok(Outcome::Declined("CI ripple error delegated".into())); + } + let msg = format!(r#"invalid --limit value "{raw}": expected a positive integer"#); + emit_ripple_error(cwd, root_opt, args, "list", json_flag, msg) +} + +fn run_simulate( + cwd: &Path, + root_opt: Option<&Path>, + args: &[OsString], + lane_flag: Option<&str>, + json_flag: bool, +) -> io::Result { + if ci_enabled() { + return Ok(Outcome::Declined("CI ripple simulate delegated".into())); + } + let Some(err) = diagnose_simulate_error(root_opt, lane_flag) else { + return Ok(Outcome::Declined("ripple simulate remote call delegated".into())); + }; + emit_ripple_error(cwd, root_opt, args, "simulate", json_flag, err) +} + +fn diagnose_simulate_error(root_opt: Option<&Path>, lane_flag: Option<&str>) -> Option { + let (current_lane, is_exported) = read_workspace_lane_state(root_opt)?; + let Some(lane) = lane_flag else { + let Some(curr) = current_lane else { + return Some( + "a simulation requires a lane. switch to a lane or pass --lane " + .into(), + ); + }; + return (!is_exported).then(|| never_exported_err(&curr)); + }; + diagnose_simulate_lane_flag(lane, current_lane.as_deref(), is_exported) +} + +fn diagnose_simulate_lane_flag( + lane: &str, + current_lane: Option<&str>, + is_exported: bool, +) -> Option { + let lane = lane.trim(); + if lane == "main" { + return Some(default_lane_err(lane)); + } + let Some((_, name)) = lane + .split_once('/') + .filter(|(scope_str, name_str)| { + !scope_str.trim().is_empty() && !name_str.trim().is_empty() && !name_str.contains('/') + }) + else { + return Some(invalid_lane_err(lane)); + }; + if name.trim() == "main" { + return Some(default_lane_err(lane)); + } + (current_lane == Some(lane) && !is_exported).then(|| never_exported_err(lane)) +} + +#[inline] +fn never_exported_err(lane: &str) -> String { + format!( + r#"lane "{lane}" was never exported. a simulation runs against the lane on bit.cloud, run "bit export" first"#, + ) +} + +#[inline] +fn default_lane_err(lane: &str) -> String { + format!( + r#""{lane}" is the default lane. a simulation needs a lane whose changes are tested against their dependents on main"#, + ) +} + +#[inline] +fn invalid_lane_err(lane: &str) -> String { + format!( + r#"invalid --lane "{lane}", expected "scope/lane-name". unable to parse "{lane}" as a valid lane-id, expected "scope/lane-name" format"#, + ) +} + +fn read_workspace_lane_state(root_opt: Option<&Path>) -> Option<(Option, bool)> { + let Some(root) = root_opt else { return Some((None, false)) }; + let ws_val = parse_jsonc(&fs::read(root.join("workspace.jsonc")).ok()?).ok()?; + if super::build_cmd::is_bitmap_auto_sync(&ws_val) { + return None; + } + let bitmap = parse_jsonc(&fs::read(root.join(".bitmap")).ok()?).ok()?; + let lane_obj = &bitmap["_bit_lane"]; + if lane_obj.is_null() { + return Some((None, false)); + } + let Some((scope, name)) = super::snap_distance::bitmap_lane_scope_and_name(&bitmap) else { + return Some((None, false)); + }; + if name == "main" { + return Some((None, false)); + } + if scope.is_empty() { + return None; + } + let exported = lane_obj["exported"].as_bool().unwrap_or(false); + Some((Some(format!("{scope}/{name}")), exported)) +} + +fn run_no_arg_job_op( + cwd: &Path, + root_opt: Option<&Path>, + args: &[OsString], + sub: &'static str, + json_flag: bool, +) -> io::Result { + if let Some(root) = root_opt { + let Some((None, _)) = read_workspace_lane_state(Some(root)) else { + return Ok(Outcome::Declined("ripple job op on lane delegated".into())); + }; + if workspace::scope_path(root).join("last-export.json").exists() { + return Ok(Outcome::Declined("ripple job op with last-export delegated".into())); + } + } + if json_flag { + return emit_no_job_json(root_opt, args, sub); + } + let chalk = Chalk::detect(args); + let report = Report { data: chalk.red(NO_JOB_MSG), code: 0, details: None }; + if let Some(root) = root_opt { + workspace_command::execute_then(root, args, sub, |_| Ok(report)) + } else { + workspace_command::execute_with_scope(cwd, args, sub, &report) + } +} + +fn emit_no_job_json( + root_opt: Option<&Path>, + args: &[OsString], + sub: &'static str, +) -> io::Result { + let val: Value = match sub { + "log" => json!({ "job": null }), + "errors" => { + json!({ "error": "No job found", "job": null, "ciNodes": [], "containerLogs": {} }) + } + _ => json!({ "error": NO_JOB_MSG }), + }; + let data = serde_json::to_string_pretty(&val)?; + if let Some(root) = root_opt { + return workspace_command::execute_json(root, args, sub, |_| { + Ok(Report { data, code: 0, details: None }) + }); + } + let mut stdout = io::stdout().lock(); + stdout.write_all(data.as_bytes())?; + stdout.flush()?; + Ok(Outcome::Exit(ExitCode::SUCCESS)) +} + +fn emit_ripple_error( + cwd: &Path, + root_opt: Option<&Path>, + args: &[OsString], + command: &str, + json_flag: bool, + msg: String, +) -> io::Result { + let err = io::Error::other(msg); + if let Some(root) = root_opt { + if json_flag { + workspace_command::execute_json(root, args, command, |_| Err(err)) + } else { + workspace_command::execute_then(root, args, command, |_| Err(err)) + } + } else { + workspace_command::execute_failure_scope(cwd, args, command, json_flag, "", err) + } +} + +pub(super) fn parse_subcommand(args: &[&str]) -> Option { + let filtered = args + .iter() + .copied() + .filter(|arg| { + !matches!( + *arg, + "--no-color" + | "--no-color=true" + | "--no-color=false" + | "--color" + | "--color=true" + | "--color=false" + | "--color=always" + | "--color=never", + ) + }) + .collect::>(); + match filtered.as_slice() { + [] | ["--"] => Some(Subcommand::Bare), + ["list", rest @ ..] if !rest.contains(&"--help") && !rest.contains(&"-h") => { + parse_list_invalid_limit(rest) + } + ["simulate", rest @ ..] if !rest.contains(&"--help") && !rest.contains(&"-h") => { + parse_simulate(rest) + } + [sub @ ("log" | "errors" | "retry" | "stop"), rest @ ..] => parse_no_arg_job_op(sub, rest), + _ => None, + } +} + +fn parse_simulate(rest: &[&str]) -> Option { + let mut lane = None; + let mut json = false; + let mut iter = rest.iter().copied(); + while let Some(arg) = iter.next() { + match arg { + "--" => { + return iter + .next() + .is_none() + .then_some(Subcommand::Simulate { lane, json }); + } + "-j" | "--json" | "--json=true" => json = true, + "--json=false" => json = false, + "--lane" => { + lane = Some(valid_ripple_val(iter.next()?)?.to_owned()); + } + "--scopes" | "--owners" | "--exclude-scopes" => { + valid_ripple_val(iter.next()?)?; + } + _ => apply_simulate_inline(arg, &mut lane)?, + } + } + Some(Subcommand::Simulate { lane, json }) +} + +fn apply_simulate_inline(arg: &str, lane: &mut Option) -> Option<()> { + if let Some(val) = arg.strip_prefix("--lane=") { + *lane = Some(valid_ripple_val(val)?.to_owned()); + return Some(()); + } + let val = arg + .strip_prefix("--scopes=") + .or_else(|| arg.strip_prefix("--owners=")) + .or_else(|| arg.strip_prefix("--exclude-scopes="))?; + valid_ripple_val(val).map(|_| ()) +} + +#[inline] +fn valid_ripple_val(val: &str) -> Option<&str> { + let trimmed = val.trim(); + (!trimmed.is_empty() && !trimmed.starts_with('-')).then_some(trimmed) +} + +fn parse_no_arg_job_op(sub: &str, rest: &[&str]) -> Option { + let sub_static = match sub { + "log" => "log", + "errors" => "errors", + "retry" => "retry", + "stop" => "stop", + _ => return None, + }; + let mut json = false; + let mut iter = rest.iter().copied(); + while let Some(arg) = iter.next() { + match arg { + "--" => { + return iter + .next() + .is_none() + .then_some(Subcommand::NoArgJobOp { sub: sub_static, json }); + } + "-j" | "--json" | "--json=true" => json = true, + "--json=false" => json = false, + "--full-log" | "--full-log=true" | "--full-log=false" if sub_static == "errors" => {} + _ => return None, + } + } + Some(Subcommand::NoArgJobOp { sub: sub_static, json }) +} + +fn parse_list_invalid_limit(rest: &[&str]) -> Option { + let mut limit = None; + let mut json = false; + let mut idx = 0; + while idx < rest.len() { + match rest[idx] { + "-j" | "--json" | "--json=true" => json = true, + "--json=false" => json = false, + "-l" | "--limit" => { + idx += 1; + limit = Some(*rest.get(idx)?); + } + arg if let Some(val) = arg + .strip_prefix("--limit=") + .or_else(|| arg.strip_prefix("-l=")) => + { + limit = Some(val); + } + _ => {} + } + idx += 1; + } + let raw = limit?; + if parse_positive_int(raw).is_some() { + return None; + } + Some(Subcommand::ListInvalidLimit { raw: raw.to_owned(), json }) +} + +#[inline] +pub(super) fn parse_positive_int(raw: &str) -> Option { + let val = raw.trim().parse::().ok()?; + (val >= 1).then_some(val) +} diff --git a/native/cli/src/commands/ripple/tests.rs b/native/cli/src/commands/ripple/tests.rs new file mode 100644 index 000000000000..9819236bc692 --- /dev/null +++ b/native/cli/src/commands/ripple/tests.rs @@ -0,0 +1,40 @@ +//! Unit tests for `bit ripple` [`Subcommand`] CLI parsing ([`parse_subcommand`]) and positive integer limit validation ([`parse_positive_int`]). +//! +//! Verifies `list`, `simulate`, and `retry` subcommands and invalid `--limit` detection. + +use super::{Subcommand, parse_positive_int, parse_subcommand}; + +/// Verifies bare `bit ripple` [`Subcommand`] parsing via [`parse_subcommand`] and [`parse_positive_int`]. +#[test] +fn parses_bare_ripple_and_invalid_limit() { + assert_eq!(parse_subcommand(&[]), Some(Subcommand::Bare)); + assert_eq!( + parse_subcommand(&["list", "--limit", "0"]), + Some(Subcommand::ListInvalidLimit { raw: "0".into(), json: false }), + ); + assert_eq!( + parse_subcommand(&["list", "-l", "abc", "-j"]), + Some(Subcommand::ListInvalidLimit { raw: "abc".into(), json: true }), + ); + assert_eq!(parse_subcommand(&["list", "--limit", "10"]), None); + assert_eq!( + parse_subcommand(&["simulate", "--lane", "org.scope/dev", "--json"]), + Some(Subcommand::Simulate { lane: Some("org.scope/dev".into()), json: true }), + ); + assert_eq!( + parse_subcommand(&["retry", "-j"]), + Some(Subcommand::NoArgJobOp { sub: "retry", json: true }), + ); + assert_eq!(parse_positive_int("1"), Some(1)); + assert_eq!(parse_positive_int("0"), None); + assert_eq!(parse_positive_int("-5"), None); +} + +/// Verifies that [`parse_subcommand`] parses `simulate` without a `--lane` flag. +#[test] +fn parses_simulate_without_lane_flag() { + assert_eq!( + parse_subcommand(&["simulate"]), + Some(Subcommand::Simulate { lane: None, json: false }), + ); +} diff --git a/native/cli/src/commands/run_action.rs b/native/cli/src/commands/run_action.rs new file mode 100644 index 000000000000..654e5eb1faa8 --- /dev/null +++ b/native/cli/src/commands/run_action.rs @@ -0,0 +1,146 @@ +//! Native `bit run-action ` (`scopes/scope/actions/run-action.cmd.ts`). +//! +//! Resolves [`scope_root`] and executes [`Options`] remote actions ([`COMMAND`]) via [`remote_action`]. + +#[cfg(test)] +mod tests; + +use super::{ + Command, + color::Chalk, + lane::scope_root, + remote_action, + workspace_command::{self, results::Report}, +}; +use crate::native::Outcome; +use serde_json::Value; +use std::{env, ffi::OsString, io}; + +/// Descriptor for the native `bit run-action` command. +pub const COMMAND: Command = Command { name: "run-action", aliases: &[], run }; + +/// Parsed CLI options for `bit run-action [options-json]`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct Options { + pub action_name: String, + pub remote: String, + pub payload: Value, +} + +/// Entry point for `bit run-action`. +fn run(args: &[OsString]) -> io::Result { + let Some(argv) = workspace_command::utf8_args(args) else { + return Ok(Outcome::Declined("run-action arguments are not UTF-8".into())); + }; + let Some(options) = parse_options(&argv[1..]) else { + return Ok(Outcome::Declined("outside native run-action grammar".into())); + }; + let cwd = env::current_dir()?; + if scope_root::discover() + .ok() + .flatten() + .is_none() + { + return run_outside_scope(&cwd, args); + } + if !options.payload.is_object() { + return Ok(Outcome::Declined("run-action options must be a JSON object".into())); + } + let file_supported = remote_action::supports_file(&options.action_name); + let Ok(Some(plan)) = remote_action::prepare(&cwd, &options.remote, false, file_supported) + else { + return Ok(Outcome::Declined("run-action requires a configured remote".into())); + }; + plan.execute(args, "run-action", |_| { + let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build()?; + let result = runtime.block_on(plan.action(&options.action_name, &options.payload))?; + report(&result) + }) +} + +#[inline] +fn run_outside_scope(cwd: &std::path::Path, args: &[OsString]) -> io::Result { + if env::var_os("CI") + .is_some_and(|val| { + let text = val.to_string_lossy(); + let trimmed = text + .trim() + .trim_matches(['"', '\'']) + .trim(); + !trimmed.is_empty() + && !trimmed.eq_ignore_ascii_case("0") + && !trimmed.eq_ignore_ascii_case("false") + && !trimmed.eq_ignore_ascii_case("no") + && !trimmed.eq_ignore_ascii_case("off") + }) + { + return Ok(Outcome::Declined("CI run-action outside scope delegated".into())); + } + let msg = + format!("error: scope not found at {}", Chalk::detect(args).bold(&cwd.to_string_lossy())); + workspace_command::execute_failure_scope( + cwd, + args, + "run-action", + false, + "", + io::Error::other(msg), + ) +} + +#[inline] +pub(super) fn parse_options(args: &[&str]) -> Option { + let filtered = super::scope_set::collect_non_flag_tokens(args)?; + if filtered.len() != 3 { + return None; + } + let action_name = filtered[0].trim(); + let remote = filtered[1].trim(); + let raw_payload = filtered[2].trim(); + if action_name.is_empty() || remote.is_empty() || raw_payload.is_empty() { + return None; + } + let payload = serde_json::from_str::(raw_payload).ok()?; + Some(Options { action_name: action_name.to_owned(), remote: remote.to_owned(), payload }) +} + +fn report(value: &Value) -> io::Result { + match value { + Value::Null | Value::Bool(false) => { + Ok(Report { data: String::new(), code: 0, details: None }) + } + Value::Number(number) if number.as_f64() == Some(0.0) => { + Ok(Report { data: String::new(), code: 0, details: None }) + } + Value::String(text) => Ok(Report { data: text.clone(), code: 0, details: None }), + Value::Bool(true) | Value::Number(_) | Value::Array(_) => { + Ok(Report { data: "undefined".into(), code: 0, details: None }) + } + Value::Object(map) => { + let details = match map.get("details") { + None | Some(Value::Null | Value::Bool(false)) => None, + Some(Value::String(text)) => (!text.is_empty()).then(|| text.clone()), + Some(_) => return Err(io::Error::other("unsupported run-action report details")), + }; + let data = match map.get("data") { + None => "undefined".to_owned(), + Some(Value::Null) => "null".to_owned(), + Some(Value::Bool(boolean)) => boolean.to_string(), + Some(Value::Number(number)) => number.to_string(), + Some(Value::String(text)) => text.clone(), + Some(Value::Array(_) | Value::Object(_)) => { + return Err(io::Error::other("unsupported nested run-action report data")); + } + }; + let code = match map.get("code") { + None | Some(Value::Null) => 0, + Some(Value::Number(number)) => number + .as_i64() + .and_then(|code| i32::try_from(code).ok()) + .ok_or_else(|| io::Error::other("unsupported run-action exit code"))?, + Some(_) => return Err(io::Error::other("unsupported run-action exit code")), + }; + Ok(Report { data, code, details }) + } + } +} diff --git a/native/cli/src/commands/run_action/tests.rs b/native/cli/src/commands/run_action/tests.rs new file mode 100644 index 000000000000..329ede7d1e63 --- /dev/null +++ b/native/cli/src/commands/run_action/tests.rs @@ -0,0 +1,42 @@ +//! Unit tests for `bit run-action` [`Options`] JSON payload CLI parsing ([`parse_options`]) and command-runner result formatting ([`report`]). +//! +//! Verifies `ExportPersist` JSON payload parsing and falsy/truthy action result formatting. + +use super::{Options, parse_options, report}; +use serde_json::json; + +/// Verifies `bit run-action` [`Options`] JSON payload parsing via [`parse_options`]. +#[test] +fn parses_and_validates_run_action_json() { + assert_eq!(parse_options(&["ExportPersist", "my-scope", "not-json"]), None); + assert_eq!(parse_options(&["ExportPersist", "my-scope", "{}", "--unknown"]), None); + assert_eq!( + parse_options(&["ExportPersist", "my-scope", r#"{"exportId":"123"}"#, "--no-color"]), + Some(Options { + action_name: "ExportPersist".into(), + remote: "my-scope".into(), + payload: json!({ "exportId": "123" }), + }), + ); +} + +/// Verifies that [`report`] formats falsy, string, plain-object, and structured `{ data, code }` payloads. +#[test] +fn formats_action_results_like_command_runner() { + for falsy in [json!(null), json!(false), json!(0), json!("")] { + let rendered = report(&falsy).unwrap(); + assert_eq!((rendered.data.as_str(), rendered.code), ("", 0)); + } + let text = report(&json!("persisted")).unwrap(); + assert_eq!((text.data.as_str(), text.code), ("persisted", 0)); + let object = report(&json!({"exists": true})).unwrap(); + assert_eq!((object.data.as_str(), object.code), ("undefined", 0)); + let structured = report(&json!({"data": "custom", "code": 2})).unwrap(); + assert_eq!((structured.data.as_str(), structured.code), ("custom", 2)); +} + +#[test] +fn rejects_too_few_run_action_positional_arguments() { + assert_eq!(parse_options(&[]), None); + assert_eq!(parse_options(&["ExportPersist", "my-scope"]), None); +} diff --git a/native/cli/src/commands/run_cmd.rs b/native/cli/src/commands/run_cmd.rs new file mode 100644 index 000000000000..d9c13620e420 --- /dev/null +++ b/native/cli/src/commands/run_cmd.rs @@ -0,0 +1,252 @@ +//! Native `bit run [app-name]` (`scopes/harmony/application/run.cmd.tsx`). +//! +//! Validates [`Options`] workspace app state ([`COMMAND`], [`is_empty_app_workspace`], [`run_missing_app`]) before running an application. + +#[cfg(test)] +mod tests; + +use super::{ + Command, + app_cmd::{ + GLOBAL_HELP, is_empty_app_workspace, run_missing_app, standard_nonapp_component_count, + }, + color::Chalk, + workspace_command, +}; +use crate::{native::Outcome, workspace}; +use std::{ + env, + ffi::OsString, + io::{self, Write}, + process::ExitCode, +}; + +/// Descriptor for the native `bit run` command. +pub const COMMAND: Command = Command { name: "run", aliases: &[], run }; + +/// Parsed command-line options for `bit run [app-name]`. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub(super) struct Options { + pub app_name: Option, + pub dev: bool, + pub port: Option, + pub verbose: bool, + pub watch: bool, + pub no_browser: bool, + pub args: Option, +} + +/// Entry point for `bit run`. +fn run(args: &[OsString]) -> io::Result { + let Some(argv) = workspace_command::utf8_args(args) else { + return Ok(Outcome::Declined("run arguments are not UTF-8".into())); + }; + if is_help_form(&argv[1..]) { + let chalk = Chalk::detect(args); + io::stdout() + .lock() + .write_all(command_help("run", chalk).as_bytes())?; + return Ok(Outcome::Exit(ExitCode::SUCCESS)); + } + if let Some(unknown) = run_unsupported_help(&argv[1..]) { + return print_unsupported_help(unknown, Chalk::detect(args)); + } + if parse_options(&argv[1..]).is_none() { + return Ok(Outcome::Declined("outside native run grammar".into())); + } + let cwd = env::current_dir()?; + let Some(root) = workspace::root(&cwd) else { + return run_missing_app(args, 0); + }; + if is_empty_app_workspace(&root).unwrap_or(false) + && let Some(count) = standard_nonapp_component_count(&root) + { + return run_missing_app(args, count); + } + Ok(Outcome::Declined("run execution delegated".into())) +} + +#[inline] +fn print_unsupported_help(unknown: &str, chalk: Chalk) -> io::Result { + io::stdout() + .lock() + .write_all(unsupported_help("run", unknown, chalk).as_bytes())?; + Ok(Outcome::Exit(ExitCode::FAILURE)) +} + +#[inline] +pub(super) fn unsupported_help(command: &str, unknown: &str, chalk: Chalk) -> String { + let unknown = chalk.yellow(unknown); + format!("{}\n{unknown}\n", command_help(command, chalk)) +} + +pub(super) fn command_help(command: &str, chalk: Chalk) -> String { + let global = GLOBAL_HELP; + let description = chalk.bold("start an application component locally"); + let app_name = chalk.green(" app-name"); + let dev = help_option(chalk, "-d", "--dev"); + let port = help_option(chalk, "-p", "--port"); + let verbose = help_option(chalk, "-v", "--verbose"); + let watch = help_option(chalk, "-w", "--watch"); + let no_browser = help_option(chalk, "-n", "--no-browser"); + let args = help_option(chalk, "-a", "--args"); + let link = chalk.underline("https://bit.dev/reference/apps/apps-overview/"); + format!( + r#"bit {command} [app-name] + +{description} +runs application components in their own development server, separate from the "bit start" UI. +apps are components that create deployable applications (React apps, Node.js servers, etc.). +when no app name is specified, automatically detects and runs the app if only one exists in the workspace. +for more info, visit: {link} + +Arguments: +{app_name} the app's name is registered by the app (run 'bit app list' to list the names of the available apps) + +Options + {dev} start the application in dev mode. [boolean] + {port} port to run the app on [string] + {verbose} show verbose output for inspection and print stack trace [boolean] + {watch} watch and compile your components upon changes [boolean] + {no_browser} do not automatically open browser when ready [boolean] + {args} the arguments passing to the app. for example, --args="--a=1 --b". don't forget to use quotes to wrap the value to escape special characters. [string] + +{global} +"#, + ) +} + +#[inline] +pub(super) fn help_option(chalk: Chalk, short: &str, long: &str) -> String { + format!("{}, {}", chalk.green(short), chalk.green(long)) +} + +#[inline] +pub(super) fn is_help_form(args: &[&str]) -> bool { + match args { + ["--help" | "-h"] => true, + [name, "--help" | "-h"] | ["--help" | "-h", name] + if !name.trim().is_empty() && !name.starts_with('-') => + { + true + } + _ => false, + } +} + +#[inline] +fn run_unsupported_help(args: &[&str]) -> Option<&'static str> { + match args { + ["--no-color"] => Some("Unknown arguments: no-color, noColor"), + [name, "--no-color"] if !name.trim().is_empty() && !name.trim().starts_with('-') => { + Some("Unknown arguments: no-color, noColor") + } + ["--json"] => Some("Unknown argument: json"), + [name, "--json"] if !name.trim().is_empty() && !name.trim().starts_with('-') => { + Some("Unknown argument: json") + } + ["--ssr"] => Some("Unknown argument: ssr"), + [name, "--ssr"] if !name.trim().is_empty() && !name.trim().starts_with('-') => { + Some("Unknown argument: ssr") + } + _ => None, + } +} + +pub(super) fn parse_options(args: &[&str]) -> Option { + let mut options = Options::default(); + let mut idx = 0; + while idx < args.len() { + if args[idx] == "--" { + super::internalize::consume_trailing_internalize( + args[idx + 1..].iter().copied(), + &mut options.app_name, + )?; + break; + } + apply_run_token(args, &mut idx, &mut options)?; + idx += 1; + } + Some(options) +} + +fn apply_run_token(args: &[&str], idx: &mut usize, options: &mut Options) -> Option<()> { + let arg = args[*idx].trim(); + if arg == "--no-color" { + return None; + } + if apply_run_bool_or_short(arg, options) { + return Some(()); + } + match arg { + "-p" | "--port" => { + if let Some(&next) = args.get(*idx + 1) + && !next.trim().starts_with('-') + { + options.port = Some(next.trim().to_owned()); + *idx += 1; + } else { + options.port = Some(String::new()); + } + } + "-a" | "--args" => { + let next = *args.get(*idx + 1)?; + options.args = Some(next.to_owned()); + *idx += 1; + } + _ => apply_inline_or_app(arg, options)?, + } + Some(()) +} + +fn apply_run_bool_or_short(arg: &str, options: &mut Options) -> bool { + if arg.starts_with('-') + && !arg.starts_with("--") + && arg.len() > 2 + && arg[1..] + .bytes() + .all(|byte| matches!(byte, b'd' | b'v' | b'w' | b'n')) + { + for byte in arg[1..].bytes() { + match byte { + b'd' => options.dev = true, + b'v' => options.verbose = true, + b'w' => options.watch = true, + _ => options.no_browser = true, + } + } + return true; + } + match arg { + "-d" | "--dev" | "--dev=true" => options.dev = true, + "--dev=false" => options.dev = false, + "-v" | "--verbose" | "--verbose=true" => options.verbose = true, + "--verbose=false" => options.verbose = false, + "-w" | "--watch" | "--watch=true" => options.watch = true, + "--watch=false" => options.watch = false, + "-n" | "--no-browser" | "--no-browser=true" => options.no_browser = true, + "--no-browser=false" => options.no_browser = false, + _ => return false, + } + true +} + +#[inline] +fn apply_inline_or_app(arg: &str, options: &mut Options) -> Option<()> { + if let Some(val) = arg + .strip_prefix("--port=") + .or_else(|| arg.strip_prefix("-p=")) + { + options.port = Some(val.to_owned()); + } else if let Some(val) = arg + .strip_prefix("--args=") + .or_else(|| arg.strip_prefix("-a=")) + { + options.args = Some(val.to_owned()); + } else if !arg.starts_with('-') && !arg.is_empty() && options.app_name.is_none() { + options.app_name = Some(arg.to_owned()); + } else { + return None; + } + Some(()) +} diff --git a/native/cli/src/commands/run_cmd/tests.rs b/native/cli/src/commands/run_cmd/tests.rs new file mode 100644 index 000000000000..41ab6baaf6ba --- /dev/null +++ b/native/cli/src/commands/run_cmd/tests.rs @@ -0,0 +1,28 @@ +//! Unit tests for `bit run` [`Options`] CLI parsing ([`parse_options`]). +//! +//! Verifies `--dev`, `--port`, `--watch`, `--no-browser`, and `--args` flag parsing. + +use super::{Options, parse_options}; + +/// Verifies `bit run` [`Options`] flag parsing via [`parse_options`]. +#[test] +fn parses_run_options() { + assert_eq!( + parse_options(&["my-app", "-d", "-p", "4000", "-w", "-n", "--args", "--flag"]), + Some(Options { + app_name: Some("my-app".into()), + dev: true, + port: Some("4000".into()), + verbose: false, + watch: true, + no_browser: true, + args: Some("--flag".into()), + }), + ); +} + +/// Verifies that an empty argument slice parses to [`Options::default`]. +#[test] +fn empty_run_args_parse_to_default_options() { + assert_eq!(parse_options(&[]), Some(Options::default())); +} diff --git a/native/cli/src/commands/schema_cmd.rs b/native/cli/src/commands/schema_cmd.rs new file mode 100644 index 000000000000..e4877298c5c5 --- /dev/null +++ b/native/cli/src/commands/schema_cmd.rs @@ -0,0 +1,311 @@ +//! Native `bit schema ` (`scopes/semantics/schema/schema.cmd.ts`, alias `api`). +//! +//! Dispatches [`COMMAND`] workspace ([`resolve_workspace_schema`]) and bare-scope ([`resolve_scope_schema_json`]) schema queries. + +mod extractor; +#[cfg(test)] +mod tests; + +use self::extractor::{ + WorkspaceSchemaOutcome, diagnose_scope_schema_error, diagnose_workspace_diff_error, + resolve_scope_schema_json, resolve_workspace_schema, +}; +use super::{ + Command, + color::Chalk, + lane::scope_root, + workspace_command::{self, results::Report}, +}; +use crate::{native::Outcome, workspace}; +use std::{ + env, + ffi::OsString, + io::{self, Write}, + path::Path, + process::ExitCode, +}; + +/// Descriptor for the native `bit schema` (`api`) command. +pub const COMMAND: Command = Command { name: "schema", aliases: &["api"], run }; + +/// Error message emitted when `bit schema` is invoked outside a workspace or scope without `--remote`. +const HOST_NOT_FOUND_ERR: &str = "Host was not found"; + +/// Parsed command-line options for `bit schema `. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct Options { + pub pattern: String, + pub remote: bool, + pub json: bool, +} + +/// Parsed command-line options for `bit schema diff `. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct DiffOptions { + pub component: String, + pub base_version: String, + pub compare_version: String, + pub json: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) enum ParsedCommand { + Schema(Options), + Diff(DiffOptions), +} + +#[inline] +fn ci_active() -> bool { + env::var_os("CI") + .is_some_and(|val| { + let text = val.to_string_lossy(); + let trimmed = text + .trim() + .trim_matches(['"', '\'']) + .trim(); + !trimmed.is_empty() + && !["0", "false", "no", "off"] + .iter() + .any(|falsy| trimmed.eq_ignore_ascii_case(falsy)) + }) +} + +fn run(args: &[OsString]) -> io::Result { + let Some(argv) = workspace_command::utf8_args(args) else { + return Ok(Outcome::Declined("schema arguments are not UTF-8".into())); + }; + let Some(parsed) = parse_command(&argv[1..]) else { + return Ok(Outcome::Declined("outside native schema grammar".into())); + }; + if ci_active() { + return Ok(Outcome::Declined("CI schema delegated".into())); + } + let cwd = env::current_dir()?; + match parsed { + ParsedCommand::Diff(diff_opts) => run_diff(&cwd, args, &diff_opts), + ParsedCommand::Schema(options) => { + if is_missing_host(&cwd, &options) { + return workspace_command::execute_failure_scope( + &cwd, + args, + "schema", + options.json, + "", + io::Error::other(HOST_NOT_FOUND_ERR), + ); + } + if !options.remote + && let Some(outcome) = try_run_local(&cwd, args, &options)? + { + return Ok(outcome); + } + Ok(Outcome::Declined("schema extraction delegated".into())) + } + } +} + +fn run_diff(cwd: &Path, args: &[OsString], opts: &DiffOptions) -> io::Result { + if let Some(root) = workspace::root(cwd) { + if let Some(err) = + diagnose_workspace_diff_error(&root, &opts.component, Chalk::detect(args)) + { + return emit_workspace_error(&root, args, "diff", opts.json, err); + } + return Ok(Outcome::Declined("schema diff delegated".into())); + } + if is_outside_any_host(cwd) { + return workspace_command::execute_failure_scope( + cwd, + args, + "diff", + opts.json, + "", + io::Error::other(HOST_NOT_FOUND_ERR), + ); + } + if let Some(scope) = scope_root::discover().ok().flatten() + && scope.bare + && let Some(err) = diagnose_scope_schema_error(&scope.path, &opts.component) + { + return workspace_command::execute_failure_scope( + &scope.path, + args, + "diff", + opts.json, + "", + io::Error::other(err), + ); + } + Ok(Outcome::Declined("schema diff delegated".into())) +} + +fn try_run_local(cwd: &Path, args: &[OsString], options: &Options) -> io::Result> { + if let Some(root) = workspace::root(cwd) { + return match resolve_workspace_schema( + &root, + &options.pattern, + options.json, + Chalk::detect(args), + ) { + WorkspaceSchemaOutcome::Error(err) => { + emit_workspace_error(&root, args, "schema", options.json, err).map(Some) + } + WorkspaceSchemaOutcome::Schemas(schemas) => { + let data = serde_json::to_string_pretty(&schemas).map_err(io::Error::other)?; + workspace_command::execute_json(&root, args, "schema", |_| { + Ok(Report { data, code: 0, details: None }) + }) + .map(Some) + } + WorkspaceSchemaOutcome::Declined => Ok(None), + }; + } + if let Some(scope) = scope_root::discover().ok().flatten() + && scope.bare + { + return try_run_bare_scope(&scope.path, args, options); + } + Ok(None) +} + +fn try_run_bare_scope( + scope_dir: &Path, + args: &[OsString], + options: &Options, +) -> io::Result> { + if options.json + && let Some(schemas) = resolve_scope_schema_json(scope_dir, &options.pattern) + { + let data = serde_json::to_string_pretty(&schemas).map_err(io::Error::other)?; + let mut stdout = io::stdout().lock(); + stdout.write_all(data.as_bytes())?; + stdout.flush()?; + return Ok(Some(Outcome::Exit(ExitCode::SUCCESS))); + } + if let Some(err) = diagnose_scope_schema_error(scope_dir, &options.pattern) { + return workspace_command::execute_failure_scope( + scope_dir, + args, + "schema", + options.json, + "", + io::Error::other(err), + ) + .map(Some); + } + Ok(None) +} + +#[inline] +fn emit_workspace_error( + root: &Path, + args: &[OsString], + command: &str, + json: bool, + err: String, +) -> io::Result { + if json { + workspace_command::execute_json(root, args, command, |_| Err(io::Error::other(err))) + } else { + workspace_command::execute_then(root, args, command, |_| Err(io::Error::other(err))) + } +} + +#[inline] +pub(super) fn is_missing_host(cwd: &Path, options: &Options) -> bool { + !options.remote && is_outside_any_host(cwd) +} + +#[inline] +fn is_outside_any_host(cwd: &Path) -> bool { + workspace::root(cwd).is_none() + && !cwd + .ancestors() + .any(|dir| { + dir.join("scope.json").is_file() + || dir.join(".bit").is_dir() + || dir.join(".git/bit").is_dir() + }) +} + +#[inline] +pub(super) fn parse_command(args: &[&str]) -> Option { + if args + .iter() + .find(|arg| !arg.starts_with('-')) + == Some(&"diff") + { + return parse_diff_options(args).map(ParsedCommand::Diff); + } + parse_options(args).map(ParsedCommand::Schema) +} + +fn parse_diff_options(args: &[&str]) -> Option { + let mut positional = Vec::with_capacity(4); + let mut json = false; + let mut iter = args.iter().copied(); + while let Some(arg) = iter.next() { + match arg { + "--" => { + consume_trailing_schema_args(iter, &mut positional)?; + break; + } + "--no-color" | "--no-color=true" | "--no-color=false" | "--color" | "--color=true" + | "--color=false" | "--color=always" | "--color=never" => {} + "-j" | "--json" | "--json=true" => json = true, + "--json=false" => json = false, + val if !val.starts_with('-') && !val.trim().is_empty() => positional.push(val.trim()), + _ => return None, + } + } + let ["diff", component, base_version, compare_version] = positional.as_slice() else { + return None; + }; + Some(DiffOptions { + component: (*component).to_owned(), + base_version: (*base_version).to_owned(), + compare_version: (*compare_version).to_owned(), + json, + }) +} + +#[inline] +fn consume_trailing_schema_args<'a>( + iter: impl Iterator, + positional: &mut Vec<&'a str>, +) -> Option<()> { + for val in iter { + let trimmed = val.trim(); + (!trimmed.is_empty() && !trimmed.starts_with('-')).then_some(())?; + positional.push(trimmed); + } + Some(()) +} + +pub(super) fn parse_options(args: &[&str]) -> Option { + let mut positional = Vec::with_capacity(1); + let mut remote = false; + let mut json = false; + let mut iter = args.iter().copied(); + while let Some(arg) = iter.next() { + match arg { + "--" => { + consume_trailing_schema_args(iter, &mut positional)?; + break; + } + "--no-color" | "--no-color=true" | "--no-color=false" | "--color" | "--color=true" + | "--color=false" | "--color=always" | "--color=never" => {} + "-r" | "--remote" | "--remote=true" => remote = true, + "--remote=false" => remote = false, + "-j" | "--json" | "--json=true" => json = true, + "--json=false" => json = false, + "-rj" | "-jr" => (remote, json) = (true, true), + val if !val.starts_with('-') && !val.trim().is_empty() && val.trim() != "diff" => { + positional.push(val.trim()); + } + _ => return None, + } + } + let [pattern] = positional.as_slice() else { return None }; + (*pattern != "diff").then(|| Options { pattern: (*pattern).to_owned(), remote, json }) +} diff --git a/native/cli/src/commands/schema_cmd/extractor.rs b/native/cli/src/commands/schema_cmd/extractor.rs new file mode 100644 index 000000000000..75b92d0ba4da --- /dev/null +++ b/native/cli/src/commands/schema_cmd/extractor.rs @@ -0,0 +1,395 @@ +//! Stored schema lookup and workspace pattern matching for `bit schema`. +//! +//! [`resolve_workspace_schema`] resolves component entries ([`WorkspaceSchemaOutcome`], [`Bitmap`], [`BitmapEntry`]) via [`resolve_component_entry`] and [`patterns`] matching. + +use crate::{ + commands::{ + aspect::workspace_default_scope, + color::Chalk, + debug_cat::lookup::resolve_component_entry, + patterns, + scope_set::{ + Failure, + selection::{resolve_pattern_entries, valid_scope_str}, + }, + }, + workspace, +}; +use bit_install::workspace::{Bitmap, BitmapEntry}; +use serde_json::Value; +use std::{fs, path::Path}; +/// Outcome of resolving component schemas from a workspace for `bit schema`. +pub(super) enum WorkspaceSchemaOutcome { + Error(String), + Schemas(Vec), + Declined, +} +/// Resolves workspace component schemas matching `pattern` when `--json` is active. +pub(super) fn resolve_workspace_schema( + root: &Path, + pattern: &str, + json_flag: bool, + chalk: Chalk, +) -> WorkspaceSchemaOutcome { + let Some(bitmap) = load_bitmap(root) else { + return WorkspaceSchemaOutcome::Declined; + }; + let entries = match resolve_pattern_entries(root, &bitmap, pattern, chalk) { + Ok(entries) => entries, + Err(Failure::User(msg)) => return WorkspaceSchemaOutcome::Error(msg), + Err(Failure::Declined(_)) => return WorkspaceSchemaOutcome::Declined, + }; + if !json_flag { + return WorkspaceSchemaOutcome::Declined; + } + let scope_dir = workspace::scope_path(root); + let mut schemas = Vec::with_capacity(entries.len()); + for entry in entries { + let Some(schema) = load_unmodified_entry_schema(root, &scope_dir, entry) else { + return WorkspaceSchemaOutcome::Declined; + }; + schemas.push(schema); + } + WorkspaceSchemaOutcome::Schemas(schemas) +} + +pub(super) fn diagnose_workspace_diff_error( + root: &Path, + pattern: &str, + chalk: Chalk, +) -> Option { + let bitmap = load_bitmap(root)?; + match resolve_pattern_entries(root, &bitmap, pattern, chalk) { + Err(Failure::User(msg)) => Some(msg), + Ok(entries) if entries.len() > 1 => Some(format!( + r#"pattern "{pattern}" matches {} components. please specify a single component."#, + entries.len(), + )), + _ => None, + } +} + +pub(super) fn diagnose_scope_schema_error(scope_dir: &Path, pattern: &str) -> Option { + if pattern.contains(['$', ':']) || pattern.contains(" AND ") { + return None; + } + if pattern.contains(['*', ',']) { + let candidates = scope_pattern_candidates(scope_dir)?; + let refs = candidates + .iter() + .map(|(id, _)| id.as_str()) + .collect::>(); + let matched = patterns::matching(&refs, &patterns::split(pattern)).ok()?; + return matched + .is_empty() + .then(|| format!(r#"unable to find any component matching the "{pattern}" pattern"#)); + } + if pattern.contains('!') { + return None; + } + let (bare_id, explicit_ver) = pattern + .rsplit_once('@') + .map_or((pattern, None), |(id, ver)| (id, Some(ver))); + if bare_id.is_empty() || explicit_ver == Some("") || bare_id.starts_with('@') { + return None; + } + let (scope, name) = bare_id.split_once('/')?; + if !valid_scope_str(scope) || name.is_empty() || name.contains("//") { + return None; + } + resolve_component_entry(scope_dir, bare_id) + .is_none() + .then(|| format!(r#"unable to find "{pattern}" in the scope"#)) +} + +pub(super) fn resolve_scope_schema_json(scope_dir: &Path, pattern: &str) -> Option> { + if pattern.contains(['$', ':']) || pattern.contains(" AND ") { + return None; + } + if pattern.contains('*') || pattern.contains(',') { + return resolve_scope_pattern_schemas(scope_dir, pattern); + } + if pattern.contains('!') { + return None; + } + let (bare_id, explicit_ver) = pattern + .rsplit_once('@') + .map_or((pattern, None), |(id, ver)| (id, Some(ver))); + if bare_id.is_empty() || explicit_ver == Some("") { + return None; + } + let (_, _, comp_hash) = resolve_component_entry(scope_dir, bare_id)?; + Some(vec![read_component_schema(scope_dir, &comp_hash, explicit_ver.unwrap_or("latest"))?]) +} + +fn read_component_schema(scope_dir: &Path, comp_hash: &str, req_ver: &str) -> Option { + let (kind, comp_val) = bit_object_import::read_scope_object(scope_dir, comp_hash).ok()??; + if kind != "Component" { + return None; + } + let ver_hash = resolve_version_hash(&comp_val, req_ver)?; + let (ver_kind, ver_val) = bit_object_import::read_scope_object(scope_dir, &ver_hash).ok()??; + if ver_kind != "Version" { + return None; + } + extract_stored_schema(scope_dir, &ver_val) +} + +fn resolve_scope_pattern_schemas(scope_dir: &Path, pattern: &str) -> Option> { + let candidates = scope_pattern_candidates(scope_dir)?; + let refs = candidates + .iter() + .map(|(id, _)| id.as_str()) + .collect::>(); + let matched: std::collections::HashSet<&str> = + patterns::matching(&refs, &patterns::split(pattern)) + .ok()? + .into_iter() + .collect(); + if matched.is_empty() { + return None; + } + let mut schemas = Vec::with_capacity(matched.len()); + for (identity, comp_hash) in &candidates { + if matched.contains(identity.as_str()) { + schemas.push(read_component_schema(scope_dir, comp_hash, "latest")?); + } + } + Some(schemas) +} + +fn scope_pattern_candidates(scope_dir: &Path) -> Option> { + let index = bit_object_import::parse_jsonc(&fs::read(scope_dir.join("index.json")).ok()?).ok()?; + let rows = index["components"].as_array().or_else(|| index.as_array())?; + if rows.len() > 128 { + return None; + } + let mut candidates = Vec::with_capacity(rows.len()); + for row in rows + .iter() + .filter(|row| row["isSymlink"] != true) + { + let comp_hash = row["hash"].as_str()?.to_owned(); + let (kind, comp_val) = bit_object_import::read_scope_object(scope_dir, &comp_hash).ok()??; + if kind != "Component" { + return None; + } + if comp_val["head"].as_str().is_none() { + continue; + } + let identity = if let Some(id_str) = row["id"] + .as_str() + .map(|raw| raw.split('@').next().unwrap_or(raw)) + .filter(|id| id.contains('/')) + { + id_str.to_owned() + } else { + format!("{}/{}", row["id"]["scope"].as_str()?, row["id"]["name"].as_str()?) + }; + candidates.push((identity, comp_hash)); + } + Some(candidates) +} + +#[inline] +fn load_bitmap(root: &Path) -> Option { + let ws_bytes = fs::read(root.join("workspace.jsonc")).ok()?; + let ws_val = bit_object_import::parse_jsonc(&ws_bytes).ok()?; + let default_scope = workspace_default_scope(&ws_val).ok()?; + Bitmap::load(root, Some(&default_scope)).ok() +} + +fn load_unmodified_entry_schema( + root: &Path, + scope_dir: &Path, + entry: &BitmapEntry, +) -> Option { + let req_ver = entry.id.version + .as_deref() + .filter(|ver| !ver.is_empty())?; + let full_id = entry.id.to_string_without_version(); + let (_, _, comp_hash) = resolve_component_entry(scope_dir, &full_id) + .or_else(|| resolve_component_entry(scope_dir, &entry.id.name))?; + let (kind, comp_val) = bit_object_import::read_scope_object(scope_dir, &comp_hash).ok()??; + if kind != "Component" { + return None; + } + let ver_hash = resolve_version_hash(&comp_val, req_ver)?; + let (ver_kind, ver_val) = bit_object_import::read_scope_object(scope_dir, &ver_hash).ok()??; + if ver_kind != "Version" || is_entry_modified_on_disk(root, scope_dir, entry, &ver_val) { + return None; + } + extract_stored_schema(scope_dir, &ver_val) +} + +fn resolve_version_hash(comp_val: &Value, req_ver: &str) -> Option { + let req_ver = req_ver.trim(); + let non_empty = |val: Option<&str>| { + val.map(str::trim) + .filter(|hash| !hash.is_empty()) + .map(str::to_owned) + }; + if matches!(req_ver, "" | "latest" | "head") { + return non_empty(comp_val["head"].as_str()); + } + if let Some(hash) = non_empty(comp_val["versions"].get(req_ver).and_then(Value::as_str)) { + return Some(hash); + } + if let Some(hash) = non_empty(comp_val["orphanedVersions"].get(req_ver).and_then(Value::as_str)) + { + return Some(hash); + } + (req_ver.len() == 40 && req_ver.bytes().all(|byte| byte.is_ascii_hexdigit())).then(|| { + req_ver.to_owned() + }) +} + +pub(super) fn extract_stored_schema(scope_dir: &Path, ver_val: &Value) -> Option { + let exts = ver_val["extensions"].as_array()?; + if let Some(schema_ext) = exts + .iter() + .find(|ext| crate::commands::update_cmd::matches_aspect(ext, "teambit.semantics/schema")) + && let Some(schema) = extract_direct_schema_data(&schema_ext["data"]) + { + return Some(schema); + } + let builder_ext = exts + .iter() + .find(|ext| { + crate::commands::update_cmd::matches_aspect(ext, "teambit.pipelines/builder") + })?; + let file_hash = find_schema_artifact_hash(builder_ext)?; + let (_, body) = bit_object_import::read_raw_scope_object(scope_dir, &file_hash).ok()??; + bit_object_import::parse_jsonc(&body).ok() +} + +#[inline] +fn extract_direct_schema_data(data: &Value) -> Option { + if data["schema"].is_object() { + return Some(data["schema"].clone()); + } + if data.is_object() + && (data.get("__schema").is_some() + || data.get("module").is_some() + || data.get("exports").is_some()) + { + return Some(data.clone()); + } + None +} + +fn find_schema_artifact_hash(builder_ext: &Value) -> Option { + let items = builder_ext["data"]["artifacts"] + .as_array() + .or_else(|| builder_ext["artifacts"].as_array())?; + let fallbacks = builder_ext["artifacts"].as_array(); + let art = items.iter().find(|item| is_schema_artifact(item))?; + extract_first_file_hash(&art["files"]) + .or_else(|| { + let name = art["name"].as_str()?; + let matching = fallbacks? + .iter() + .find(|other| other["name"].as_str() == Some(name))?; + extract_first_file_hash(&matching["files"]) + }) +} + +#[inline] +fn is_schema_artifact(art: &Value) -> bool { + art["task"]["id"] + .as_str() + .is_some_and(|id| { + bit_install::config::aspect_id_without_version(id) == "teambit.semantics/schema" + }) + || art["generatedBy"] + .as_str() + .is_some_and(|id| { + bit_install::config::aspect_id_without_version(id) == "teambit.semantics/schema" + }) + || art["task"]["name"].as_str() == Some("ExtractSchema") + || art["name"].as_str() == Some("schema") +} + +#[inline] +fn extract_first_file_hash(files_val: &Value) -> Option { + let arr = files_val["refs"].as_array().or_else(|| files_val.as_array())?; + let first = arr.first()?; + first["ref"] + .as_str() + .or_else(|| first["ref"]["hash"].as_str()) + .or_else(|| first["file"].as_str()) + .map(str::trim) + .filter(|hash| !hash.is_empty()) + .map(str::to_owned) +} + +pub(super) fn is_entry_modified_on_disk( + root: &Path, + scope_dir: &Path, + entry: &BitmapEntry, + ver_val: &Value, +) -> bool { + let comp_dir = root.join(&entry.root_dir); + let Some(files) = ver_val["files"].as_array() else { + return true; + }; + if !comp_dir.is_dir() || count_comp_files(&comp_dir) != Some(files.len()) { + return true; + } + for item in files { + if !file_matches_version(&comp_dir, scope_dir, item) { + return true; + } + } + false +} + +fn file_matches_version(comp_dir: &Path, scope_dir: &Path, item: &Value) -> bool { + let Some(rel_path) = item["relativePath"] + .as_str() + .or_else(|| item["path"].as_str()) + .map(|path| { + path.trim() + .trim_start_matches("./") + .trim_start_matches(r".\") + }) + .filter(|path| !path.is_empty()) + else { + return false; + }; + let Some(hash) = item["file"] + .as_str() + .or_else(|| item["file"]["hash"].as_str()) + .map(str::trim) + .filter(|hash| !hash.is_empty()) + else { + return false; + }; + let Ok(disk_bytes) = fs::read(comp_dir.join(rel_path)) else { + return false; + }; + let Ok(Some((_, source_bytes))) = bit_object_import::read_raw_scope_object(scope_dir, hash) + else { + return false; + }; + disk_bytes == source_bytes +} + +fn count_comp_files(dir: &Path) -> Option { + let mut count = 0usize; + for entry_res in fs::read_dir(dir).ok()? { + let entry = entry_res.ok()?; + let name = entry.file_name(); + let name_str = name.to_str()?; + if matches!(name_str, "node_modules" | "dist" | "package.json" | "component.json") { + continue; + } + let path = entry.path(); + if path.is_dir() { + count += count_comp_files(&path)?; + } else if path.is_file() { + count += 1; + } + } + Some(count) +} diff --git a/native/cli/src/commands/schema_cmd/tests.rs b/native/cli/src/commands/schema_cmd/tests.rs new file mode 100644 index 000000000000..93dbae5b6dc9 --- /dev/null +++ b/native/cli/src/commands/schema_cmd/tests.rs @@ -0,0 +1,126 @@ +//! Unit tests for `bit schema` [`Options`] / [`ParsedCommand`] CLI parsing ([`parse_options`], [`parse_command`]), stored schema extraction ([`extract_stored_schema`]), and bare-scope pattern resolution ([`resolve_scope_schema_json`]). +//! +//! Verifies [`DiffOptions`], [`is_missing_host`], [`is_entry_modified_on_disk`], and [`Bitmap`] inspection. + +use super::{ + DiffOptions, Options, ParsedCommand, + extractor::{extract_stored_schema, is_entry_modified_on_disk, resolve_scope_schema_json}, + is_missing_host, parse_command, parse_options, +}; +use bit_install::workspace::Bitmap; +use flate2::{Compression, write::ZlibEncoder}; +use serde_json::json; +use std::{fs, io::Write, path::Path}; +use tempfile::tempdir; + +/// Verifies `bit schema` [`Options`] and [`ParsedCommand`] parsing via [`parse_options`], [`parse_command`], and [`is_missing_host`]. +#[test] +fn parses_schema_options_and_detects_missing_host() { + assert_eq!(parse_options(&[]), None); + let opts = parse_options(&["comp1", "-j"]).expect("parse options"); + assert_eq!(opts, Options { pattern: "comp1".into(), remote: false, json: true }); + assert_eq!( + parse_command(&["diff", "comp1", "0.0.1", "0.0.2", "-j"]), + Some(ParsedCommand::Diff(DiffOptions { + component: "comp1".into(), + base_version: "0.0.1".into(), + compare_version: "0.0.2".into(), + json: true, + })), + ); + + let temp = tempdir().expect("tempdir"); + assert!(is_missing_host(temp.path(), &opts)); + let remote_opts = parse_options(&["scope/comp1", "--remote"]).expect("parse remote"); + assert!(!is_missing_host(temp.path(), &remote_opts)); +} + +/// Verifies [`extract_stored_schema`] and [`is_entry_modified_on_disk`] on a stored `APISchema` extension. +#[test] +fn extracts_stored_schema() { + let temp = tempdir().expect("tempdir"); + fs::write( + temp.path().join(".bitmap"), + r#"{"bar/foo": {"scope": "my-scope", "version": "0.0.1", "mainFile": "index.ts", "rootDir": "bar/foo"}}"#, + ) + .expect("write bitmap"); + let bitmap = Bitmap::load(temp.path(), Some("my-scope")).expect("load bitmap"); + let ver_val = json!({ + "extensions": [{ + "name": "teambit.semantics/schema", + "data": { "schema": { "__schema": "APISchema", "exports": [] } }, + }], + "files": [], + }); + let schema = extract_stored_schema(temp.path(), &ver_val).expect("extract schema"); + assert_eq!(schema["__schema"], "APISchema"); + assert!(is_entry_modified_on_disk(temp.path(), temp.path(), &bitmap.entries[0], &ver_val)); +} + +#[test] +fn resolves_bare_scope_schemas_by_glob_comma_and_negation_patterns() { + let temp = tempdir().expect("tempdir"); + let scope = temp.path(); + let comp1_hash = "1111111111111111111111111111111111111111"; + let ver1_hash = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let comp2_hash = "2222222222222222222222222222222222222222"; + let ver2_hash = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + write_object( + scope, + "Component", + comp1_hash, + &json!({"scope":"my-scope","name":"comp1","head":ver1_hash,"versions":{"0.0.1":ver1_hash}}), + ); + write_object( + scope, + "Version", + ver1_hash, + &json!({"extensions":[{"name":"teambit.semantics/schema","data":{"schema":{"__schema":"APISchema","id":"comp1"}}}]}), + ); + write_object( + scope, + "Component", + comp2_hash, + &json!({"scope":"my-scope","name":"comp2","head":ver2_hash,"versions":{"0.0.1":ver2_hash}}), + ); + write_object( + scope, + "Version", + ver2_hash, + &json!({"extensions":[{"name":"teambit.semantics/schema","data":{"schema":{"__schema":"APISchema","id":"comp2"}}}]}), + ); + fs::write( + scope.join("index.json"), + serde_json::to_vec(&json!({ + "components": [ + {"id":{"scope":"my-scope","name":"comp1"},"hash":comp1_hash,"isSymlink":false}, + {"id":{"scope":"my-scope","name":"comp2"},"hash":comp2_hash,"isSymlink":false}, + ], + "lanes": [], + })) + .unwrap(), + ) + .unwrap(); + let all = resolve_scope_schema_json(scope, "my-scope/*").expect("glob match"); + assert_eq!(all.len(), 2); + let only_first = resolve_scope_schema_json(scope, "**, !my-scope/comp2").expect("negation"); + assert_eq!(only_first, vec![json!({"__schema":"APISchema","id":"comp1"})]); +} + +fn write_object(scope: &Path, kind: &str, hash: &str, body: &serde_json::Value) { + let raw = serde_json::to_vec(body).unwrap(); + let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default()); + write!(encoder, "{kind} {hash} {}\0", raw.len()).unwrap(); + encoder.write_all(&raw).unwrap(); + let path = scope + .join("objects") + .join(&hash[..2]) + .join(&hash[2..]); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, encoder.finish().unwrap()).unwrap(); +} + +#[test] +fn rejects_schema_diff_with_missing_version_arguments() { + assert_eq!(parse_command(&["diff", "comp1", "0.0.1"]), None); +} diff --git a/native/cli/src/commands/scope_rename.rs b/native/cli/src/commands/scope_rename.rs new file mode 100644 index 000000000000..fbdafd378d6c --- /dev/null +++ b/native/cli/src/commands/scope_rename.rs @@ -0,0 +1,202 @@ +//! Scope identity mutation keeps one native batch and one retained provider/tooling session. +//! +//! Coordinates [`COMMAND`], [`options`], [`Selection`], [`execution`], and [`report`] for `bit scope rename` and `bit scope fork`. +mod execution; +mod options; +mod report; +use super::{Command, add::ProjectedLinker, color::Chalk, workspace_command}; +use crate::{native::Outcome, workspace}; +use bit_object_import::component_scope_rename::Selection; +use std::{env, ffi::OsString, io}; +/// Descriptor for the native `bit scope` command family (`rename`, `rename-owner`, `fork`, `set`, `trust`). +pub const COMMAND: Command = Command { name: "scope", aliases: &[], run }; +/// Entry point for `bit scope`. +fn run(args: &[OsString]) -> io::Result { + let first = first_subcommand(args); + if first.as_deref() == Some("fork") { + return super::fork::run_scope(args); + } + if first.as_deref() == Some("set") { + let outcome = super::scope_set::run(args)?; + if !matches!(outcome, Outcome::Declined(_)) { + return Ok(outcome); + } + } + if first.as_deref() == Some("trust") { + let outcome = super::scope_trust::run(args)?; + if !matches!(outcome, Outcome::Declined(_)) { + return Ok(outcome); + } + } + let in_ws = env::current_dir() + .ok() + .is_some_and(|cwd| workspace::root(&cwd).is_some()); + if let Some(sub) = unrecognized_subcommand(args, in_ws) { + return handle_unrecognized_subcommand(args, &sub); + } + run_rename(args) +} + +/// Returns the first non-color argument token following `bit scope`. +#[inline] +fn first_subcommand(args: &[OsString]) -> Option { + args.iter() + .skip(1) + .filter_map(|arg| arg.to_str()) + .map(str::trim) + .find(|token| { + !token.is_empty() + && !matches!( + *token, + "--no-color" + | "--no-color=true" + | "--no-color=false" + | "--color" + | "--color=true" + | "--color=false" + | "--color=always" + | "--color=never", + ) + }) + .map(str::to_owned) +} + +fn handle_unrecognized_subcommand(args: &[OsString], sub: &str) -> io::Result { + if ci_active() { + return Ok(Outcome::Declined("CI scope unrecognized subcommand".into())); + } + let cwd = env::current_dir()?; + let chalk = Chalk::detect(args); + let data = chalk.red(&format!( + r#""{sub}" is not a subcommand of "scope", please run "bit scope --help" to list the subcommands"#, + )); + let report = workspace_command::results::Report { data, code: 0, details: None }; + if let Some(root) = workspace::root(&cwd) { + workspace_command::execute_then(&root, args, "scope", move |_output| Ok(report)) + } else { + workspace_command::execute_with_scope(&cwd, args, "scope", &report) + } +} + +pub(super) fn unrecognized_subcommand(args: &[OsString], in_ws: bool) -> Option { + let filtered = args + .iter() + .skip(1) + .map(|arg| arg.to_str().map(str::trim)) + .filter(|token| { + !token.is_some_and(|val| { + val.is_empty() + || matches!( + val, + "--no-color" + | "--no-color=true" + | "--no-color=false" + | "--color" + | "--color=true" + | "--color=false" + | "--color=always" + | "--color=never", + ) + }) + }) + .collect::>>()?; + match filtered.as_slice() { + [sub] => { + let is_known = matches!(*sub, "fork" | "rename" | "rename-owner") + || (in_ws && matches!(*sub, "set" | "trust")); + (!sub.starts_with('-') && !is_known).then(|| (*sub).to_owned()) + } + [sub @ ("set" | "trust"), rest @ ..] + if !in_ws && !rest.contains(&"--help") && !rest.contains(&"-h") => + { + Some((*sub).to_owned()) + } + _ => None, + } +} +fn run_rename(args: &[OsString]) -> io::Result { + let Some(argv) = workspace_command::utf8_args(args) else { + return Ok(Outcome::Declined("scope rename arguments are not UTF-8".into())); + }; + let Some(options) = options::parse(&argv[1..]) else { + return Ok(Outcome::Declined("outside native scope rename grammar".into())); + }; + let cwd = env::current_dir()?; + let Some(root) = workspace::root(&cwd) else { + return handle_outside_workspace(&cwd, args, options.owner); + }; + if let Err(error) = Selection::prepare(&root, &options) + && decline_preflight(&error) + { + return Ok(Outcome::Declined(format!("scope rename: {error}"))); + } + let mut linker = prepare_rename_linker(&root, args, options.owner || options.skip_install)?; + if options.owner { + workspace_command::execute_without_loader(&root, args, "rename-owner", |output| { + execution::run(&root, &options, &mut linker, Chalk::detect(args), output) + }) + } else { + workspace_command::execute_then(&root, args, "rename", |output| { + execution::run(&root, &options, &mut linker, Chalk::detect(args), output) + }) + } +} + +fn prepare_rename_linker( + root: &std::path::Path, + args: &[OsString], + skip_install: bool, +) -> io::Result { + let mut linker = if skip_install { + ProjectedLinker::prepare(root)? + } else { + ProjectedLinker::prepare_with_installation(root, args)? + }; + let stdout = linker.take_stdout(); + if !stdout.is_empty() { + print!("{stdout}"); + } + Ok(linker) +} + +#[inline] +pub(super) fn ci_active() -> bool { + env::var_os("CI") + .is_some_and(|val| { + let text = val.to_string_lossy(); + let trimmed = text + .trim() + .trim_matches(['"', '\'']) + .trim(); + !trimmed.is_empty() + && !["0", "false", "no", "off"] + .iter() + .any(|falsy| trimmed.eq_ignore_ascii_case(falsy)) + }) +} + +#[inline] +fn handle_outside_workspace( + cwd: &std::path::Path, + args: &[OsString], + owner: bool, +) -> io::Result { + if ci_active() { + return Ok(Outcome::Declined("scope rename requires a workspace".into())); + } + let err = io::Error::other(super::log::OUTSIDE_WORKSPACE_ERR); + if owner { + workspace_command::execute_without_loader_scope(cwd, args, "rename-owner", move |_| { + Err(err) + }) + } else { + workspace_command::execute_failure_scope(cwd, args, "rename", false, "", err) + } +} + +#[inline] +fn decline_preflight(error: &io::Error) -> bool { + error.kind() != io::ErrorKind::InvalidInput + || error.to_string().trim().is_empty() + || ci_active() +} diff --git a/native/cli/src/commands/scope_rename/execution.rs b/native/cli/src/commands/scope_rename/execution.rs new file mode 100644 index 000000000000..d32dc92c0daf --- /dev/null +++ b/native/cli/src/commands/scope_rename/execution.rs @@ -0,0 +1,131 @@ +//! Scope rename execution across [`Batch`] planning, AST refactoring, and [`ProjectedLinker`] updates. +//! +//! [`run`] coordinates [`phases`] and [`report`] to produce a [`Report`] for [`Options`]. +mod phases; +use super::report; +use crate::commands::{ + add::ProjectedLinker, + color::Chalk, + rename, + workspace_command::{self, format::Formatter, results::Report}, +}; +use bit_object_import::component_scope_rename::{Batch, Options, Selection}; +use serde_json::{Value, json}; +use std::{io, path::Path}; +/// Paired component rename facts and [`Batch`] plan for `bit scope rename`. +struct Entries { + facts: Vec, + batch: Batch, +} +/// Executes a scope rename [`Options`] batch and returns the formatted [`Report`]. +pub(super) fn run( + root: &Path, + options: &Options, + linker: &mut ProjectedLinker, + chalk: Chalk, + output: &bit_install::output::Output, +) -> io::Result { + let selection = Selection::prepare(root, options)?; + let file_id = workspace_command::file_id(); + selection.publish_default(&file_id)?; + if let Some(scope) = &selection.default_scope { + linker.call(root, "identityScopeDefault", &json!({"scope":scope}))?; + flush_stdout(linker); + } + let mut entries = prepare(root, &selection, linker)?; + phases::initial(root, &mut entries, linker, chalk, output, options)?; + entries.batch.write(&file_id, "rename")?; + entries.batch.publish_configuration(&file_id)?; + linker.forget_loaded(); + let mut changed = phases::targets(root, &mut entries, linker)?; + if options.refactor { + changed = phases::refactor(root, &mut entries, linker, output, &changed)?; + } + phases::materialize(root, &mut entries, linker)?; + entries.batch.finish(&file_id)?; + let ids = phases::ids(&entries.batch)?; + if !ids.is_empty() { + finish_linked_tooling(root, &entries.batch, &ids, options, linker, chalk)?; + } + output.stop(); + Ok(Report { + data: report::render(&entries.batch, options, &changed, &Formatter::new(chalk))?, + code: 0, + details: None, + }) +} +/// Prepares component rename plans and hydrates provider facts for every entry in `selection`. +fn prepare( + root: &Path, + selection: &Selection, + linker: &mut ProjectedLinker, +) -> io::Result { + let mut prepared = rename::preparation::prepare_many(root, &selection.entries)?; + let workspace = bit_object_import::parse_jsonc(&std::fs::read(root.join("workspace.jsonc"))?)?; + let tracking_scope = crate::commands::aspect::workspace_default_scope(&workspace) + .map_or(serde_json::Value::Null, serde_json::Value::String); + for (component, option) in prepared.iter_mut().zip(&selection.entries) { + component.facts["trackingDefaultScope"] = tracking_scope.clone(); + rename::execution::hydrate(root, component, option, linker)?; + } + let (plans, facts) = prepared + .into_iter() + .map(|component| (component.plan, component.facts)) + .unzip(); + Ok(Entries { batch: Batch::prepare(root, plans)?, facts }) +} + +#[inline] +fn flush_stdout(linker: &mut ProjectedLinker) { + let stdout = linker.take_stdout(); + if !stdout.is_empty() { + print!("{stdout}"); + } +} + +fn install(root: &Path, linker: &mut ProjectedLinker) -> io::Result<()> { + if let Err(error) = linker.install_scope_rename(root) { + let text = error.to_string(); + let trimmed = text.trim(); + let message = if trimmed.is_empty() { + "scope rename dependency installation failed" + } else { + trimmed + }; + let res = linker.call( + root, + "renameTooling", + &json!({"action":"installFailure","message":message}), + ); + flush_stdout(linker); + res?; + } else { + flush_stdout(linker); + } + Ok(()) +} + +#[inline] +fn finish_linked_tooling( + root: &Path, + batch: &Batch, + ids: &[String], + options: &Options, + linker: &mut ProjectedLinker, + chalk: Chalk, +) -> io::Result<()> { + let linked = linker.link_retained(root, batch.bitmap(), ids, false, chalk); + flush_stdout(linker); + linked?; + if !options.owner && !options.skip_install { + install(root, linker)?; + } + let compiled = linker.call( + root, + "renameTooling", + &json!({"action":"compile","ids":ids,"progress":!options.owner}), + ); + flush_stdout(linker); + compiled?; + Ok(()) +} diff --git a/native/cli/src/commands/scope_rename/execution/phases.rs b/native/cli/src/commands/scope_rename/execution/phases.rs new file mode 100644 index 000000000000..0e30d5348986 --- /dev/null +++ b/native/cli/src/commands/scope_rename/execution/phases.rs @@ -0,0 +1,266 @@ +//! Initial and final linking, refactoring, and publication phases for `bit scope rename`. +//! +//! [`initial`] and follow-up helpers coordinate [`Entries`], [`ProjectedLinker`], [`Chalk`], and [`Batch`] commits. +use super::{Batch, Chalk, Entries, ProjectedLinker, Value, json, workspace_command}; +use std::{io, path::Path}; +/// Stages each component in `entries`, applying file moves and `onAdd` / `onRename` tooling callbacks. +pub(super) fn initial( + root: &Path, + entries: &mut Entries, + linker: &mut ProjectedLinker, + chalk: Chalk, + output: &bit_install::output::Output, + options: &bit_object_import::component_scope_rename::Options, +) -> io::Result<()> { + let mut reloaded = false; + for index in 0..entries.batch.len() { + if reloaded { + let source = entries.batch.plan(index)?.identity.source.clone(); + let rows = linker.capture(root, entries.batch.bitmap(), std::slice::from_ref(&source))?; + print!("{}", linker.take_stdout()); + refresh(root, entries, index, &rows, linker)?; + } + entries.batch.stage(index)?; + if entries.batch.plan(index)?.identity.exported { + entries.batch.plan(index)?.apply_files()?; + let target = requested(entries, index, options.owner)?; + let added = entries.batch.added_identity(index)?; + output.status("loading 1 component(s)"); + linker.link(root, entries.batch.bitmap(), std::slice::from_ref(&added), false, chalk)?; + print!("{}", linker.take_stdout()); + entries.batch.write(&workspace_command::file_id(), &format!("adding {target}"))?; + let components = load_added(root, entries.batch.bitmap(), &added, &target, linker)?; + linker.call( + root, + "renameTooling", + &json!({"action":"onAdd","ids":[target],"components":components,"progress":!options.owner,"defaultScoped":!options.owner}), + )?; + print!("{}", linker.take_stdout()); + if !options.deprecate { + output.status("loading 1 component(s)"); + } + reloaded = true; + } + let package = entries.facts[index]["sourcePackage"] + .as_str() + .ok_or_else(|| io::Error::other("scope rename source package"))?; + entries.batch.retire(index, &workspace_command::file_id(), package)?; + } + Ok(()) +} +/// Captures component rows from `linker` when `added` matches `requested`. +#[inline] +fn load_added( + root: &Path, + bitmap: &Value, + added: &String, + requested: &str, + linker: &mut ProjectedLinker, +) -> io::Result>> { + if added != requested { + return Ok(None); + } + let rows = linker.capture(root, bitmap, std::slice::from_ref(added))?; + print!("{}", linker.take_stdout()); + Ok(Some(rows)) +} +pub(super) fn pairs(entries: &Entries) -> io::Result> { + entries.facts + .iter() + .map(|facts| { + let source = facts["sourcePackage"] + .as_str() + .map(str::trim) + .filter(|pkg| !pkg.is_empty()) + .ok_or_else(|| io::Error::other("scope rename source package"))?; + let target = facts["targetPackage"] + .as_str() + .map(str::trim) + .filter(|pkg| !pkg.is_empty()) + .ok_or_else(|| io::Error::other("scope rename target package"))?; + Ok((source.into(), target.into())) + }) + .collect() +} +pub(super) fn targets( + root: &Path, + entries: &mut Entries, + linker: &mut ProjectedLinker, +) -> io::Result> { + let pairs = pairs(entries)?; + let mut changed = Vec::with_capacity(entries.batch.len()); + for index in 0..entries.batch.len() { + let target = entries.batch.plan(index)?.identity.target.clone(); + let rows = + linker.capture_cached(root, entries.batch.bitmap(), std::slice::from_ref(&target))?; + print!("{}", linker.take_stdout()); + if !entries.batch.plan(index)?.identity.exported + && let Some(row) = rows + .iter() + .find(|row| row["identity"] == target) + && entries.batch.target_load(index, row, &pairs)? + { + changed.push(target); + } + } + Ok(changed) +} +pub(super) fn refactor( + root: &Path, + entries: &mut Entries, + linker: &mut ProjectedLinker, + output: &bit_install::output::Output, + targets: &[String], +) -> io::Result> { + let bitmap = published_bitmap(root)?; + let all = bitmap_ids(&bitmap)?; + let raw_order = linker.call(root, "identityListOrder", &json!({"ids":all})); + print!("{}", linker.take_stdout()); + let order: Vec = serde_json::from_value(raw_order?)?; + output.status(&format!("loading {} component(s)", all.len())); + let rows = linker.capture_cached(root, entries.batch.bitmap(), &all)?; + print!("{}", linker.take_stdout()); + let mut changed = entries.batch.refactor_loaded(&rows, &pairs(entries)?)?; + changed.extend_from_slice(targets); + let changed = order_changed(&bitmap, &order, &changed)?; + entries.batch.apply_refactored()?; + entries.batch.write_refactored_targets(targets)?; + linker.call(root, "identityMaterialized", &entries.batch.refactored_materialized())?; + print!("{}", linker.take_stdout()); + Ok(changed) +} +pub(super) fn materialize( + root: &Path, + entries: &mut Entries, + linker: &mut ProjectedLinker, +) -> io::Result<()> { + for index in 0..entries.batch.len() { + if entries.batch.plan(index)?.identity.exported { + continue; + } + entries.batch.materialize(index, &workspace_command::file_id())?; + linker.call(root, "identityMaterialized", &entries.batch.plan(index)?.materialized())?; + let stdout = linker.take_stdout(); + if !stdout.is_empty() { + print!("{stdout}"); + } + } + Ok(()) +} +#[inline] +pub(super) fn ids(batch: &Batch) -> io::Result> { + (0..batch.len()) + .map(|index| Ok(batch.plan(index)?.identity.target.clone())) + .collect() +} +#[inline] +fn bitmap_ids(bitmap: &Value) -> io::Result> { + Ok(bitmap + .as_object() + .ok_or_else(|| io::Error::other("scope rename bitmap"))? + .iter() + .filter_map(|(key, row)| row_id(key, row)) + .collect()) +} +#[inline] +fn row_id(key: &str, row: &Value) -> Option { + if key.starts_with('$') || key == "_bit_lane" || key == "version" || !row.is_object() { + return None; + } + let removed = + crate::commands::update_cmd::aspect_entry(&row["config"], "teambit.component/remove") + .is_some_and(|(_, val)| val["removed"] == true); + if removed || row["isAvailableOnCurrentLane"] == false { + return None; + } + row["rootDir"].as_str()?; + let (scope, name) = crate::commands::update_cmd::bitmap_row_scope_and_name(key, row); + if scope.is_empty() || name.is_empty() { + return None; + } + Some(format!("{scope}/{name}")) +} +fn order_changed(bitmap: &Value, order: &[String], changed: &[String]) -> io::Result> { + if changed.is_empty() { + return Ok(Vec::new()); + } + let rows = bitmap.as_object().ok_or_else(|| io::Error::other("scope rename bitmap"))?; + Ok(order + .iter() + .filter_map(|id| { + if !changed.contains(id) { + return None; + } + let (key, row) = rows + .iter() + .find(|(key, row)| row_id(key, row).as_ref() == Some(id))?; + Some(row_version_or_id(id, key, row)) + }) + .collect()) +} +#[inline] +fn row_version_or_id(id: &str, key: &str, row: &Value) -> String { + row["version"] + .as_str() + .map(str::trim) + .filter(|version| !version.is_empty()) + .or_else(|| { + key.rsplit_once('@') + .map(|(_, ver)| ver.trim()) + .filter(|ver| !ver.is_empty()) + }) + .map_or_else(|| id.to_owned(), |version| format!("{id}@{version}")) +} + +fn requested(entries: &Entries, index: usize, owner: bool) -> io::Result { + let identity = &entries.batch.plan(index)?.identity; + Ok(if owner { + identity.source_version + .as_deref() + .map(str::trim) + .filter(|version| !version.is_empty()) + .map_or_else( + || identity.target.clone(), + |version| format!("{}@{version}", identity.target), + ) + } else { + identity.target.clone() + }) +} + +fn refresh( + root: &Path, + entries: &mut Entries, + index: usize, + rows: &[Value], + linker: &mut ProjectedLinker, +) -> io::Result<()> { + let id = &entries.batch.plan(index)?.identity.source; + let row = rows + .iter() + .find(|row| row["identity"] == *id) + .ok_or_else(|| io::Error::other("scope rename reloaded source"))?; + entries.facts[index]["sourceExtensions"] = row["current"]["extensions"].clone(); + entries.facts[index]["sourceVersion"] = row["version"].clone(); + if entries.batch.plan(index)?.identity.exported { + entries.facts[index]["sourceConfigs"] = + linker.call(root, "renameSourceConfig", &json!({"component":row}))?; + print!("{}", linker.take_stdout()); + } + let component = entries.facts[index]["components"] + .as_array_mut() + .and_then(|components| { + components + .iter_mut() + .find(|component| component["identity"] == *id) + }) + .ok_or_else(|| io::Error::other("scope rename source inventory"))?; + component["sourceOverlayRoot"] = row["sourceOverlayRoot"].clone(); + component["sourceOverlays"] = row["sourceOverlays"].clone(); + entries.batch.refresh(index, &entries.facts[index]) +} + +#[inline] +fn published_bitmap(root: &Path) -> io::Result { + bit_object_import::parse_jsonc(&std::fs::read(root.join(".bitmap"))?) +} diff --git a/native/cli/src/commands/scope_rename/options.rs b/native/cli/src/commands/scope_rename/options.rs new file mode 100644 index 000000000000..69a5511d0e2d --- /dev/null +++ b/native/cli/src/commands/scope_rename/options.rs @@ -0,0 +1,107 @@ +//! Command-line flag and positional argument parser for `bit scope rename`. +//! +//! [`parse`] validates `bit scope rename ` and `rename-owner` flags into [`Options`]. +use bit_object_import::component_scope_rename::Options; +/// Parses `bit scope ` arguments into [`Options`]. +pub(super) fn parse(args: &[&str]) -> Option { + let filtered: Vec<&str> = args + .iter() + .map(|arg| arg.trim()) + .filter(|arg| { + !matches!( + *arg, + "--no-color" + | "--no-color=true" + | "--no-color=false" + | "--color" + | "--color=true" + | "--color=false" + | "--color=always" + | "--color=never", + ) + }) + .collect(); + let (kind, args) = filtered.split_first()?; + let owner = match *kind { + "rename" => false, + "rename-owner" => true, + _ => return None, + }; + let mut options = Options { owner, ..Default::default() }; + let mut names = Vec::with_capacity(2); + let mut iter = args.iter().copied(); + while let Some(arg) = iter.next() { + if arg == "--" { + consume_trailing_names(iter, &mut names)?; + break; + } + apply_arg(arg, owner, &mut options, &mut names)?; + } + let [old, new] = names.as_slice() else { return None }; + options.old = (*old).into(); + options.new = (*new).into(); + Some(options) +} + +/// Returns `true` when `token` is a non-empty, non-flag scope or owner identifier without whitespace or NUL bytes. +#[inline] +fn valid_name_token(token: &str) -> bool { + !token.is_empty() + && !token.starts_with('-') + && !token.contains(char::is_whitespace) + && !token.contains('\0') +} + +#[inline] +fn consume_trailing_names<'a>( + iter: impl Iterator, + names: &mut Vec<&'a str>, +) -> Option<()> { + for rest in iter { + valid_name_token(rest).then_some(())?; + names.push(rest); + } + Some(()) +} + +#[inline] +fn apply_arg<'a>( + arg: &'a str, + owner: bool, + options: &mut Options, + names: &mut Vec<&'a str>, +) -> Option<()> { + if !arg.starts_with('-') && !arg.is_empty() { + valid_name_token(arg).then_some(())?; + names.push(arg); + return Some(()); + } + match arg { + "-r" | "--refactor" | "--refactor=true" => options.refactor = true, + "--refactor=false" => options.refactor = false, + "--ast" | "--ast=true" | "--ast=false" if owner => {} // Published report deliberately does not forward this advertised flag. + other if !owner => apply_scope_rename_flag(other, options)?, + _ => return None, + } + Some(()) +} + +#[inline] +fn apply_scope_rename_flag(arg: &str, options: &mut Options) -> Option<()> { + match arg { + "-p" | "--preserve" | "--preserve=true" => options.preserve = true, + "--preserve=false" => options.preserve = false, + "-d" | "--deprecate" | "--deprecate=true" => options.deprecate = true, + "--deprecate=false" => options.deprecate = false, + "-x" | "--skip-dependency-installation" | "--skip-dependency-installation=true" => { + options.skip_install = true; + } + "--skip-dependency-installation=false" => options.skip_install = false, + "-rx" | "-xr" => { + options.refactor = true; + options.skip_install = true; + } + _ => return None, + } + Some(()) +} diff --git a/native/cli/src/commands/scope_rename/report.rs b/native/cli/src/commands/scope_rename/report.rs new file mode 100644 index 000000000000..0ed5f28b0967 --- /dev/null +++ b/native/cli/src/commands/scope_rename/report.rs @@ -0,0 +1,63 @@ +//! Human-readable summary report formatting for `bit scope rename`. +//! +//! [`render`] formats renamed [`Batch`] entries and refactored component IDs for [`Options`] with [`Formatter`]. +use crate::commands::workspace_command::format::Formatter; +use bit_object_import::component_scope_rename::{Batch, Options}; +use std::io; +/// Formats the human-readable summary and affected/refactored component sections for `bit scope rename`. +pub(super) fn render( + batch: &Batch, + options: &Options, + refactored: &[String], + format: &Formatter, +) -> io::Result { + let kind = if options.owner { "owner" } else { "scope" }; + let summary = format.success_summary(&format!( + r#"replaced "{}" {kind} with "{}""#, + options.old.trim(), + options.new.trim(), + )); + let mut affected = Vec::with_capacity(batch.len()); + for index in 0..batch.len() { + let plan = batch.plan(index)?; + let source = plan.identity.source + .trim() + .rsplit_once('@') + .filter(|(base, _)| !base.trim().is_empty()) + .map_or_else(|| plan.identity.source.trim(), |(base, _)| base.trim()); + affected.push(format.item(&format!( + "{source} {}-> {}", + if plan.identity.exported { "(deprecated) " } else { "" }, + plan.identity.target.trim(), + ))); + } + let mut seen = Vec::with_capacity(refactored.len()); + for id in refactored + .iter() + .map(|id| id.trim().trim_matches('/')) + .filter(|id| !id.is_empty()) + { + if !seen.contains(&id) { + seen.push(id); + } + } + let changed = seen + .into_iter() + .map(|id| format.item(id)) + .collect::>(); + Ok(Formatter::join(&[ + summary, + section("affected components", &affected, format), + section("refactored components", &changed, format), + ])) +} +/// Formats a titled report section when `rows` is non-empty. +#[inline] +fn section(name: &str, rows: &[String], format: &Formatter) -> String { + let name = name.trim(); + if rows.is_empty() || name.is_empty() { + return String::new(); + } + let title = format.title(&format!("{name} ({})", rows.len())); + format!("{title}\n{}", rows.join("\n")) +} diff --git a/native/cli/src/commands/scope_set.rs b/native/cli/src/commands/scope_set.rs new file mode 100644 index 000000000000..76cfef6affba --- /dev/null +++ b/native/cli/src/commands/scope_set.rs @@ -0,0 +1,399 @@ +//! Native `bit scope set [component-pattern]` (`ScopeSetCmd`). +//! +//! Updates workspace or component default scopes ([`Options`], [`Failure`], [`Bitmap`]) via [`selection`] and formats a [`Report`]. + +pub(in crate::commands) mod selection; +#[cfg(test)] +mod tests; + +use super::{ + color::Chalk, + workspace_command::{self, format::Formatter, results::Report}, +}; +use crate::{native::Outcome, workspace}; +use bit_install::workspace::Bitmap; +use serde_json::{Value, json}; +use std::{ + env, + ffi::OsString, + fs, io, + path::{Path, PathBuf}, +}; + +/// Parsed command-line options for `bit scope set [pattern]`. +#[derive(Debug, Clone, PartialEq, Eq)] +struct Options { + scope_name: String, + pattern: Option, +} +/// Error classification distinguishing delegation declines from user-facing diagnostics. +#[derive(Debug)] +pub(in crate::commands) enum Failure { + Declined(String), + User(String), +} + +impl From for Failure { + #[inline] + fn from(error: io::Error) -> Self { + Self::Declined(error.to_string()) + } +} + +struct WorkspacePlan { + root: PathBuf, + ws_key: String, + old_scope: String, + new_scope: String, + workspace_before: Vec, + bitmap_before: Vec, + bitmap_val: Value, + bitmap_changed: bool, +} + +struct ComponentPlan { + root: PathBuf, + new_scope: String, + changed_ids: Vec, + bitmap_before: Vec, + bitmap_val: Value, +} + +enum Prepared { + Workspace(WorkspacePlan), + Components(ComponentPlan), +} + +pub(super) fn run(args: &[OsString]) -> io::Result { + let Some(options) = parse_options(args) else { + return Ok( + Outcome::Declined("arguments outside the native `scope set` grammar".to_owned()), + ); + }; + let Some(root) = workspace::root(&env::current_dir()?) else { + return Ok(Outcome::Declined("scope set requires a workspace".to_owned())); + }; + let chalk = Chalk::detect(args); + match prepare(&root, &options, chalk) { + Ok(prepared) => { + workspace_command::execute_then(&root, args, "set", |_output| prepared.commit(chalk)) + } + Err(Failure::User(_)) if super::scope_rename::ci_active() => { + Ok(Outcome::Declined("CI scope set error stack".to_owned())) + } + Err(Failure::User(message)) => { + workspace_command::execute_then(&root, args, "set", |_output| { + Err(io::Error::other(message)) + }) + } + Err(Failure::Declined(reason)) => Ok(Outcome::Declined(format!("scope set: {reason}"))), + } +} + +fn parse_options(args: &[OsString]) -> Option { + let tokens = args + .get(1..)? + .iter() + .map(|arg| arg.to_str()) + .filter(|tok| { + !tok.is_some_and(|val| val.starts_with("--no-color") || val.starts_with("--color")) + }) + .collect::>>()?; + let (&"set", rest) = tokens.split_first()? else { return None }; + let positional = collect_non_flag_tokens(rest)?; + let (scope_name, pattern) = match positional.as_slice() { + [scope] if !scope.trim().is_empty() => (scope.trim().to_owned(), None), + [scope, pat] if !scope.trim().is_empty() && !pat.trim().is_empty() => { + let pat = pat.trim(); + if pat.contains(['$', ':', '@']) || pat.contains(" AND ") || pat.starts_with("teambit.") + { + return None; + } + (scope.trim().to_owned(), Some(pat.to_owned())) + } + _ => return None, + }; + Some(Options { scope_name, pattern }) +} + +pub(super) fn collect_non_flag_tokens<'a>(rest: &[&'a str]) -> Option> { + let mut positional = Vec::with_capacity(rest.len()); + let mut iter = rest.iter().map(|token| token.trim()); + while let Some(token) = iter.next() { + if token == "--" { + return collect_trailing(&mut positional, iter).then_some(positional); + } + if matches!( + token, + "--no-color" + | "--no-color=true" + | "--no-color=false" + | "--color" + | "--color=true" + | "--color=false" + | "--color=always" + | "--color=never", + ) { + continue; + } + if token.is_empty() || token.starts_with('-') { + return None; + } + positional.push(token); + } + Some(positional) +} + +#[inline] +fn collect_trailing<'a>( + positional: &mut Vec<&'a str>, + iter: impl Iterator, +) -> bool { + for trailing in iter { + let trailing = trailing.trim(); + if trailing.starts_with('-') || trailing.is_empty() { + return false; + } + positional.push(trailing); + } + true +} + +fn prepare(root: &Path, options: &Options, chalk: Chalk) -> Result { + let workspace_before = fs::read(root.join("workspace.jsonc"))?; + let bitmap_before = fs::read(root.join(".bitmap"))?; + let workspace = bit_object_import::parse_jsonc(&workspace_before)?; + let bitmap_val = bit_object_import::parse_jsonc(&bitmap_before)?; + let (ws_key, old_scope) = workspace_scope_info(&workspace)?; + match &options.pattern { + None => prepare_workspace_scope( + root, + &ws_key, + &old_scope, + &options.scope_name, + workspace_before, + bitmap_before, + bitmap_val, + ), + Some(pattern) => prepare_component_scope( + root, + &old_scope, + &options.scope_name, + pattern, + bitmap_before, + bitmap_val, + chalk, + ), + } +} + +fn workspace_scope_info(workspace: &Value) -> Result<(String, String), Failure> { + let rows = workspace + .as_object() + .ok_or_else(|| Failure::Declined("workspace.jsonc object".to_owned()))?; + let key = rows + .keys() + .find(|key| key.split('@').next() == Some("teambit.workspace/workspace")) + .cloned() + .unwrap_or_else(|| "teambit.workspace/workspace".to_owned()); + let ws = &workspace[&key]; + if ws["bitmapAutoSync"] == true { + return Err(Failure::Declined("bitmapAutoSync enabled".to_owned())); + } + let scope = ws["defaultScope"] + .as_str() + .filter(|scope| !scope.is_empty()) + .ok_or_else(|| Failure::Declined("missing defaultScope".to_owned()))?; + Ok((key, scope.to_owned())) +} + +fn prepare_workspace_scope( + root: &Path, + ws_key: &str, + old_scope: &str, + new_scope: &str, + workspace_before: Vec, + bitmap_before: Vec, + mut bitmap_val: Value, +) -> Result { + if old_scope == new_scope { + return Err(Failure::User(format!( + r#"the default-scope is already set as "{new_scope}", nothing to change"#, + ))); + } + selection::validate_scope_name(new_scope)?; + let bitmap_changed = update_bitmap_default_scope(&mut bitmap_val, old_scope, new_scope)?; + Ok(Prepared::Workspace(WorkspacePlan { + root: root.to_path_buf(), + ws_key: ws_key.to_owned(), + old_scope: old_scope.to_owned(), + new_scope: new_scope.to_owned(), + workspace_before, + bitmap_before, + bitmap_val, + bitmap_changed, + })) +} + +fn update_bitmap_default_scope( + bitmap_val: &mut Value, + old_scope: &str, + new_scope: &str, +) -> Result { + let rows = + bitmap_val.as_object_mut().ok_or_else(|| Failure::Declined("bitmap object".to_owned()))?; + let mut changed = false; + for (key, row) in rows { + if key.starts_with('$') + || matches!(key.as_str(), "_bit_lane" | "version") + || !row.is_object() + || has_version(key, row) + { + continue; + } + let default_scope = row["defaultScope"].as_str(); + let unscoped = row["scope"].as_str().is_none_or(str::is_empty); + if default_scope == Some(old_scope) || (default_scope.is_none_or(str::is_empty) && unscoped) + { + row["defaultScope"] = json!(new_scope); + changed = true; + } + } + Ok(changed) +} + +#[inline] +fn has_version(key: &str, row: &Value) -> bool { + row["version"] + .as_str() + .is_some_and(|version| !version.trim().is_empty()) + || key + .rsplit_once('@') + .is_some_and(|(_, version)| !version.trim().is_empty()) +} + +fn prepare_component_scope( + root: &Path, + default_scope: &str, + new_scope: &str, + pattern: &str, + bitmap_before: Vec, + mut bitmap_val: Value, + chalk: Chalk, +) -> Result { + let bitmap = Bitmap::load(root, Some(default_scope))?; + let matched = selection::resolve_pattern_entries(root, &bitmap, pattern, chalk)?; + selection::validate_scope_name(new_scope)?; + let new_entries = matched + .iter() + .copied() + .filter(|entry| entry.id.version.as_deref().is_none_or(str::is_empty)) + .collect::>(); + if new_entries.is_empty() { + let ids = matched + .iter() + .map(|entry| selection::entry_full_id(entry)) + .collect::>() + .join(", "); + return Err(Failure::User(format!( + "unable to set default-scope for the following components, as they are not new:\n{ids}", + ))); + } + let mut changed_ids = Vec::with_capacity(new_entries.len()); + for entry in new_entries { + set_entry_default_scope(&mut bitmap_val, entry, new_scope)?; + changed_ids.push(format!("{new_scope}/{}", entry.id.name)); + } + Ok(Prepared::Components(ComponentPlan { + root: root.to_path_buf(), + new_scope: new_scope.to_owned(), + changed_ids, + bitmap_before, + bitmap_val, + })) +} + +fn set_entry_default_scope( + bitmap_val: &mut Value, + entry: &bit_install::workspace::BitmapEntry, + new_scope: &str, +) -> Result<(), Failure> { + let rows = + bitmap_val.as_object_mut().ok_or_else(|| Failure::Declined("bitmap object".to_owned()))?; + let (_, row) = rows + .iter_mut() + .find(|(key, row)| { + !key.starts_with('$') + && !matches!(key.as_str(), "_bit_lane" | "version") + && row.is_object() + && row["isAvailableOnCurrentLane"] != false + && ((!entry.root_dir.is_empty() + && row["rootDir"].as_str() == Some(entry.root_dir.as_str())) + || super::update_cmd::bitmap_row_name(key, row) == entry.id.name) + }) + .ok_or_else(|| Failure::Declined(format!("bitmap entry {}", entry.id.name)))?; + row["defaultScope"] = json!(new_scope); + Ok(()) +} + +impl Prepared { + #[inline] + fn commit(self, chalk: Chalk) -> io::Result { + match self { + Self::Workspace(plan) => plan.commit(chalk), + Self::Components(plan) => plan.commit(chalk), + } + } +} + +impl WorkspacePlan { + fn commit(self, chalk: Chalk) -> io::Result { + let formatter = Formatter::new(chalk); + let file_id = workspace_command::file_id(); + selection::publish_workspace_default_scope( + &self.root, + &self.workspace_before, + &self.ws_key, + &self.new_scope, + &file_id, + )?; + if self.bitmap_changed { + bit_object_import::component_deprecation::write_bitmap( + &self.root, + &self.bitmap_before, + &self.bitmap_val, + &file_id, + "scope-set", + )?; + } + let data = formatter.success_summary(&format!( + r#"set the workspace's default-scope to {}. (previous scope was "{}")"#, + chalk.bold(&self.new_scope), + self.old_scope, + )); + Ok(Report { data, code: 0, details: None }) + } +} + +impl ComponentPlan { + fn commit(self, chalk: Chalk) -> io::Result { + let formatter = Formatter::new(chalk); + let file_id = workspace_command::file_id(); + bit_object_import::component_deprecation::write_bitmap( + &self.root, + &self.bitmap_before, + &self.bitmap_val, + &file_id, + "scope-set", + )?; + let title = formatter.success_summary(&format!( + "set {} as the default-scope for the following component(s)", + chalk.bold(&self.new_scope), + )); + let items = self.changed_ids + .iter() + .map(|id| formatter.item(id)) + .collect::>(); + Ok(Report { data: format!("{title}\n{}", items.join("\n")), code: 0, details: None }) + } +} diff --git a/native/cli/src/commands/scope_set/selection.rs b/native/cli/src/commands/scope_set/selection.rs new file mode 100644 index 000000000000..4c1fedbead24 --- /dev/null +++ b/native/cli/src/commands/scope_set/selection.rs @@ -0,0 +1,263 @@ +//! Component-pattern resolution and scope-name validation for `bit scope set`. +//! +//! [`resolve_pattern_entries`] matches [`Bitmap`] entries ([`BitmapEntry`]) against [`patterns`] and [`inventory`] rules. + +use super::{ + super::{color::Chalk, inventory, patterns}, + Failure, +}; +use crate::workspace; +use bit_install::workspace::{Bitmap, BitmapEntry}; +use serde_json::{Value, json}; +use std::{ + fs, + io::{self, Write}, + path::Path, +}; + +/// Resolves active `.bitmap` entries matching `pattern` (literal or glob/multi-clause) for `bit scope set`. +pub(in crate::commands) fn resolve_pattern_entries<'a>( + root: &Path, + bitmap: &'a Bitmap, + pattern: &str, + chalk: Chalk, +) -> Result, Failure> { + if !pattern.contains(['*', ',', '!', '$', ':']) { + return resolve_literal_entry(root, bitmap, pattern, chalk); + } + let active = bitmap.entries + .iter() + .filter(|entry| entry.owns_its_dir()) + .collect::>(); + let identities = active + .iter() + .map(|entry| entry.id.to_string_without_version()) + .collect::>(); + let identity_refs = identities + .iter() + .map(String::as_str) + .collect::>(); + let matched = patterns::extended::matching(&identity_refs, &patterns::split(pattern))?; + if matched.is_empty() { + return Err(Failure::User(format!( + r#"unable to find any component matching the "{pattern}" pattern"#, + ))); + } + Ok(matched + .into_iter() + .filter_map(|identity| { + active + .iter() + .copied() + .zip(&identities) + .find_map(|(entry, entry_id)| (entry_id == identity).then_some(entry)) + }) + .collect()) +} + +/// Strips an optional `@version` suffix from a literal component selector and rejects package selectors. +#[inline] +fn parse_bare_literal(pattern: &str) -> Result<&str, Failure> { + let pattern = pattern.trim(); + if pattern.starts_with('@') { + return Err(Failure::Declined("package name selector".to_owned())); + } + let bare = match pattern.rsplit_once('@') { + Some((_, ver)) if ver.trim().is_empty() => { + return Err(Failure::Declined("empty component version".to_owned())); + } + Some((id, _)) => id.trim(), + None => pattern, + }; + if bare.is_empty() { + return Err(Failure::Declined("empty component id".to_owned())); + } + Ok(bare) +} + +fn resolve_literal_entry<'a>( + root: &Path, + bitmap: &'a Bitmap, + pattern: &str, + chalk: Chalk, +) -> Result, Failure> { + let bare = parse_bare_literal(pattern)?; + let normalized = inventory::normalize(bare); + let directory = normalized + .strip_prefix("./") + .unwrap_or(&normalized) + .trim_end_matches('/'); + let found = bitmap.entries + .iter() + .find(|entry| { + !entry.root_dir.is_empty() && entry.root_dir.trim_end_matches('/') == directory + }) + .or_else(|| { + bitmap.entries + .iter() + .find(|entry| { + entry.id.to_string_without_version() == bare || entry.id.name == bare + }) + }); + if let Some(entry) = found.filter(|entry| entry.owns_its_dir()) { + return Ok(vec![entry]); + } + if found.is_some() || is_known_scope_or_valid_scoped_id(root, bare)? { + return Err(Failure::User(format!( + "error: component \"{}\" was not found on your local workspace.\nplease specify a valid component ID or track the component using 'bit create' (see 'bit create --help' for more information)", + chalk.bold(pattern), + ))); + } + Err(Failure::User(format!( + r#"error: component ID "{bare}" is invalid, please use the following format: [scope]/"#, + ))) +} + +#[inline] +fn is_known_scope_or_valid_scoped_id(root: &Path, pattern: &str) -> Result { + if let Some((scope, name)) = pattern.split_once('/') { + validate_scope_name(scope)?; + validate_component_name(name)?; + return Ok(true); + } + Ok(!pattern.contains('.') && exists_in_scope_index(root, pattern)) +} + +pub(in crate::commands) fn exists_in_scope_index(root: &Path, name: &str) -> bool { + let Ok(bytes) = fs::read(workspace::scope_path(root).join("index.json")) else { + return false; + }; + let Ok(index) = bit_object_import::parse_jsonc(&bytes) else { + return false; + }; + index["components"] + .as_array() + .or_else(|| index.as_array()) + .is_some_and(|components| { + components + .iter() + .any(|item| item["isSymlink"] != true && row_has_name(item, name)) + }) +} + +#[inline] +pub(in crate::commands) fn row_has_name(row: &Value, name: &str) -> bool { + let name = name.trim(); + row["id"]["name"].as_str().map(str::trim) == Some(name) + || row["id"] + .as_str() + .map(str::trim) + .map(|raw| { + raw.split('@') + .next() + .unwrap_or(raw) + .trim() + }) + .is_some_and(|bare| { + bare == name + || bare + .split_once('/') + .is_some_and(|(_, nm)| nm.trim() == name) + }) +} + +#[inline] +pub(in crate::commands) fn valid_id_chunk(chunk: &str) -> bool { + !chunk.is_empty() + && !chunk.contains("//") + && chunk + .chars() + .all(|ch| { + ch.is_ascii_lowercase() + || ch.is_ascii_digit() + || matches!(ch, '-' | '_' | '$' | '!' | '/') + }) +} + +#[inline] +pub(in crate::commands) fn validate_component_name(name: &str) -> Result<(), Failure> { + if valid_id_chunk(name) { + return Ok(()); + } + Err(Failure::User(format!( + r#"error: "{name}" is invalid, component names can only contain alphanumeric, lowercase characters, and the following ["-", "_", "$", "!", "/"]"#, + ))) +} + +#[inline] +pub(in crate::commands) fn valid_scope_str(scope: &str) -> bool { + scope.len() >= 2 + && !scope.starts_with('.') + && !scope.ends_with('.') + && scope + .bytes() + .filter(|byte| *byte == b'.') + .count() + <= 1 + && scope + .bytes() + .all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || b"$-_!.".contains(&byte) + }) +} + +#[inline] +pub(in crate::commands) fn validate_scope_name(scope: &str) -> Result<(), Failure> { + if valid_scope_str(scope) { + return Ok(()); + } + Err(Failure::User(format!( + r#"error: "{scope}" is invalid, component scope names can only contain alphanumeric, lowercase characters, and the following ["-", "_", "$", "!"]"#, + ))) +} + +#[inline] +pub(in crate::commands) fn entry_full_id(entry: &BitmapEntry) -> String { + let identity = entry.id.to_string_without_version(); + match &entry.id.version { + Some(version) if !version.is_empty() && !matches!(version.as_str(), "latest" | "head") => { + format!("{identity}@{version}") + } + _ => identity, + } +} + +pub(super) fn publish_workspace_default_scope( + root: &Path, + before: &[u8], + key: &str, + scope: &str, + file_id: &str, +) -> io::Result<()> { + let text = std::str::from_utf8(before).map_err(io::Error::other)?; + let mut document = bit_workspace_config::jsonc::Document::parse(text)?; + document.set(&[key, "defaultScope"], &json!(scope)); + let _ = backup_workspace_config(root, before, scope, file_id); + let rendered = format!("{document}\n"); + let bytes = if cfg!(windows) + && before + .windows(2) + .any(|pair| pair == b"\r\n") + { + rendered.replace('\n', "\r\n").into_bytes() + } else { + rendered.into_bytes() + }; + fs::write(root.join("workspace.jsonc"), bytes) +} + +fn backup_workspace_config( + root: &Path, + before: &[u8], + scope: &str, + file_id: &str, +) -> io::Result<()> { + let directory = workspace::scope_path(root).join("workspace-config-history"); + fs::create_dir_all(directory.join("files"))?; + fs::write(directory.join("files").join(file_id), before)?; + let mut metadata = fs::OpenOptions::new() + .create(true) + .append(true) + .open(directory.join("metadata.txt"))?; + writeln!(metadata, "{file_id} default-scope ({scope})") +} diff --git a/native/cli/src/commands/scope_set/tests.rs b/native/cli/src/commands/scope_set/tests.rs new file mode 100644 index 000000000000..0df53af6b185 --- /dev/null +++ b/native/cli/src/commands/scope_set/tests.rs @@ -0,0 +1,44 @@ +//! Unit tests for `bit scope set` [`Options`] CLI argument parsing ([`parse_options`]) and unsupported-selector rejection. +//! +//! Verifies workspace-wide and component-pattern scope assignment argument parsing. + +use super::{Options, parse_options}; +use std::ffi::OsString; + +/// Parses a slice of string tokens into [`Options`] via [`parse_options`]. +fn parse(words: &[&str]) -> Option { + let args = words + .iter() + .map(OsString::from) + .collect::>(); + parse_options(&args) +} + +/// Verifies [`parse_options`] for workspace-wide and component-specific `bit scope set` invocations. +#[test] +fn parses_workspace_and_component_scope_set() { + assert_eq!( + parse(&["scope", "set", "org.my-scope"]), + Some(Options { scope_name: "org.my-scope".to_owned(), pattern: None }), + ); + assert_eq!( + parse(&["scope", "set", "org.my-scope", "comp1"]), + Some(Options { scope_name: "org.my-scope".to_owned(), pattern: Some("comp1".to_owned()) }), + ); +} + +#[test] +fn declines_unsupported_scope_set_arguments() { + assert_eq!(parse(&["scope", "set"]), None); + assert_eq!(parse(&["scope", "set", "org.scope", "--help"]), None); + assert_eq!(parse(&["scope", "set", "org.scope", "$modified"]), None); + assert_eq!(parse(&["scope", "set", "org.scope", "comp1", "extra"]), None); +} + +#[test] +fn accepts_no_color_flag_with_scope_set() { + assert_eq!( + parse(&["scope", "set", "org.my-scope", "--no-color"]), + Some(Options { scope_name: "org.my-scope".to_owned(), pattern: None }), + ); +} diff --git a/native/cli/src/commands/scope_trust.rs b/native/cli/src/commands/scope_trust.rs new file mode 100644 index 000000000000..f073121fd4ee --- /dev/null +++ b/native/cli/src/commands/scope_trust.rs @@ -0,0 +1,371 @@ +//! Native `bit scope trust [action] [pattern]` (`ScopeTrustCmd`). +//! +//! Manages trusted scope entries ([`Action`], [`TRUSTED_SCOPES_KEY`], [`BUILTIN_TRUSTED_PATTERNS`]) in `workspace.jsonc` and formats a [`Report`] with [`Formatter`]. + +#[cfg(test)] +mod tests; + +use super::{ + color::Chalk, + workspace_command::{self, format::Formatter, results::Report}, +}; +use crate::{native::Outcome, workspace}; +use serde_json::{Value, json}; +use std::{ + env, + ffi::OsString, + fs, io, + path::{Path, PathBuf}, +}; + +/// Default trusted scope glob patterns enabled when `bit scope trust --enable` initializes `trustedScopes`. +const BUILTIN_TRUSTED_PATTERNS: &[&str] = + &["teambit.*", "bitdev.*", "learn-bit-react.*", "bitdesign.*", "frontend.*"]; +/// Top-level `workspace.jsonc` key storing trusted scope patterns. +const TRUSTED_SCOPES_KEY: &str = "trustedScopes"; + +/// Action selected by `bit scope trust` CLI flags and positional arguments. +#[derive(Debug, Clone, PartialEq, Eq)] +enum Action { + List, + Enable, + Disable, + Add(String), + Remove(String), +} + +#[derive(Debug)] +enum Failure { + Declined(String), + User(String), +} + +impl From for Failure { + #[inline] + fn from(error: io::Error) -> Self { + Self::Declined(error.to_string()) + } +} + +struct WorkspaceTrust { + root: PathBuf, + ws_key: String, + default_scope: String, + opted_in: bool, + configured: Vec, +} + +pub(super) fn run(args: &[OsString]) -> io::Result { + let Some(tokens) = parse_tokens(args) else { + return Ok(Outcome::Declined( + "arguments outside the native `scope trust` grammar".to_owned(), + )); + }; + let Some(root) = workspace::root(&env::current_dir()?) else { + return Ok(Outcome::Declined("scope trust requires a workspace".to_owned())); + }; + let chalk = Chalk::detect(args); + match prepare_and_execute(&root, &tokens, chalk) { + Ok(report) => workspace_command::execute_then(&root, args, "trust", |_output| Ok(report)), + Err(Failure::User(message)) => { + if env::var_os("CI") + .is_some_and(|value| { + let text = value.to_string_lossy(); + let trimmed = text + .trim() + .trim_matches(['"', '\'']) + .trim(); + !trimmed.is_empty() + && !["0", "false", "no", "off"] + .iter() + .any(|falsy| trimmed.eq_ignore_ascii_case(falsy)) + }) + { + return Ok(Outcome::Declined("CI scope trust error stack".to_owned())); + } + workspace_command::execute_then(&root, args, "trust", |_output| { + Err(io::Error::other(message)) + }) + } + Err(Failure::Declined(reason)) => Ok(Outcome::Declined(format!("scope trust: {reason}"))), + } +} + +fn parse_tokens(args: &[OsString]) -> Option> { + let tokens = args + .iter() + .skip(1) + .map(|arg| arg.to_str()) + .filter(|token| { + !token.is_some_and(|val| { + matches!( + val, + "--no-color" + | "--no-color=true" + | "--no-color=false" + | "--color" + | "--color=true" + | "--color=false" + | "--color=always" + | "--color=never", + ) + }) + }) + .collect::>>()?; + let (&"trust", rest) = tokens.split_first()? else { + return None; + }; + let positional: Vec = super::scope_set::collect_non_flag_tokens(rest)? + .into_iter() + .map(str::to_owned) + .collect(); + (positional.len() <= 2).then_some(positional) +} + +fn parse_action(tokens: &[String]) -> Result { + let raw_action = tokens.first().map_or("list", |action| action.trim()); + let pattern = tokens.get(1).map(String::as_str); + match raw_action { + "list" | "enable" | "disable" if pattern.is_some() => { + Err(Failure::Declined("unexpected pattern argument".to_owned())) + } + "list" => Ok(Action::List), + "enable" => Ok(Action::Enable), + "disable" => Ok(Action::Disable), + "add" => Ok(Action::Add(require_pattern("add", pattern)?)), + "remove" => Ok(Action::Remove(require_pattern("remove", pattern)?)), + _ => Err(Failure::User(format!( + r#"unknown action "{raw_action}". valid actions: list, enable, disable, add, remove."#, + ))), + } +} + +#[inline] +fn require_pattern(action: &str, pattern: Option<&str>) -> Result { + let Some(pattern) = pattern + .map(str::trim) + .filter(|value| !value.is_empty()) + else { + return Err(Failure::User(format!( + r#""{action}" requires a pattern. example: bit scope trust {action} acme.frontend"#, + ))); + }; + Ok(pattern.to_owned()) +} + +fn prepare_and_execute(root: &Path, tokens: &[String], chalk: Chalk) -> Result { + let trust = WorkspaceTrust::load(root)?; + let action = parse_action(tokens)?; + let formatter = Formatter::new(chalk); + let data = match action { + Action::List => trust.format_list(&formatter), + Action::Enable => { + trust.enable()?; + formatter.success_summary( + "scope-trust enabled (added trustedScopes: [] to workspace.jsonc)", + ) + } + Action::Disable => { + trust.disable()?; + formatter.success_summary( + "scope-trust disabled (removed trustedScopes from workspace.jsonc)", + ) + } + Action::Add(pattern) => { + trust.add(&pattern)?; + formatter.success_summary(&format!( + "added {} to trustedScopes in workspace.jsonc", + chalk.bold(&pattern), + )) + } + Action::Remove(pattern) => { + trust.remove(&pattern)?; + formatter.success_summary(&format!( + "removed {} from trustedScopes in workspace.jsonc", + chalk.bold(&pattern), + )) + } + }; + Ok(Report { data, code: 0, details: None }) +} + +impl WorkspaceTrust { + fn load(root: &Path) -> Result { + let bytes = fs::read(root.join("workspace.jsonc"))?; + let workspace = bit_object_import::parse_jsonc(&bytes)?; + let rows = workspace + .as_object() + .ok_or_else(|| Failure::Declined("workspace.jsonc object".to_owned()))?; + let ws_key = rows + .keys() + .find(|key| key.split('@').next() == Some("teambit.workspace/workspace")) + .cloned() + .unwrap_or_else(|| "teambit.workspace/workspace".to_owned()); + let ext = workspace[&ws_key].as_object(); + let default_scope = ext + .and_then(|obj| obj.get("defaultScope")) + .and_then(Value::as_str) + .unwrap_or_default() + .to_owned(); + let opted_in = ext.is_some_and(|obj| obj.contains_key(TRUSTED_SCOPES_KEY)); + let configured = ext + .and_then(|obj| obj.get(TRUSTED_SCOPES_KEY)) + .and_then(Value::as_array) + .map(|items| { + items + .iter() + .filter_map(Value::as_str) + .map(str::to_owned) + .collect() + }) + .unwrap_or_default(); + Ok(Self { root: root.to_path_buf(), ws_key, default_scope, opted_in, configured }) + } + + fn format_list(&self, formatter: &Formatter) -> String { + if !self.opted_in { + return Formatter::join(&[ + formatter.title("scope-trust is off for this workspace."), + "aspects from any scope load without a check.".to_owned(), + formatter.hint( + "to turn on:\n bit scope trust enable (no scopes added; only builtins + owner-of-defaultScope auto-trusted)\n bit scope trust add (turns on and adds the first scope)", + ), + ]); + } + let builtin = BUILTIN_TRUSTED_PATTERNS + .iter() + .map(|pattern| formatter.item(pattern)) + .collect::>(); + let owner = self + .inferred_owner_pattern() + .into_iter() + .map(|pattern| formatter.item(&pattern)) + .collect::>(); + let configured_section = if self.configured.is_empty() { + formatter.hint( + "no scopes configured in workspace.jsonc. add one with `bit scope trust add `.", + ) + } else { + let items = self.configured + .iter() + .map(|pattern| formatter.item(pattern)) + .collect::>(); + formatter.section("configured in workspace.jsonc", "", &items) + }; + Formatter::join(&[ + formatter.title("scope-trust is on. aspects from these scopes load without a prompt:"), + formatter.section("builtin", "", &builtin), + formatter.section("inferred from workspace defaultScope", "", &owner), + configured_section, + ]) + } + + #[inline] + fn inferred_owner_pattern(&self) -> Option { + if self.default_scope.is_empty() { + return None; + } + let Some((owner, _)) = self.default_scope.split_once('.') else { + return Some(self.default_scope.clone()); + }; + (!owner.is_empty()).then(|| format!("{owner}.*")) + } + + #[inline] + fn enable(&self) -> Result<(), Failure> { + if self.opted_in { + return Ok(()); + } + self.write_trusted_scopes(&[], "enable scope-trust") + } + + fn disable(&self) -> Result<(), Failure> { + if !self.opted_in { + return Ok(()); + } + let scope_path = workspace::scope_path(&self.root); + let file_id = workspace_command::file_id(); + bit_install::command::edit_workspace_config( + &self.root, + &scope_path, + &file_id, + "disable scope-trust", + |document| { + document.remove(&[&self.ws_key, TRUSTED_SCOPES_KEY]); + }, + )?; + Ok(()) + } + + fn add(&self, pattern: &str) -> Result<(), Failure> { + if !is_valid_pattern(pattern) { + return Err(Failure::User(format!( + r#"invalid scope pattern: "{pattern}". use an exact scope name (e.g. "acme.frontend" or "my-scope") or an owner wildcard (e.g. "acme.*")."#, + ))); + } + if self.configured + .iter() + .any(|item| item == pattern) + { + return Ok(()); + } + let mut next = self.configured.clone(); + next.push(pattern.to_owned()); + self.write_trusted_scopes(&next, &format!("add trusted scope {pattern}")) + } + + fn remove(&self, pattern: &str) -> Result<(), Failure> { + if !self.configured + .iter() + .any(|item| item == pattern) + { + return Ok(()); + } + let next = self.configured + .iter() + .filter(|item| item.as_str() != pattern) + .cloned() + .collect::>(); + self.write_trusted_scopes(&next, &format!("remove trusted scope {pattern}")) + } + + fn write_trusted_scopes(&self, list: &[String], reason: &str) -> Result<(), Failure> { + let scope_path = workspace::scope_path(&self.root); + let file_id = workspace_command::file_id(); + bit_install::command::edit_workspace_config( + &self.root, + &scope_path, + &file_id, + reason, + |document| { + document.set(&[&self.ws_key, TRUSTED_SCOPES_KEY], &json!(list)); + }, + )?; + Ok(()) + } +} + +#[inline] +fn is_valid_pattern(pattern: &str) -> bool { + if let Some(owner) = pattern.strip_suffix(".*") { + return !owner.contains('.') && valid_scope_str(owner); + } + valid_scope_str(pattern) +} + +#[inline] +fn valid_scope_str(scope: &str) -> bool { + scope.len() >= 2 + && !scope.starts_with('.') + && !scope.ends_with('.') + && scope + .bytes() + .filter(|byte| *byte == b'.') + .count() + <= 1 + && scope + .bytes() + .all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || b"$-_!.".contains(&byte) + }) +} diff --git a/native/cli/src/commands/scope_trust/tests.rs b/native/cli/src/commands/scope_trust/tests.rs new file mode 100644 index 000000000000..869a48689b88 --- /dev/null +++ b/native/cli/src/commands/scope_trust/tests.rs @@ -0,0 +1,108 @@ +//! Unit tests for `bit scope trust` [`Action`] CLI subcommand parsing ([`parse_tokens`], [`parse_action`]), pattern validation ([`is_valid_pattern`]), and [`WorkspaceTrust`] `workspace.jsonc` updates. +//! +//! Verifies [`Chalk`] and [`Formatter`] output alongside `workspace.jsonc` trust list mutations. + +use super::{Action, Chalk, WorkspaceTrust, is_valid_pattern, parse_action, parse_tokens}; +use crate::commands::workspace_command::format::Formatter; +use std::{ffi::OsString, fs, path::Path}; + +/// Converts string slice tokens into [`OsString`] vectors for [`parse_tokens`]. +fn os_args(items: &[&str]) -> Vec { + items + .iter() + .map(OsString::from) + .collect() +} + +/// Writes a minimal `workspace.jsonc` and `.bit` directory with `default_scope` at `root`. +fn write_workspace(root: &Path, default_scope: &str) { + fs::create_dir_all(root.join(".bit")).unwrap(); + fs::write( + root.join("workspace.jsonc"), + format!( + "{{\n \"teambit.workspace/workspace\": {{\n \"defaultScope\": \"{default_scope}\"\n }}\n}}\n", + ), + ) + .unwrap(); +} + +#[test] +fn parses_tokens_and_actions() { + assert_eq!(parse_tokens(&os_args(&["scope", "trust"])), Some(Vec::new())); + assert_eq!( + parse_tokens(&os_args(&[ + "scope", + "trust", + "add", + "acme.*", + "--no-color", + "--no-color=false", + ])), + Some(vec!["add".to_owned(), "acme.*".to_owned()]), + ); + assert_eq!(parse_tokens(&os_args(&["scope", "trust", "--help"])), None); + assert_eq!(parse_tokens(&os_args(&["scope", "trust", "a", "b", "c"])), None); + + assert_eq!(parse_action(&[]).ok(), Some(Action::List)); + assert_eq!(parse_action(&["enable".to_owned()]).ok(), Some(Action::Enable)); + assert_eq!(parse_action(&["disable".to_owned()]).ok(), Some(Action::Disable)); + assert_eq!( + parse_action(&["add".to_owned(), "acme.*".to_owned()]).ok(), + Some(Action::Add("acme.*".to_owned())), + ); + assert!(parse_action(&["add".to_owned()]).is_err()); + assert!(parse_action(&["bogus".to_owned()]).is_err()); +} + +#[test] +fn validates_scope_trust_patterns() { + assert!(is_valid_pattern("acme.frontend")); + assert!(is_valid_pattern("my-scope")); + assert!(is_valid_pattern("acme.*")); + assert!(!is_valid_pattern("acme.frontend.*")); + assert!(!is_valid_pattern("Acme.*")); + assert!(!is_valid_pattern("a")); + assert!(!is_valid_pattern(".*")); +} + +#[test] +fn manages_trusted_scopes_lifecycle_in_workspace_jsonc() { + let dir = tempfile::tempdir().unwrap(); + write_workspace(dir.path(), "my-org.my-scope"); + let formatter = Formatter::new(Chalk::new(false)); + + let initial = WorkspaceTrust::load(dir.path()).unwrap(); + assert!(!initial.opted_in); + assert!(initial.format_list(&formatter).contains("scope-trust is off for this workspace.")); + + initial.enable().unwrap(); + let enabled = WorkspaceTrust::load(dir.path()).unwrap(); + assert!(enabled.opted_in); + assert!(enabled.configured.is_empty()); + let list_text = enabled.format_list(&formatter); + assert!(list_text.contains("scope-trust is on.")); + assert!(list_text.contains("my-org.*")); + assert!(list_text.contains("no scopes configured in workspace.jsonc.")); + + enabled.add("acme.frontend").unwrap(); + let added = WorkspaceTrust::load(dir.path()).unwrap(); + assert_eq!(added.configured, vec!["acme.frontend".to_owned()]); + assert!(added.format_list(&formatter).contains("acme.frontend")); + + added.remove("acme.frontend").unwrap(); + let removed = WorkspaceTrust::load(dir.path()).unwrap(); + assert!(removed.opted_in); + assert!(removed.configured.is_empty()); + + removed.disable().unwrap(); + let disabled = WorkspaceTrust::load(dir.path()).unwrap(); + assert!(!disabled.opted_in); +} + +#[test] +fn parses_remove_action_with_pattern() { + assert_eq!( + parse_action(&["remove".to_owned(), "acme.*".to_owned()]).ok(), + Some(Action::Remove("acme.*".to_owned())), + ); +} diff --git a/native/cli/src/commands/script.rs b/native/cli/src/commands/script.rs new file mode 100644 index 000000000000..497a790466a2 --- /dev/null +++ b/native/cli/src/commands/script.rs @@ -0,0 +1,217 @@ +//! Native `bit script [script-name]` (`scopes/workspace/scripts/script.cmd.ts`). +//! +//! Re-exports [`COMMAND`], [`ScriptHandler`], [`parse_env_scripts_in_dir`], and [`resolve_workspace_components`]. + +pub(in crate::commands) use self::{ + parser::{ScriptHandler, parse_env_scripts_in_dir}, + runner::{find_workspace_env_component, resolve_workspace_components}, +}; + +mod parser; +mod runner; +#[cfg(test)] +mod tests; + +use self::runner::{ScriptFailure, execute, read_allowed_envs}; +use super::{ + Command, + color::Chalk, + lane::scope_root, + workspace_command::{self, results::Report}, +}; +use crate::{native::Outcome, workspace}; +use bit_object_import::parse_jsonc; +use serde_json::Value; +use std::{env, ffi::OsString, fs, io, path::Path}; + +/// Descriptor for the native `bit script` command. +pub const COMMAND: Command = Command { name: "script", aliases: &[], run }; + +/// Error message emitted when no script name is provided and `--list` is not set. +const MISSING_SCRIPT_ERR: &str = "script name is required. Use --list to see available scripts."; +/// Warning message emitted when `teambit.workspace/scripts` has no configured environments. +const NO_ENVS_CONFIG_MSG: &str = "no envs configured. Add to workspace.jsonc:\n{\n \"teambit.workspace/scripts\": {\n \"envs\": [\"your-scope/your-env\"]\n }\n}"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct Options { + pub script_name: Option, + pub list: bool, +} + +fn run(args: &[OsString]) -> io::Result { + let Some(argv) = workspace_command::utf8_args(args) else { + return Ok(Outcome::Declined("script arguments are not UTF-8".into())); + }; + let Some(options) = parse_options(&argv[1..]) else { + return Ok(Outcome::Declined("outside native script grammar".into())); + }; + if !options.list && options.script_name.is_none() && !is_ci_active() { + return missing_script_outcome(args); + } + let cwd = env::current_dir()?; + let chalk = Chalk::detect(args); + let Some(root) = workspace::root(&cwd) else { + return run_outside_workspace(&cwd, args, chalk); + }; + if let Ok(Some(msg)) = fast_workspace_message(&root, chalk) { + return workspace_command::execute_without_loader(&root, args, "script", |_| { + Ok(Report { data: msg, code: 0, details: None }) + }); + } + finish_script_outcome(&root, args, execute(&root, &options, chalk)) +} + +fn run_outside_workspace(cwd: &Path, args: &[OsString], chalk: Chalk) -> io::Result { + if let Some(scope) = scope_root::discover().ok().flatten() { + if scope.bare && bare_scope_has_no_envs(&scope.path) { + let msg = chalk.yellow(NO_ENVS_CONFIG_MSG); + return workspace_command::execute_without_loader_scope( + &scope.path, + args, + "script", + |_| Ok(Report { data: msg, code: 0, details: None }), + ); + } + return Ok(Outcome::Declined("script requires a workspace".into())); + } + let msg = chalk.yellow(NO_ENVS_CONFIG_MSG); + workspace_command::execute_without_loader_scope(cwd, args, "script", |_| { + Ok(Report { data: msg, code: 0, details: None }) + }) +} + +#[inline] +fn bare_scope_has_no_envs(scope_path: &Path) -> bool { + let Ok(bytes) = fs::read(scope_path.join("scope.json")) else { + return false; + }; + let Ok(parsed) = parse_jsonc(&bytes) else { + return false; + }; + let config = if parsed["config"].is_object() { &parsed["config"] } else { &parsed }; + read_allowed_envs(config).is_empty() +} + +#[inline] +fn missing_script_outcome(args: &[OsString]) -> io::Result { + let cwd = env::current_dir()?; + if let Some(root) = workspace::root(&cwd) { + return workspace_command::execute_without_loader(&root, args, "script", |_| { + Err(io::Error::other(MISSING_SCRIPT_ERR)) + }); + } + workspace_command::execute_without_loader_scope(&cwd, args, "script", |_| { + Err(io::Error::other(MISSING_SCRIPT_ERR)) + }) +} + +fn finish_script_outcome( + root: &Path, + args: &[OsString], + result: Result, +) -> io::Result { + match result { + Ok(data) => workspace_command::execute_without_loader(root, args, "script", |_| { + Ok(Report { data, code: 0, details: None }) + }), + Err(ScriptFailure::User(message)) => { + if is_ci_active() { + return Ok(Outcome::Declined("CI script error stack".into())); + } + workspace_command::execute_without_loader(root, args, "script", |_| { + Err(io::Error::other(message)) + }) + } + Err(ScriptFailure::Declined(reason)) => Ok(Outcome::Declined(format!("script: {reason}"))), + } +} + +#[inline] +fn is_ci_active() -> bool { + env::var_os("CI") + .is_some_and(|val| { + let text = val.to_string_lossy(); + let trimmed = text + .trim() + .trim_matches(['"', '\'']) + .trim(); + !trimmed.is_empty() + && !["0", "false", "no", "off"] + .iter() + .any(|falsy| trimmed.eq_ignore_ascii_case(falsy)) + }) +} + +pub(super) fn parse_options(args: &[&str]) -> Option { + let mut script_name = None; + let mut list = false; + let mut iter = args.iter().copied(); + while let Some(arg) = iter.next() { + match arg { + "--" => { + super::internalize::consume_trailing_internalize(iter, &mut script_name)?; + break; + } + "--no-color" | "--no-color=true" | "--no-color=false" | "--color" | "--color=true" + | "--color=false" | "--color=always" | "--color=never" => {} + "-l" | "--list" | "--list=true" => list = true, + "--list=false" | "--no-list" => list = false, + val if let Some(name) = val + .strip_prefix("--script-name=") + .or_else(|| val.strip_prefix("--scriptName=")) + .map(|raw| { + raw.trim() + .trim_matches(['"', '\'']) + .trim() + }) + .filter(|name| !name.is_empty()) => + { + script_name = Some(name.to_owned()); + } + val if !val.starts_with('-') && !val.trim().is_empty() && script_name.is_none() => { + script_name = Some(val.trim().to_owned()); + } + _ => return None, + } + } + Some(Options { script_name, list }) +} + +#[inline] +pub(super) fn fast_workspace_message(root: &Path, chalk: Chalk) -> io::Result> { + let ws_config = parse_jsonc(&fs::read(root.join("workspace.jsonc"))?)?; + if super::build_cmd::is_bitmap_auto_sync(&ws_config) { + return Ok(None); + } + if read_allowed_envs(&ws_config).is_empty() { + return Ok(Some(chalk.yellow(NO_ENVS_CONFIG_MSG))); + } + let bitmap = parse_jsonc(&fs::read(root.join(".bitmap"))?)?; + if active_component_count(&bitmap) == 0 { + return Ok(Some(chalk.yellow("no components found"))); + } + Ok(None) +} + +#[inline] +pub(super) fn active_component_count(bitmap: &Value) -> usize { + bitmap + .as_object() + .map_or(0, |rows| { + rows.iter() + .filter(|(key, row)| { + let key = key.trim(); + !key.starts_with('$') + && key != "_bit_lane" + && key != "version" + && row.is_object() + && row["isAvailableOnCurrentLane"] != false + && !super::update_cmd::aspect_entry( + &row["config"], + "teambit.component/remove", + ) + .is_some_and(|(_, cfg)| cfg["removed"] == true) + }) + .count() + }) +} diff --git a/native/cli/src/commands/script/parser.rs b/native/cli/src/commands/script/parser.rs new file mode 100644 index 000000000000..12d6f96658cd --- /dev/null +++ b/native/cli/src/commands/script/parser.rs @@ -0,0 +1,369 @@ +//! Source-file parser and function evaluator for `env.scripts()` (`Scripts.from(...)`). +//! +//! [`parse_env_scripts_in_dir`] extracts [`EnvScripts`], [`ParsedScript`], and [`ScriptHandler`] shell commands and log statements. + +use std::{ + fs, + path::{Path, PathBuf}, +}; + +/// Parsed handler body for an environment script (`Shell` command string or `Function` body). +#[derive(Debug, Clone, PartialEq, Eq)] +pub(in crate::commands) enum ScriptHandler { + Shell(String), + Function(String), +} + +/// Named script entry extracted from an environment's `scripts()` definition. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(in crate::commands) struct ParsedScript { + pub name: String, + pub handler: ScriptHandler, +} + +/// Scripts and `scripts()` method presence extracted from an environment component directory. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub(in crate::commands) struct EnvScripts { + pub has_scripts_method: bool, + pub scripts: Vec, +} + +pub(in crate::commands) fn parse_env_scripts_in_dir(dir: &Path) -> EnvScripts { + let mut files = collect_source_files(dir); + files.sort_by_key(|path| file_sort_key(path)); + for path in files { + let Ok(content) = fs::read_to_string(&path) else { + continue; + }; + let parsed = parse_scripts_from_source(&content); + if parsed.has_scripts_method || !parsed.scripts.is_empty() { + return parsed; + } + } + EnvScripts::default() +} + +#[inline] +fn file_sort_key(path: &Path) -> (u8, String, String) { + let name = path + .file_name() + .and_then(|val| val.to_str()) + .unwrap_or_default(); + let priority = if name.contains(".bit-env.") { + 0 + } else if name.contains(".main.runtime.") { + 1 + } else { + 2 + }; + (priority, name.to_owned(), path.to_string_lossy().into_owned()) +} + +fn collect_source_files(dir: &Path) -> Vec { + let Ok(entries) = fs::read_dir(dir) else { + return Vec::new(); + }; + let mut out = Vec::new(); + for entry in entries.filter_map(Result::ok) { + let path = entry.path(); + if path.is_dir() { + out.extend(collect_subdir_files(&path)); + } else if is_script_source_file(&path) { + out.push(path); + } + } + out +} + +#[inline] +fn collect_subdir_files(path: &Path) -> Vec { + let dir_name = path + .file_name() + .and_then(|val| val.to_str()) + .unwrap_or_default(); + if matches!(dir_name, "node_modules" | "dist" | ".git") { + return Vec::new(); + } + collect_source_files(path) +} + +#[inline] +fn is_script_source_file(path: &Path) -> bool { + path.extension() + .and_then(|ext| ext.to_str()) + .is_some_and(|ext| { + let trimmed = ext.trim(); + ["ts", "tsx", "js", "jsx", "mjs", "cjs"] + .iter() + .any(|candidate| trimmed.eq_ignore_ascii_case(candidate)) + }) +} + +pub(in crate::commands) fn parse_scripts_from_source(source: &str) -> EnvScripts { + let Some(obj_inner) = find_scripts_object_body(source) else { + return EnvScripts::default(); + }; + let scripts = split_top_level_entries(obj_inner) + .into_iter() + .filter_map(parse_single_entry) + .collect(); + EnvScripts { has_scripts_method: true, scripts } +} + +fn find_scripts_object_body(source: &str) -> Option<&str> { + for marker in ["Scripts.from(", "new Scripts("] { + if let Some(pos) = source.find(marker) { + let after = &source[pos + marker.len()..]; + let open_offset = after.find('{')?; + return extract_braced_inner(&after[open_offset..]); + } + } + let method_pos = source.find("scripts(")?; + let method_rest = &source[method_pos..]; + let method_open = method_rest.find('{')?; + let method_body = extract_braced_inner(&method_rest[method_open..])?; + let ret_pos = method_body.find("return")?; + let after_ret = &method_body[ret_pos + "return".len()..]; + let obj_open = after_ret.find('{')?; + extract_braced_inner(&after_ret[obj_open..]) +} + +fn extract_braced_inner(text: &str) -> Option<&str> { + let bytes = text.as_bytes(); + if bytes.first().copied() != Some(b'{') { + return None; + } + let mut depth = 0usize; + let mut idx = 0usize; + while idx < bytes.len() { + if let Some(next_idx) = skip_token(bytes, idx) { + idx = next_idx; + continue; + } + if bytes[idx] == b'{' { + depth += 1; + } else if bytes[idx] == b'}' { + depth -= 1; + if depth == 0 { + return Some(&text[1..idx]); + } + } + idx += 1; + } + None +} + +#[inline] +fn skip_token(bytes: &[u8], idx: usize) -> Option { + match bytes[idx] { + b'\'' | b'"' | b'`' => Some(skip_quoted(bytes, idx)), + b'/' if bytes.get(idx + 1) == Some(&b'/') => Some(skip_line_comment(bytes, idx + 2)), + b'/' if bytes.get(idx + 1) == Some(&b'*') => Some(skip_block_comment(bytes, idx + 2)), + _ => None, + } +} + +#[inline] +fn skip_quoted(bytes: &[u8], start: usize) -> usize { + let quote = bytes[start]; + let mut idx = start + 1; + while idx < bytes.len() { + if bytes[idx] == b'\\' { + idx += 2; + } else if bytes[idx] == quote { + return idx + 1; + } else { + idx += 1; + } + } + bytes.len() +} + +#[inline] +fn skip_line_comment(bytes: &[u8], mut idx: usize) -> usize { + while idx < bytes.len() && bytes[idx] != b'\n' { + idx += 1; + } + idx +} + +#[inline] +fn skip_block_comment(bytes: &[u8], mut idx: usize) -> usize { + while idx + 1 < bytes.len() { + if bytes[idx] == b'*' && bytes[idx + 1] == b'/' { + return idx + 2; + } + idx += 1; + } + bytes.len() +} + +fn split_top_level_entries(inner: &str) -> Vec<&str> { + let bytes = inner.as_bytes(); + let mut entries = Vec::new(); + let mut start = 0usize; + let mut depth = 0usize; + let mut idx = 0usize; + while idx < bytes.len() { + if let Some(next_idx) = skip_token(bytes, idx) { + idx = next_idx; + continue; + } + match bytes[idx] { + b'{' | b'(' | b'[' => depth += 1, + b'}' | b')' | b']' => depth = depth.saturating_sub(1), + b',' if depth == 0 => { + push_non_empty(&mut entries, &inner[start..idx]); + start = idx + 1; + } + _ => {} + } + idx += 1; + } + push_non_empty(&mut entries, &inner[start..]); + entries +} + +#[inline] +fn push_non_empty<'a>(entries: &mut Vec<&'a str>, slice: &'a str) { + let trimmed = slice.trim(); + if !trimmed.is_empty() { + entries.push(trimmed); + } +} + +fn parse_single_entry(raw_entry: &str) -> Option { + let cleaned = strip_leading_comments(raw_entry).trim(); + let colon_idx = find_top_level_colon(cleaned)?; + let raw_key = cleaned[..colon_idx].trim(); + let raw_val = cleaned[colon_idx + 1..].trim(); + let unquoted_key = unquote_string(raw_key); + let name = unquoted_key + .as_deref() + .unwrap_or(raw_key) + .trim(); + if name.is_empty() || raw_val.is_empty() { + return None; + } + let handler = match unquote_string(raw_val) { + Some(cmd) => ScriptHandler::Shell(cmd), + None => ScriptHandler::Function(raw_val.to_owned()), + }; + Some(ParsedScript { name: name.to_owned(), handler }) +} + +fn strip_leading_comments(mut text: &str) -> &str { + loop { + text = text.trim_start(); + if let Some(rest) = text.strip_prefix("//") { + text = rest + .find('\n') + .map_or("", |pos| &rest[pos + 1..]); + } else if let Some(rest) = text.strip_prefix("/*") { + text = rest + .find("*/") + .map_or("", |pos| &rest[pos + 2..]); + } else { + return text; + } + } +} + +fn find_top_level_colon(text: &str) -> Option { + let bytes = text.as_bytes(); + let mut depth = 0usize; + let mut idx = 0usize; + while idx < bytes.len() { + if let Some(next_idx) = skip_token(bytes, idx) { + idx = next_idx; + continue; + } + match bytes[idx] { + b'(' | b'[' | b'{' => depth += 1, + b')' | b']' | b'}' => depth = depth.saturating_sub(1), + b':' if depth == 0 => return Some(idx), + _ => {} + } + idx += 1; + } + None +} + +#[inline] +fn unquote_string(raw: &str) -> Option { + let trimmed = raw.trim(); + let first = *trimmed.as_bytes().first()?; + if trimmed.len() < 2 || !matches!(first, b'\'' | b'"' | b'`') { + return None; + } + (skip_quoted(trimmed.as_bytes(), 0) == trimmed.len()).then(|| { + trimmed[1..trimmed.len() - 1].to_owned() + }) +} + +pub(in crate::commands) fn eval_function_logs(body: &str, component_count: usize) -> Vec { + let mut logs = Vec::new(); + let mut rest = body; + let marker = "console.log("; + while let Some(pos) = rest.find(marker) { + let after = &rest[pos + marker.len()..]; + let Some(close_pos) = find_closing_paren(after) else { + break; + }; + if let Some(rendered) = render_log_arg(after[..close_pos].trim(), component_count) { + logs.push(rendered); + } + rest = &after[close_pos + 1..]; + } + logs +} + +fn find_closing_paren(text: &str) -> Option { + let bytes = text.as_bytes(); + let mut depth = 1usize; + let mut idx = 0usize; + while idx < bytes.len() { + if let Some(next_idx) = skip_token(bytes, idx) { + idx = next_idx; + continue; + } + if bytes[idx] == b'(' { + depth += 1; + } else if bytes[idx] == b')' { + depth -= 1; + if depth == 0 { + return Some(idx); + } + } + idx += 1; + } + None +} + +fn render_log_arg(arg: &str, component_count: usize) -> Option { + let inner = unquote_string(arg)?; + let mut out = String::with_capacity(inner.len()); + let mut rest = inner.as_str(); + while let Some(start) = rest.find("${") { + out.push_str(&rest[..start]); + let after = &rest[start + 2..]; + let Some(end) = after.find('}') else { + out.push_str(&rest[start..]); + return Some(out); + }; + out.push_str(&eval_template_expr(after[..end].trim(), component_count)); + rest = &after[end + 1..]; + } + out.push_str(rest); + Some(out) +} + +#[inline] +fn eval_template_expr(expr: &str, component_count: usize) -> String { + let trimmed = expr.trim(); + if trimmed.contains("components") && trimmed.contains("length") { + component_count.to_string() + } else { + trimmed.to_owned() + } +} diff --git a/native/cli/src/commands/script/runner.rs b/native/cli/src/commands/script/runner.rs new file mode 100644 index 000000000000..8d43da79f877 --- /dev/null +++ b/native/cli/src/commands/script/runner.rs @@ -0,0 +1,333 @@ +//! Workspace environment resolution, script listing, and script execution. +//! +//! Resolves [`WorkspaceComponent`] environments ([`ScriptFailure`]) and executes [`ScriptHandler`] entries for [`Options`]. + +use super::{ + Options, + parser::{EnvScripts, ScriptHandler, eval_function_logs, parse_env_scripts_in_dir}, +}; +use crate::commands::color::Chalk; +use bit_install::{ + config::{ENVS_ASPECT, WorkspaceConfig, aspect_id_without_version}, + model::WorkspaceComponent, + workspace::{CORE_ENVS, DEFAULT_ENV, Workspace}, +}; +use bit_object_import::parse_jsonc; +use serde_json::Value; +use std::{fs, io, path::Path, process::Command}; + +/// Embedded JSON array of built-in core aspect IDs. +const CORE_ASPECT_IDS: &str = + include_str!("../../../../../scopes/harmony/testing/load-aspect/core-aspects-ids.json"); + +/// Failure classification for `bit script` distinguishing user errors from declined fallback cases. +pub(super) enum ScriptFailure { + User(String), + Declined(String), +} + +/// Workspace components grouped under a single resolved environment ID and its [`EnvScripts`]. +struct EnvGroup<'a> { + env_id: String, + components: Vec<&'a WorkspaceComponent>, + env_scripts: EnvScripts, +} + +pub(in crate::commands) fn resolve_workspace_components( + root: &Path, +) -> io::Result<(WorkspaceConfig, Workspace)> { + let ws_config = WorkspaceConfig::load(root)?; + let core_ids: Vec = serde_json::from_str(CORE_ASPECT_IDS).map_err(io::Error::other)?; + let mut ws = Workspace::load(root, &ws_config, &core_ids)?; + promote_bit_env_plugin_components(&mut ws, &core_ids); + Ok((ws_config, ws)) +} + +#[inline] +fn promote_bit_env_plugin_components(ws: &mut Workspace, core_ids: &[String]) { + let plugin_envs = collect_plugin_env_ids(ws); + if plugin_envs.is_empty() { + return; + } + for comp in &mut ws.components { + assign_plugin_env_to_comp(comp, &plugin_envs, core_ids); + } +} + +fn collect_plugin_env_ids(ws: &mut Workspace) -> Vec { + let mut plugin_envs = Vec::new(); + for comp in &mut ws.components { + if comp.files + .iter() + .any(|file| file.contains(".bit-env.")) + { + comp.is_env = true; + } + if comp.is_env { + plugin_envs.push(comp.id.to_string_without_version()); + } + } + plugin_envs +} + +fn assign_plugin_env_to_comp( + comp: &mut WorkspaceComponent, + plugin_envs: &[String], + core_ids: &[String], +) { + if comp.env_id != DEFAULT_ENV || has_explicit_env(comp) { + return; + } + let Some(matched_key) = comp.config + .iter() + .find(|(key, val)| { + val.as_str() != Some("-") && is_matching_plugin_env(key, plugin_envs, core_ids) + }) + .map(|(key, _)| key.clone()) + else { + return; + }; + comp.env_id = matched_key; +} + +#[inline] +fn is_matching_plugin_env(key: &str, plugin_envs: &[String], core_ids: &[String]) -> bool { + let without = aspect_id_without_version(key); + without != ENVS_ASPECT + && plugin_envs + .iter() + .any(|env_id| env_id == without) + && !core_ids + .iter() + .any(|core| core == without) +} + +#[inline] +fn has_explicit_env(comp: &WorkspaceComponent) -> bool { + comp.config.contains_key(DEFAULT_ENV) + || comp.config + .get(ENVS_ASPECT) + .and_then(|cfg| cfg.get("env")) + .is_some() +} + +pub(super) fn read_allowed_envs(ws_jsonc: &Value) -> Vec { + let Some(obj) = ws_jsonc.as_object() else { + return Vec::new(); + }; + let scripts_val = obj + .iter() + .find(|(key, _)| aspect_id_without_version(key) == "teambit.workspace/scripts") + .map(|(_, val)| val); + let Some(arr) = scripts_val.and_then(|val| val["envs"].as_array()) else { + return Vec::new(); + }; + let mut allowed = Vec::with_capacity(arr.len()); + for env_id in arr + .iter() + .filter_map(Value::as_str) + .map(str::trim) + .filter(|text| !text.is_empty()) + { + if !allowed + .iter() + .any(|existing| existing == env_id) + { + allowed.push(env_id.to_owned()); + } + } + allowed +} + +#[inline] +pub(super) fn is_env_allowed(env_id: &str, allowed_envs: &[String]) -> bool { + let env_id = env_id.trim().trim_matches('/'); + if env_id.is_empty() { + return false; + } + let bare_env = aspect_id_without_version(env_id); + allowed_envs + .iter() + .any(|allowed| { + let allowed = allowed.trim().trim_matches('/'); + if allowed.is_empty() { + return false; + } + if env_id == allowed { + return true; + } + !allowed.contains('@') && bare_env == allowed + }) +} + +pub(super) fn execute( + root: &Path, + options: &Options, + chalk: Chalk, +) -> Result { + let ws_bytes = fs::read(root.join("workspace.jsonc")) + .map_err(|err| ScriptFailure::Declined(err.to_string()))?; + let ws_jsonc = parse_jsonc(&ws_bytes).map_err(|err| ScriptFailure::Declined(err.to_string()))?; + let allowed_envs = read_allowed_envs(&ws_jsonc); + let (_, ws) = + resolve_workspace_components(root).map_err(|err| ScriptFailure::Declined(err.to_string()))?; + let groups = build_env_groups(&ws, &allowed_envs)?; + if options.list { + return Ok(render_list(&groups, chalk)); + } + let Some(script_name) = options.script_name.as_deref() else { + return Err(ScriptFailure::User( + "script name is required. Use --list to see available scripts.".into(), + )); + }; + run_named_script(root, &groups, script_name, chalk) +} + +fn build_env_groups<'a>( + ws: &'a Workspace, + allowed_envs: &[String], +) -> Result>, ScriptFailure> { + let mut grouped: Vec<(String, Vec<&'a WorkspaceComponent>)> = Vec::new(); + for (_, comp) in ws.active() { + if !is_env_allowed(&comp.env_id, allowed_envs) { + continue; + } + if let Some((_, list)) = grouped + .iter_mut() + .find(|(env_id, _)| *env_id == comp.env_id) + { + list.push(comp); + } else { + grouped.push((comp.env_id.clone(), vec![comp])); + } + } + let mut out = Vec::with_capacity(grouped.len()); + for (env_id, components) in grouped { + let bare = aspect_id_without_version(&env_id); + if CORE_ENVS.contains(&bare) { + continue; + } + let Some(env_comp) = find_workspace_env_component(ws, bare) else { + return Err(ScriptFailure::Declined(format!("external env {env_id}"))); + }; + let env_scripts = parse_env_scripts_in_dir(&env_comp.dir); + out.push(EnvGroup { env_id, components, env_scripts }); + } + Ok(out) +} + +#[inline] +pub(in crate::commands) fn find_workspace_env_component<'a>( + ws: &'a Workspace, + bare_env_id: &str, +) -> Option<&'a WorkspaceComponent> { + ws.entries + .iter() + .zip(&ws.components) + .find(|(entry, comp)| { + entry.owns_its_dir() + && (comp.id.to_string_without_version() == bare_env_id + || comp.id.name == bare_env_id) + }) + .map(|(_, comp)| comp) +} + +fn render_list(groups: &[EnvGroup<'_>], chalk: Chalk) -> String { + let mut results = Vec::new(); + for group in groups { + if group.env_scripts.scripts.is_empty() { + continue; + } + if results.is_empty() { + results.push(chalk.green("Available scripts:\n")); + } + results.extend(format_group_scripts(group, chalk)); + } + if results.is_empty() { + return chalk.yellow("no scripts defined in the configured environments"); + } + results.join("\n") +} + +fn format_group_scripts(group: &EnvGroup<'_>, chalk: Chalk) -> Vec { + let mut lines = Vec::with_capacity(group.env_scripts.scripts.len() + 2); + lines.push(chalk.cyan(&format!("\nEnvironment: {}", group.env_id))); + lines.push(chalk.gray(&format!(" (used by {} component(s))", group.components.len()))); + for script in &group.env_scripts.scripts { + let handler_str = match &script.handler { + ScriptHandler::Function(_) => chalk.gray("[function]"), + ScriptHandler::Shell(cmd) => chalk.white(cmd), + }; + lines.push(format!(" {}: {handler_str}", chalk.bold(&script.name))); + } + lines +} + +fn run_named_script( + root: &Path, + groups: &[EnvGroup<'_>], + script_name: &str, + chalk: Chalk, +) -> Result { + let script_name = script_name.trim(); + let mut results = Vec::with_capacity(groups.len() * 2); + for group in groups { + if !group.env_scripts.has_scripts_method { + continue; + } + let Some(script) = group.env_scripts.scripts + .iter() + .find(|item| item.name == script_name) + else { + return Err(ScriptFailure::User(format!( + r#"script "{script_name}" was not found in environment "{}""#, + group.env_id, + ))); + }; + let title = chalk.green(&format!( + "\nRunning script \"{script_name}\" for {} component(s) with env {}:", + group.components.len(), + group.env_id, + )); + results.push(title); + let result = match &script.handler { + ScriptHandler::Shell(cmd) => execute_shell_command(root, cmd, chalk), + ScriptHandler::Function(body) => { + execute_function_body(body, group.components.len(), chalk) + } + }; + results.push(result); + } + Ok(results.join("\n")) +} + +fn execute_shell_command(root: &Path, command_str: &str, chalk: Chalk) -> String { + let mut cmd = if cfg!(windows) { + let mut command = Command::new("cmd"); + command.args(["/C", command_str]); + command + } else { + let mut command = Command::new("sh"); + command.args(["-c", command_str]); + command + }; + match cmd.current_dir(root).output() { + Ok(output) if output.status.success() => { + let stdout = String::from_utf8_lossy(&output.stdout); + chalk.white(&stdout) + } + Ok(output) => { + let stderr = String::from_utf8_lossy(&output.stderr); + let err_msg = if stderr.trim().is_empty() { "unknown error" } else { stderr.as_ref() }; + chalk.red(&format!("Error executing script: {err_msg}")) + } + Err(err) => chalk.red(&format!("Error executing script: {err}")), + } +} + +#[inline] +fn execute_function_body(body: &str, component_count: usize, chalk: Chalk) -> String { + let logs = eval_function_logs(body, component_count); + let success = chalk.green("✓ Script function executed successfully"); + if logs.is_empty() { success } else { format!("{}\n{success}", logs.join("\n")) } +} diff --git a/native/cli/src/commands/script/tests.rs b/native/cli/src/commands/script/tests.rs new file mode 100644 index 000000000000..5d62200c0c1f --- /dev/null +++ b/native/cli/src/commands/script/tests.rs @@ -0,0 +1,181 @@ +//! Unit tests for `bit script` [`Options`] CLI parsing ([`parse_options`]), `.bit-env.ts` [`ParsedScript`] extraction ([`parse_scripts_from_source`]), and [`execute`] execution. +//! +//! Verifies [`fast_workspace_message`], [`ScriptHandler`], [`eval_function_logs`], and [`is_env_allowed`] with [`Chalk`]. + +use super::{ + Options, fast_workspace_message, parse_options, + parser::{ParsedScript, ScriptHandler, eval_function_logs, parse_scripts_from_source}, + runner::{execute, is_env_allowed}, +}; +use crate::commands::color::Chalk; +use std::fs; + +/// Verifies `bit script` [`Options`] flag and script-name parsing via [`parse_options`]. +#[test] +fn parses_script_options() { + assert_eq!(parse_options(&["-l"]), Some(Options { script_name: None, list: true })); + assert_eq!( + parse_options(&["build-svg"]), + Some(Options { script_name: Some("build-svg".into()), list: false }), + ); + assert_eq!(parse_options(&[]), Some(Options { script_name: None, list: false })); +} + +/// Verifies [`fast_workspace_message`] for workspaces with unconfigured script envs or zero tracked components. +#[test] +fn detects_unconfigured_envs_and_empty_workspace() { + let temp = tempfile::tempdir().unwrap(); + fs::write( + temp.path().join("workspace.jsonc"), + r#"{ "teambit.workspace/workspace": { "defaultScope": "my-scope" } }"#, + ) + .unwrap(); + fs::write(temp.path().join(".bitmap"), "{}").unwrap(); + + let msg = fast_workspace_message(temp.path(), Chalk::new(false)).unwrap().unwrap(); + assert!(msg.contains("no envs configured"), "{msg}"); + + fs::write( + temp.path().join("workspace.jsonc"), + r#"{ + "teambit.workspace/workspace": { "defaultScope": "my-scope" }, + "teambit.workspace/scripts": { "envs": ["my-scope/my-env"] } + }"#, + ) + .unwrap(); + let empty_msg = fast_workspace_message(temp.path(), Chalk::new(false)).unwrap().unwrap(); + assert_eq!(empty_msg, "no components found"); +} + +#[test] +fn parses_scripts_from_bit_env_source_and_evaluates_logs() { + let src = r" +import { Scripts } from '@teambit/scripts'; +export class EnvWithScripts { + name = 'env-with-scripts'; + scripts() { + return Scripts.from({ + 'test-script': 'echo hello from script', + 'another-script': 'echo another output', + 'async-script': async (context) => { + return new Promise((resolve) => { + setTimeout(() => { + console.log('async script executed'); + console.log(`executed for ${context?.components.length || 0} component(s)`); + resolve(); + }, 100); + }); + }, + }); + } +} +export default new EnvWithScripts(); +"; + let parsed = parse_scripts_from_source(src); + assert!(parsed.has_scripts_method); + assert_eq!(parsed.scripts.len(), 3); + assert_eq!( + parsed.scripts[0], + ParsedScript { + name: "test-script".into(), + handler: ScriptHandler::Shell("echo hello from script".into()), + }, + ); + assert_eq!( + parsed.scripts[1], + ParsedScript { + name: "another-script".into(), + handler: ScriptHandler::Shell("echo another output".into()), + }, + ); + let ScriptHandler::Function(body) = &parsed.scripts[2].handler else { + panic!("expected function handler"); + }; + let logs = eval_function_logs(body, 3); + assert_eq!(logs, vec!["async script executed", "executed for 3 component(s)"]); +} + +#[test] +fn checks_env_version_matching() { + let unversioned = vec!["my-scope/my-env".to_owned()]; + assert!(is_env_allowed("my-scope/my-env", &unversioned)); + assert!(is_env_allowed("my-scope/my-env@0.0.1", &unversioned)); + + let versioned = vec!["my-scope/my-env@0.0.1".to_owned()]; + assert!(is_env_allowed("my-scope/my-env@0.0.1", &versioned)); + assert!(!is_env_allowed("my-scope/my-env", &versioned)); + assert!(!is_env_allowed("my-scope/my-env@0.0.2", &versioned)); +} + +#[test] +fn executes_list_and_run_in_workspace() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path(); + fs::create_dir_all(root.join("env-with-scripts")).unwrap(); + fs::create_dir_all(root.join("comp1")).unwrap(); + fs::write( + root.join("env-with-scripts/env-with-scripts.bit-env.ts"), + "import { Scripts } from '@teambit/scripts';\n\ + export class EnvWithScripts {\n\ + scripts() {\n\ + return Scripts.from({\n\ + 'test-script': 'echo hello from script',\n\ + });\n\ + }\n\ + }\n", + ) + .unwrap(); + fs::write(root.join("comp1/index.js"), "module.exports = () => 'hi';\n").unwrap(); + fs::write( + root.join("workspace.jsonc"), + r#"{ + "teambit.workspace/workspace": { "defaultScope": "my-scope" }, + "teambit.workspace/scripts": { "envs": ["my-scope/env-with-scripts"] }, + "teambit.workspace/variants": { + "env-with-scripts": { "teambit.envs/env": {} }, + "comp1": { "my-scope/env-with-scripts": {} } + } + }"#, + ) + .unwrap(); + fs::write( + root.join(".bitmap"), + r#"{ + "env-with-scripts": { + "scope": "", + "version": "", + "mainFile": "env-with-scripts.bit-env.ts", + "rootDir": "env-with-scripts" + }, + "comp1": { + "scope": "", + "version": "", + "mainFile": "index.js", + "rootDir": "comp1" + } + }"#, + ) + .unwrap(); + + let list_out = + execute(root, &Options { script_name: None, list: true }, Chalk::new(false)).ok().unwrap(); + assert!(list_out.contains("Available scripts"), "{list_out}"); + assert!(list_out.contains("my-scope/env-with-scripts"), "{list_out}"); + assert!(list_out.contains("test-script: echo hello from script"), "{list_out}"); + + let run_out = execute( + root, + &Options { script_name: Some("test-script".into()), list: false }, + Chalk::new(false), + ) + .ok() + .unwrap(); + assert!(run_out.contains(r#"Running script "test-script""#), "{run_out}"); + assert!(run_out.contains("hello from script"), "{run_out}"); +} + +#[test] +fn rejects_multiple_script_names_or_unknown_flags() { + assert_eq!(parse_options(&["a", "b"]), None); + assert_eq!(parse_options(&["--unknown"]), None); +} diff --git a/native/cli/src/commands/search.rs b/native/cli/src/commands/search.rs new file mode 100644 index 000000000000..b3c61142895f --- /dev/null +++ b/native/cli/src/commands/search.rs @@ -0,0 +1,226 @@ +//! Native `bit search` command (`scopes/component/lister/search.cmd.ts`). +//! +//! Executes [`COMMAND`] local or remote component search queries ([`SearchArgs`]) and formats output via [`Formatter`]. + +#[cfg(test)] +mod tests; + +use crate::{ + commands::{Command, color::Chalk, workspace_command::format::Formatter}, + native::Outcome, + workspace, +}; +use bit_install::workspace::Bitmap; +use bit_workspace_config::jsonc::Document; +use serde_json::json; +use std::{ + collections::BTreeSet, + env, + ffi::OsString, + fs, + io::{self, Write}, + path::Path, + process::ExitCode, +}; + +/// Descriptor for the native `bit search` command. +pub const COMMAND: Command = Command { name: "search", aliases: &[], run }; + +/// Parsed CLI flags and query strings for `bit search`. +struct SearchArgs { + queries: Vec, + local_only: bool, + remote_only: bool, + json: bool, +} + +/// Entry point for `bit search`. +fn run(args: &[OsString]) -> io::Result { + let Some(parsed) = parse_args(args) else { + return Ok(Outcome::Declined("arguments outside the native `search` grammar".to_owned())); + }; + if parsed.queries.is_empty() { + writeln!(io::stderr(), "error: search requires at least one query")?; + return Ok(Outcome::Exit(ExitCode::FAILURE)); + } + if parsed.local_only && parsed.remote_only { + writeln!(io::stderr(), "error: --local-only and --remote-only cannot be used together")?; + return Ok(Outcome::Exit(ExitCode::FAILURE)); + } + if !parsed.local_only { + return Ok(Outcome::Declined("remote search requires cloud".to_owned())); + } + let Some(root) = workspace::root(&env::current_dir()?) else { + writeln!( + io::stderr(), + "error: --local-only requires a workspace. Run without --local-only to search remote only.", + )?; + return Ok(Outcome::Exit(ExitCode::FAILURE)); + }; + execute_local_search(&root, args, &parsed) +} + +fn execute_local_search( + root: &Path, + args: &[OsString], + parsed: &SearchArgs, +) -> io::Result { + let chalk = Chalk::detect(args); + let ids = load_workspace_ids(root); + let (all_matches, per_query) = match_queries(&ids, &parsed.queries); + let mut stdout = io::stdout().lock(); + if parsed.json { + let output = json!({ + "remote": [], + "local": all_matches, + "perQuery": per_query, + "hasWorkspace": true, + }); + writeln!(stdout, "{output}")?; + } else { + let formatter = Formatter::new(chalk); + let header = formatter.title(&format!("Local ({})", all_matches.len())); + let body = if all_matches.is_empty() { + formatter.hint("no matches in workspace") + } else { + all_matches.join("\n") + }; + writeln!(stdout, "{header}\n{body}")?; + } + stdout.flush()?; + Ok(Outcome::Exit(ExitCode::SUCCESS)) +} + +fn match_queries(ids: &[String], queries: &[String]) -> (Vec, Vec) { + let mut all_set = BTreeSet::new(); + let unique_queries = deduplicate_preserve_order(queries); + let mut per_query = Vec::with_capacity(unique_queries.len()); + let lowered_ids: Vec = ids + .iter() + .map(|id| id.to_lowercase()) + .collect(); + + for query in unique_queries { + let lower = query.to_lowercase(); + let matches: Vec = ids + .iter() + .zip(&lowered_ids) + .filter(|(_, lowered_id)| lowered_id.contains(&lower)) + .map(|(id, _)| id.clone()) + .collect(); + for matched in &matches { + all_set.insert(matched.clone()); + } + per_query.push(json!({ + "query": query, + "remoteCount": 0, + "localCount": matches.len(), + "error": null, + "localMatches": matches, + "remoteComponents": [], + })); + } + let sorted_all: Vec = all_set.into_iter().collect(); + (sorted_all, per_query) +} + +#[inline] +fn deduplicate_preserve_order(items: &[String]) -> Vec<&str> { + let mut seen = std::collections::HashSet::with_capacity(items.len()); + let mut result = Vec::with_capacity(items.len()); + for item in items { + let trimmed = item.trim(); + if !trimmed.is_empty() && seen.insert(trimmed) { + result.push(trimmed); + } + } + result +} + +#[inline] +fn load_workspace_ids(root: &Path) -> Vec { + let default_scope = workspace_default_scope(root); + let Ok(bitmap) = Bitmap::load(root, default_scope.as_deref()) else { + return Vec::new(); + }; + bitmap.entries + .iter() + .filter(|entry| entry.owns_its_dir()) + .map(|entry| entry.id.to_string_without_version()) + .collect() +} + +#[inline] +fn workspace_default_scope(root: &Path) -> Option { + let content = fs::read_to_string(root.join("workspace.jsonc")).ok()?; + let doc = Document::parse(&content).ok()?; + let val = doc.value(); + val.get("defaultScope") + .or_else(|| { + super::update_cmd::aspect_entry(&val, "teambit.workspace/workspace") + .and_then(|(_, ws)| ws.get("defaultScope")) + }) + .and_then(|scope| scope.as_str()) + .map(|scope| scope.trim().trim_matches('/')) + .filter(|scope| !scope.is_empty()) + .map(ToOwned::to_owned) +} + +fn parse_args(args: &[OsString]) -> Option { + let mut parsed = + SearchArgs { queries: Vec::new(), local_only: false, remote_only: false, json: false }; + let mut iter = args.iter().skip(1); + while let Some(arg) = iter.next() { + let str_arg = arg.to_str()?; + if str_arg == "--" { + consume_trailing_search_queries(iter, &mut parsed.queries)?; + break; + } + apply_search_arg(str_arg, &mut parsed)?; + } + Some(parsed) +} + +#[inline] +fn apply_search_arg(str_arg: &str, parsed: &mut SearchArgs) -> Option<()> { + match str_arg { + "--no-color" | "--no-color=true" | "--no-color=false" | "--color" | "--color=true" + | "--color=false" | "--color=always" | "--color=never" => {} + "--local-only" | "-l" | "--local-only=true" => parsed.local_only = true, + "--local-only=false" => parsed.local_only = false, + "--remote-only" | "-r" | "--remote-only=true" => parsed.remote_only = true, + "--remote-only=false" => parsed.remote_only = false, + "--json" | "-j" | "--json=true" => parsed.json = true, + "--json=false" => parsed.json = false, + "-lj" | "-jl" => (parsed.local_only, parsed.json) = (true, true), + "-rj" | "-jr" => (parsed.remote_only, parsed.json) = (true, true), + "-lr" | "-rl" => (parsed.local_only, parsed.remote_only) = (true, true), + "-ljr" | "-lrj" | "-jlr" | "-jrl" | "-rlj" | "-rjl" => { + (parsed.local_only, parsed.remote_only, parsed.json) = (true, true, true); + } + query if query.starts_with('-') => return None, + query => { + let trimmed = query.trim(); + if !trimmed.is_empty() { + parsed.queries.push(trimmed.to_owned()); + } + } + } + Some(()) +} + +#[inline] +fn consume_trailing_search_queries<'a>( + iter: impl Iterator, + queries: &mut Vec, +) -> Option<()> { + for arg in iter { + let query = arg.to_str()?; + (!query.starts_with('-')).then_some(())?; + let trimmed = query.trim(); + if !trimmed.is_empty() { + queries.push(trimmed.to_owned()); + } + } + Some(()) +} diff --git a/native/cli/src/commands/search/tests.rs b/native/cli/src/commands/search/tests.rs new file mode 100644 index 000000000000..6ea6af47b465 --- /dev/null +++ b/native/cli/src/commands/search/tests.rs @@ -0,0 +1,58 @@ +//! Unit tests for `bit search` CLI flag parsing ([`parse_args`]) and case-insensitive local query matching ([`match_queries`]). +//! +//! Verifies `--local-only`, `--remote-only`, and `--json` flags and multi-term search queries. + +use super::{match_queries, parse_args}; +use serde_json::json; +use std::ffi::OsString; + +/// Converts string slice tokens into [`OsString`] vectors for [`parse_args`]. +fn os_args(args: &[&str]) -> Vec { + args.iter() + .map(OsString::from) + .collect() +} + +/// Verifies `bit search` CLI flag parsing for `--local-only` and `--json`. +#[test] +fn parses_search_flags() { + let parsed = parse_args(&os_args(&["search", "foo", "bar", "--local-only", "--json"])).unwrap(); + assert_eq!(parsed.queries, vec!["foo", "bar"]); + assert!(parsed.local_only); + assert!(!parsed.remote_only); + assert!(parsed.json); +} + +#[test] +fn parses_short_flags() { + let parsed = parse_args(&os_args(&["search", "foo", "-l", "-j"])).unwrap(); + assert_eq!(parsed.queries, vec!["foo"]); + assert!(parsed.local_only); + assert!(parsed.json); + let combined = parse_args(&os_args(&["search", "foo", "-lj", "--remote-only=false"])).unwrap(); + assert!(combined.local_only && combined.json && !combined.remote_only); +} + +#[test] +fn rejects_unsupported_flags() { + assert!(parse_args(&os_args(&["search", "--owners", "my-org"])).is_none()); + assert!(parse_args(&os_args(&["search", "--invalid"])).is_none()); +} + +#[test] +fn matches_queries_case_insensitively_and_deduplicates() { + let ids = vec!["my-scope/bar/foo".to_owned(), "my-scope/utils/is-type".to_owned()]; + let (all, per_query) = match_queries(&ids, &["FOO".to_owned(), "bar".to_owned()]); + assert_eq!(all, vec!["my-scope/bar/foo".to_owned()]); + assert_eq!(per_query.len(), 2); + assert_eq!(per_query[0]["localCount"], json!(1)); + assert_eq!(per_query[1]["localCount"], json!(1)); +} + +#[test] +fn unmatched_search_query_reports_zero_local_count() { + let ids = vec!["my-scope/bar/foo".to_owned()]; + let (all, per_query) = match_queries(&ids, &["nonexistent".to_owned()]); + assert!(all.is_empty()); + assert_eq!(per_query[0]["localCount"], json!(0)); +} diff --git a/native/cli/src/commands/server_cmd.rs b/native/cli/src/commands/server_cmd.rs new file mode 100644 index 000000000000..51748f9c2348 --- /dev/null +++ b/native/cli/src/commands/server_cmd.rs @@ -0,0 +1,100 @@ +//! Native `bit server` (`scopes/harmony/bit/server-cmd.ts`). +//! +//! Validates [`Options`] workspace preconditions for [`COMMAND`] (`bit server`) before delegating or reporting into [`Outcome`]. + +#[cfg(test)] +mod tests; + +use super::{Command, workspace_command}; +use crate::{native::Outcome, workspace}; +use std::{env, ffi::OsString, io}; + +/// Descriptor for the native `bit server` command. +pub const COMMAND: Command = Command { name: "server", aliases: &[], run }; + +/// Parsed command-line options for `bit server`. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub(super) struct Options { + pub port: Option, + pub compile: bool, +} + +/// Entry point for `bit server`. +fn run(args: &[OsString]) -> io::Result { + let Some(argv) = workspace_command::utf8_args(args) else { + return Ok(Outcome::Declined("server arguments are not UTF-8".into())); + }; + if parse_options(&argv[1..]).is_none() { + return Ok(Outcome::Declined("outside native server grammar".into())); + } + if !env::var_os("CI") + .is_some_and(|val| { + let text = val.to_string_lossy(); + let trimmed = text + .trim() + .trim_matches(['"', '\'']) + .trim(); + !trimmed.is_empty() + && !["0", "false", "no", "off"] + .iter() + .any(|falsy| trimmed.eq_ignore_ascii_case(falsy)) + }) + { + let cwd = env::current_dir()?; + if workspace::root(&cwd).is_none() { + let msg = format!( + "unable to run bit-server, the current directory {} is not a workspace", + cwd.display(), + ); + return workspace_command::execute_failure_scope( + &cwd, + args, + "server", + false, + "", + io::Error::other(msg), + ); + } + } + Ok(Outcome::Declined("server execution delegated".into())) +} + +#[inline] +fn parse_nonzero_port(raw: &str) -> Option { + raw.trim() + .parse::() + .ok() + .filter(|&port| port >= 1) +} + +pub(super) fn parse_options(args: &[&str]) -> Option { + let mut options = Options::default(); + let mut idx = 0; + while idx < args.len() { + let arg = args[idx]; + match arg { + "--" => return (idx + 1 == args.len()).then_some(options), + "--no-color" | "--no-color=true" | "--no-color=false" | "--color" | "--color=true" + | "--color=false" | "--color=always" | "--color=never" => {} + "-c" | "--compile" | "--compile=true" => options.compile = true, + "--compile=false" | "--no-compile" => options.compile = false, + "-p" | "--port" => { + if let Some(&next) = args.get(idx + 1) + && !next.starts_with('-') + { + options.port = Some(parse_nonzero_port(next)?); + idx += 1; + } + } + val if let Some(port) = val + .strip_prefix("--port=") + .or_else(|| val.strip_prefix("-p=")) => + { + options.port = Some(parse_nonzero_port(port)?); + } + _ => return None, + } + idx += 1; + } + Some(options) +} diff --git a/native/cli/src/commands/server_cmd/tests.rs b/native/cli/src/commands/server_cmd/tests.rs new file mode 100644 index 000000000000..4acdc075af3a --- /dev/null +++ b/native/cli/src/commands/server_cmd/tests.rs @@ -0,0 +1,25 @@ +//! Unit tests for `bit server` [`Options`] port and compile CLI option parsing ([`parse_options`]). +//! +//! Verifies `-p` / `--port` numeric validation and `--compile` boolean flag handling. + +use super::{Options, parse_options}; + +/// Verifies `bit server` [`Options`] port and `--compile` flag parsing via [`parse_options`]. +#[test] +fn parses_and_validates_server_port() { + assert_eq!(parse_options(&[]), Some(Options { port: None, compile: false })); + assert_eq!( + parse_options(&["-p", "3000", "--compile"]), + Some(Options { port: Some(3000), compile: true }), + ); + assert_eq!(parse_options(&["--port=invalid"]), None); +} + +/// Verifies inline `--port=` and `--compile=false` flag parsing via [`parse_options`]. +#[test] +fn parses_inline_port_and_compile_false_flag() { + assert_eq!( + parse_options(&["--port=4000", "--compile=false"]), + Some(Options { port: Some(4000), compile: false }), + ); +} diff --git a/native/cli/src/commands/set_peer.rs b/native/cli/src/commands/set_peer.rs new file mode 100644 index 000000000000..a10ffa6a316d --- /dev/null +++ b/native/cli/src/commands/set_peer.rs @@ -0,0 +1,357 @@ +//! Native `bit set-peer ` and `bit unset-peer ` (`scopes/dependencies/dependencies/dependencies-cmd.ts`). +//! +//! Resolves component entries ([`SET_PEER`], [`UNSET_PEER`], [`resolve_component_entry`], [`find_bitmap_row_mut`]) and updates peer dependency configuration. + +#[cfg(test)] +mod tests; + +use super::{ + Command, + aspect::{find_bitmap_row_mut, find_bitmap_row_ref, is_supported_pattern}, + color::Chalk, + debug_cat::lookup::{load_cat_component, resolve_component_entry}, + deps::{BitmapMutation, apply_dep_resolver_config}, + lane::scope_root::ScopeRoot, + scope_set::{Failure, selection}, + workspace_command::{self, format::Formatter, results::Report}, +}; +use crate::{native::Outcome, workspace}; +use bit_install::workspace::BitmapEntry; +use serde_json::{Map, Value, json}; +use std::{env, ffi::OsString, fs, io, path::Path}; + +/// Descriptor for the native `bit set-peer` command. +pub const SET_PEER: Command = Command { name: "set-peer", aliases: &[], run: run_set }; +/// Descriptor for the native `bit unset-peer` command. +pub const UNSET_PEER: Command = Command { name: "unset-peer", aliases: &[], run: run_unset }; + +/// Aspect ID for the dependency resolver configuration entry. +pub(in crate::commands) const DEP_RESOLVER_ID: &str = "teambit.dependencies/dependency-resolver"; + +/// Parsed `set-peer` or `unset-peer` action and target component selector. +#[derive(Debug, Clone, PartialEq, Eq)] +enum PeerAction { + Set { component_id: String, range: String }, + Unset { component_id: String }, +} + +struct Prepared { + mutation: BitmapMutation, + summary: &'static str, +} + +fn run_set(args: &[OsString]) -> io::Result { + let Some(argv) = workspace_command::utf8_args(args) else { + return Ok(Outcome::Declined("set-peer arguments are not UTF-8".into())); + }; + let Some(action) = parse_set_args(&argv[1..]) else { + return Ok(Outcome::Declined("outside native set-peer grammar".into())); + }; + run_action(args, "set-peer", &action) +} + +fn run_unset(args: &[OsString]) -> io::Result { + let Some(argv) = workspace_command::utf8_args(args) else { + return Ok(Outcome::Declined("unset-peer arguments are not UTF-8".into())); + }; + let Some(action) = parse_unset_args(&argv[1..]) else { + return Ok(Outcome::Declined("outside native unset-peer grammar".into())); + }; + run_action(args, "unset-peer", &action) +} + +#[inline] +fn is_ci_active() -> bool { + env::var_os("CI") + .is_some_and(|value| { + let text = value.to_string_lossy(); + let trimmed = text + .trim() + .trim_matches(['"', '\'']) + .trim(); + !trimmed.is_empty() + && !["0", "false", "no", "off"] + .iter() + .any(|falsy| trimmed.eq_ignore_ascii_case(falsy)) + }) +} + +fn run_action(args: &[OsString], label: &'static str, action: &PeerAction) -> io::Result { + let cwd = env::current_dir()?; + let Some(root) = workspace::root(&cwd) else { + if is_ci_active() { + return Ok(Outcome::Declined(format!("CI {label} outside workspace"))); + } + return workspace_command::execute_failure_scope( + &cwd, + args, + label, + false, + "", + io::Error::other("Cannot read properties of undefined (reading 'resolveComponentId')"), + ); + }; + let chalk = Chalk::detect(args); + match prepare(&root, action, chalk) { + Ok(prepared) => { + workspace_command::execute_then(&root, args, label, |_output| prepared.commit(chalk)) + } + Err(Failure::User(message)) => { + if is_ci_active() { + return Ok(Outcome::Declined(format!("CI {label} error stack"))); + } + workspace_command::execute_then(&root, args, label, |_output| { + Err(io::Error::other(message)) + }) + } + Err(Failure::Declined(reason)) => Ok(Outcome::Declined(format!("{label}: {reason}"))), + } +} + +#[inline] +fn parse_set_args(args: &[&str]) -> Option { + let filtered = super::scope_set::collect_non_flag_tokens(args)?; + let [component_id, range] = filtered.as_slice() else { + return None; + }; + if !is_single_component_arg(component_id) || !is_safe_range_arg(range) { + return None; + } + Some(PeerAction::Set { component_id: (*component_id).to_owned(), range: (*range).to_owned() }) +} + +#[inline] +fn parse_unset_args(args: &[&str]) -> Option { + let filtered = super::scope_set::collect_non_flag_tokens(args)?; + let [component_id] = filtered.as_slice() else { + return None; + }; + if !is_single_component_arg(component_id) { + return None; + } + Some(PeerAction::Unset { component_id: (*component_id).to_owned() }) +} + +#[inline] +fn is_single_component_arg(id: &str) -> bool { + is_supported_pattern(id) && !id.contains(['*', ',', '!']) +} + +#[inline] +fn is_safe_range_arg(range: &str) -> bool { + !range.is_empty() + && !range.starts_with('-') + && range.trim() == range + && !range.starts_with("0x") + && !range.starts_with("0X") +} + +fn prepare(root: &Path, action: &PeerAction, chalk: Chalk) -> Result { + let (mut mutation, bitmap) = BitmapMutation::load(root)?; + let component_id = match action { + PeerAction::Set { component_id, .. } | PeerAction::Unset { component_id } => component_id, + }; + let matched = selection::resolve_pattern_entries(root, &bitmap, component_id, chalk)?; + let [entry] = matched.as_slice() else { + return Err(Failure::Declined("multiple entries on set-peer/unset-peer".into())); + }; + let summary = apply_peer_action(&mut mutation, entry, action)?; + Ok(Prepared { mutation, summary }) +} + +fn apply_peer_action( + mutation: &mut BitmapMutation, + entry: &BitmapEntry, + action: &PeerAction, +) -> Result<&'static str, Failure> { + match action { + PeerAction::Set { component_id, range } => { + let cfg = json!({ "peer": true, "defaultPeerRange": range }); + apply_dep_resolver_config(mutation, entry, cfg, true, true)?; + mutation.reason = format!("set-peer ({component_id})"); + Ok("successfully marked the component as a peer component") + } + PeerAction::Unset { component_id } => { + let mut cfg = load_dep_resolver_config(&mutation.root, &mutation.bitmap_val, entry)? + .unwrap_or_else(|| json!({})); + if let Some(obj) = cfg.as_object_mut() { + obj.remove("peer"); + obj.remove("defaultPeerRange"); + } + mutation.bitmap_changed = + set_bitmap_dep_resolver_config(&mut mutation.bitmap_val, entry, cfg)?; + mutation.reason = format!("unset-peer ({component_id})"); + Ok("successfully marked the component as not a peer component") + } + } +} + +fn set_bitmap_dep_resolver_config( + bitmap_val: &mut Value, + entry: &BitmapEntry, + config: Value, +) -> Result { + let row = find_bitmap_row_mut(bitmap_val, entry)?; + let current = row + .get("config") + .and_then(|cfg| super::update_cmd::aspect_entry(cfg, DEP_RESOLVER_ID).map(|(_, val)| val)); + if current == Some(&config) { + return Ok(false); + } + insert_dep_resolver_config(row, config)?; + Ok(true) +} + +fn insert_dep_resolver_config(row: &mut Value, config: Value) -> Result<(), Failure> { + let row_obj = + row.as_object_mut().ok_or_else(|| Failure::Declined("bitmap row object".into()))?; + let cfg_val = row_obj + .entry("config") + .or_insert_with(|| json!({})); + let key = super::update_cmd::aspect_entry(cfg_val, DEP_RESOLVER_ID) + .map_or_else(|| DEP_RESOLVER_ID.to_owned(), |(key, _)| key.to_owned()); + let cfg_obj = + cfg_val.as_object_mut().ok_or_else(|| Failure::Declined("bitmap config object".into()))?; + cfg_obj.insert(key, config); + Ok(()) +} + +pub(in crate::commands) fn load_dep_resolver_config( + root: &Path, + bitmap_val: &Value, + entry: &BitmapEntry, +) -> Result, Failure> { + if root.join("workspace-config-merge.json").exists() + || workspace::scope_path(root).join("unmerged.json").exists() + { + return Err(Failure::Declined("merge state active".into())); + } + let row = find_bitmap_row_ref(bitmap_val, entry)?; + if let Some((_, val)) = + row.get("config").and_then(|cfg| super::update_cmd::aspect_entry(cfg, DEP_RESOLVER_ID)) + { + return clean_dep_resolver_value(val); + } + let mut comp_json_propagate = true; + if !entry.root_dir.is_empty() { + let comp_json_path = root.join(&entry.root_dir).join("component.json"); + if comp_json_path.is_file() { + let parsed: Value = bit_object_import::parse_jsonc(&fs::read(&comp_json_path)?)?; + comp_json_propagate = parsed["propagate"].as_bool().unwrap_or(false); + if let Some((_, val)) = + super::update_cmd::aspect_entry(&parsed["extensions"], DEP_RESOLVER_ID) + { + return clean_dep_resolver_value(val); + } + } + } + load_scope_dep_resolver_config(root, entry, comp_json_propagate) +} + +#[inline] +fn clean_dep_resolver_value(val: &Value) -> Result, Failure> { + if val.as_str() == Some("-") { + return Ok(None); + } + let Some(obj) = val.as_object() else { + return Err(Failure::Declined("non-object dependency-resolver config".into())); + }; + let mut cleaned = obj.clone(); + cleaned.remove("__specific"); + Ok((!cleaned.is_empty()).then_some(Value::Object(cleaned))) +} + +fn load_scope_dep_resolver_config( + root: &Path, + entry: &BitmapEntry, + comp_json_propagate: bool, +) -> Result, Failure> { + let scope_dir = workspace::scope_path(root); + let bare_id = entry.id.to_string_without_version(); + let Some(ver) = entry.id.version + .as_deref() + .filter(|ver| !ver.is_empty()) + else { + if resolve_component_entry(&scope_dir, &bare_id).is_some() { + return Err(Failure::Declined("unversioned bitmap entry exists in scope".into())); + } + return Ok(None); + }; + let scope_root = ScopeRoot { path: root.to_path_buf(), bare: false }; + let Some(ver_val) = load_cat_component(&scope_root, &scope_dir, &format!("{bare_id}@{ver}")) + else { + return Err(Failure::Declined("version object missing in local scope".into())); + }; + extract_scope_dep_resolver_config(&ver_val, comp_json_propagate) +} + +fn extract_scope_dep_resolver_config( + ver_val: &Value, + comp_json_propagate: bool, +) -> Result, Failure> { + let Some(ext) = ver_val["extensions"] + .as_array() + .and_then(|arr| { + arr.iter().find(|ext| super::update_cmd::matches_aspect(ext, DEP_RESOLVER_ID)) + }) + else { + return Ok(None); + }; + if ext["config"].as_str() == Some("-") { + return Ok(None); + } + let Some(cfg_obj) = ext["config"].as_object() else { + return Ok(None); + }; + let is_specific = cfg_obj + .get("__specific") + .and_then(Value::as_bool) + .unwrap_or(false); + if !is_specific && !comp_json_propagate { + return Ok(None); + } + let mut cleaned = cfg_obj.clone(); + cleaned.remove("__specific"); + normalize_scope_dep_resolver_policy(&mut cleaned); + Ok((!cleaned.is_empty()).then_some(Value::Object(cleaned))) +} + +fn normalize_scope_dep_resolver_policy(cleaned: &mut Map) { + let Some(policy) = cleaned.get_mut("policy").and_then(Value::as_object_mut) else { + return; + }; + for field in ["dependencies", "devDependencies", "peerDependencies"] { + let Some(arr) = policy.get(field).and_then(Value::as_array) else { + continue; + }; + let forced = collect_forced_policy_entries(arr); + if forced.is_empty() { + policy.remove(field); + } else { + policy.insert(field.into(), Value::Object(forced)); + } + } + if policy.is_empty() { + cleaned.remove("policy"); + } +} + +#[inline] +fn collect_forced_policy_entries(arr: &[Value]) -> Map { + arr.iter() + .filter(|item| item["force"].as_bool() == Some(true)) + .filter_map(|item| { + let name = item["name"].as_str()?.trim(); + (!name.is_empty()).then_some((name.to_owned(), item.get("version")?.clone())) + }) + .collect() +} + +impl Prepared { + #[inline] + fn commit(self, chalk: Chalk) -> io::Result { + self.mutation.flush()?; + let formatter = Formatter::new(chalk); + Ok(Report { data: formatter.success_summary(self.summary), code: 0, details: None }) + } +} diff --git a/native/cli/src/commands/set_peer/tests.rs b/native/cli/src/commands/set_peer/tests.rs new file mode 100644 index 000000000000..07d4ea890361 --- /dev/null +++ b/native/cli/src/commands/set_peer/tests.rs @@ -0,0 +1,120 @@ +//! Unit tests for `bit set-peer` ([`parse_set_args`]) and `bit unset-peer` ([`parse_unset_args`]) [`PeerAction`] CLI parsing and [`prepare`] config mutations. +//! +//! Verifies [`DEP_RESOLVER_ID`] peer dependency policy updates and [`Chalk`] output formatting. + +use super::{DEP_RESOLVER_ID, PeerAction, parse_set_args, parse_unset_args, prepare}; +use crate::commands::color::Chalk; +use serde_json::{Value, json}; +use std::fs; +use tempfile::tempdir; + +/// Verifies `bit set-peer` and `bit unset-peer` [`PeerAction`] parsing via [`parse_set_args`] and [`parse_unset_args`]. +#[test] +fn parses_set_and_unset_peer_arguments() { + assert_eq!( + parse_set_args(&["comp1", "^1.0.0"]), + Some(PeerAction::Set { component_id: "comp1".into(), range: "^1.0.0".into() }), + ); + assert_eq!( + parse_set_args(&["comp1", "0", "--no-color"]), + Some(PeerAction::Set { component_id: "comp1".into(), range: "0".into() }), + ); + assert_eq!(parse_set_args(&["comp*", "^1.0.0"]), None); + assert_eq!(parse_set_args(&["comp1"]), None); + assert_eq!( + parse_unset_args(&["comp1"]), + Some(PeerAction::Unset { component_id: "comp1".into() }), + ); + assert_eq!(parse_unset_args(&["comp1", "extra"]), None); +} + +/// Verifies [`prepare`] and commit lifecycle for setting and unsetting [`DEP_RESOLVER_ID`] peer config in `.bitmap`. +#[test] +fn sets_and_unsets_peer_in_bitmap() { + let dir = tempdir().expect("tempdir"); + fs::write( + dir.path().join("workspace.jsonc"), + r#"{ "teambit.workspace/workspace": { "defaultScope": "my-scope" } }"#, + ) + .expect("write workspace.jsonc"); + fs::write( + dir.path().join(".bitmap"), + r#"{ "comp1": { "scope": "", "version": "", "mainFile": "index.ts", "rootDir": "comp1" }, "$schema-version": "17.0.0" }"#, + ) + .expect("write .bitmap"); + fs::create_dir_all(dir.path().join("comp1")).expect("mkdir comp1"); + + let set_act = PeerAction::Set { component_id: "comp1".into(), range: "^1.0.0".into() }; + let prepared = prepare(dir.path(), &set_act, Chalk::new(false)).expect("prepare set-peer"); + assert!(prepared.mutation.bitmap_changed); + assert_eq!( + prepared.mutation.bitmap_val["comp1"]["config"][DEP_RESOLVER_ID], + json!({ "peer": true, "defaultPeerRange": "^1.0.0" }), + ); + let report = prepared + .commit(Chalk::new(false)) + .expect("commit set-peer"); + assert!(report.data.contains("successfully marked the component as a peer component")); + + let unset_act = PeerAction::Unset { component_id: "comp1".into() }; + let unset_prep = + prepare(dir.path(), &unset_act, Chalk::new(false)).expect("prepare unset-peer"); + assert!(unset_prep.mutation.bitmap_changed); + let after: Value = unset_prep.mutation.bitmap_val; + assert_eq!(after["comp1"]["config"][DEP_RESOLVER_ID], json!({})); +} + +#[test] +fn sets_peer_in_component_json_when_present() { + let dir = tempdir().expect("tempdir"); + fs::write( + dir.path().join("workspace.jsonc"), + r#"{ "teambit.workspace/workspace": { "defaultScope": "my-scope" } }"#, + ) + .expect("write workspace.jsonc"); + fs::write( + dir.path().join(".bitmap"), + r#"{ "comp1": { "scope": "", "version": "", "mainFile": "index.ts", "rootDir": "comp1" }, "$schema-version": "17.0.0" }"#, + ) + .expect("write .bitmap"); + fs::create_dir_all(dir.path().join("comp1")).expect("mkdir comp1"); + fs::write( + dir.path().join("comp1/component.json"), + r#"{ + "componentId": { "scope": "my-scope", "name": "comp1" }, + "propagate": false, + "extensions": { + "teambit.dependencies/dependency-resolver": { + "policy": { "dependencies": { "lodash": "4.17.21" } } + } + } +} +"#, + ) + .expect("write component.json"); + + let set_act = PeerAction::Set { component_id: "comp1".into(), range: "^2.0.0".into() }; + let prepared = prepare(dir.path(), &set_act, Chalk::new(false)).expect("prepare set-peer"); + assert!(!prepared.mutation.bitmap_changed); + assert_eq!(prepared.mutation.comp_json_edits.len(), 1); + prepared + .commit(Chalk::new(false)) + .expect("commit set-peer"); + let updated: Value = + serde_json::from_slice(&fs::read(dir.path().join("comp1/component.json")).unwrap()) + .unwrap(); + assert_eq!( + updated["extensions"][DEP_RESOLVER_ID], + json!({ + "policy": { "dependencies": { "lodash": "4.17.21" } }, + "peer": true, + "defaultPeerRange": "^2.0.0" + }), + ); +} + +#[test] +fn rejects_empty_set_or_unset_peer_arguments() { + assert_eq!(parse_set_args(&[]), None); + assert_eq!(parse_unset_args(&[]), None); +} diff --git a/native/cli/src/commands/show_cmd.rs b/native/cli/src/commands/show_cmd.rs new file mode 100644 index 000000000000..cfea209cd021 --- /dev/null +++ b/native/cli/src/commands/show_cmd.rs @@ -0,0 +1,217 @@ +//! Native `bit show ` (`scopes/component/component/show/show.cmd.ts`). +//! +//! Coordinates [`COMMAND`], [`Options`], [`remote_show`], [`workspace_show`], [`deps`], [`deps_policy`], [`render`], and [`help`]. + +pub(crate) mod remote_show; + +mod deps; +mod deps_policy; +mod help; +mod render; +#[cfg(test)] +mod tests; +mod workspace_show; + +use super::{Command, workspace_command}; +use crate::{native::Outcome, workspace}; +use std::{env, ffi::OsString, io, path::Path}; + +/// Descriptor for the native `bit show` command. +pub const COMMAND: Command = Command { name: "show", aliases: &[], run }; + +/// Error message when `bit show --remote` is run outside a workspace or scope without `--legacy`. +const REMOTE_NO_HOST_ERR: &str = "error: the current directory is not a workspace nor a scope. the full \"bit show\" is not supported.\nto see the legacy bit show, please use \"--legacy\" flag"; +/// Error message when `bit show` is run locally outside a workspace or scope. +const LOCAL_NO_HOST_ERR: &str = "Could not find a workspace or scope. Consider using --remote or run inside a valid workspace or scope."; + +/// Parsed CLI options for `bit show `. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub(super) struct Options { + pub id: String, + pub json: bool, + pub legacy: bool, + pub remote: bool, + pub browser: bool, + pub compare: bool, +} + +#[inline] +fn is_ci_active() -> bool { + env::var_os("CI") + .is_some_and(|val| { + let text = val.to_string_lossy(); + let trimmed = text + .trim() + .trim_matches(['"', '\'']) + .trim(); + !trimmed.is_empty() + && !["0", "false", "no", "off"] + .iter() + .any(|falsy| trimmed.eq_ignore_ascii_case(falsy)) + }) +} + +/// Entry point for `bit show`. +fn run(args: &[OsString]) -> io::Result { + let Some(argv) = workspace_command::utf8_args(args) else { + return Ok(Outcome::Declined("show arguments are not UTF-8".into())); + }; + if let Some(outcome) = help::try_run(args, &argv[1..])? { + return Ok(outcome); + } + let Some(options) = parse_options(&argv[1..]) else { + return Ok(Outcome::Declined("outside native show grammar".into())); + }; + if is_ci_active() { + return Ok(Outcome::Declined("CI show delegated".into())); + } + let cwd = env::current_dir()?; + if let Some(err) = missing_host_error(&cwd, &options) { + if options.json { + return workspace_command::execute_failure_scope( + &cwd, + args, + "show", + true, + "", + io::Error::other(err), + ); + } + return workspace_command::execute_without_loader_scope(&cwd, args, "show", |_| { + Err(io::Error::other(err)) + }); + } + let outcome_opt = if options.remote { + remote_show::try_run(&cwd, args, &options)? + } else { + workspace_show::try_run(&cwd, args, &options)? + }; + if let Some(outcome) = outcome_opt { + return Ok(outcome); + } + Ok(Outcome::Declined("show execution delegated".into())) +} + +#[inline] +pub(super) fn missing_host_error(cwd: &Path, options: &Options) -> Option<&'static str> { + if options.legacy + || workspace::root(cwd).is_some() + || cwd + .ancestors() + .any(|dir| { + dir.join("scope.json").is_file() + || dir.join(".bit").is_dir() + || dir.join(".git/bit").is_dir() + }) + { + return None; + } + if options.remote { Some(REMOTE_NO_HOST_ERR) } else { Some(LOCAL_NO_HOST_ERR) } +} + +pub(super) fn parse_options(args: &[&str]) -> Option { + let mut id = None; + let mut options = Options::default(); + let mut iter = args.iter().copied(); + while let Some(arg) = iter.next() { + match arg { + "--" => { + consume_trailing_show_id(iter, &mut id)?; + break; + } + "--no-color" => return None, + "-j" | "--json" | "--json=true" => options.json = true, + "--json=false" | "--no-json" => options.json = false, + "-l" | "--legacy" | "--legacy=true" => options.legacy = true, + "--legacy=false" | "--no-legacy" => options.legacy = false, + "-r" | "--remote" | "--remote=true" => options.remote = true, + "--remote=false" | "--no-remote" => options.remote = false, + "-b" | "--browser" | "--browser=true" => options.browser = true, + "--browser=false" | "--no-browser" => options.browser = false, + "-c" | "--compare" | "--compare=true" => options.compare = true, + "--compare=false" | "--no-compare" => options.compare = false, + short if apply_short_bundle(short, &mut options) => {} + val if !val.starts_with('-') && !val.trim().is_empty() && id.is_none() => { + id = Some(val.trim().to_owned()); + } + _ => return None, + } + } + options.id = id?; + Some(options) +} + +#[inline] +fn consume_trailing_show_id<'a>( + iter: impl Iterator, + id: &mut Option, +) -> Option<()> { + for val in iter { + let val = val.trim(); + (!val.is_empty() && !val.starts_with('-') && id.replace(val.to_owned()).is_none()) + .then_some(())?; + } + Some(()) +} + +#[inline] +fn apply_short_bundle(arg: &str, options: &mut Options) -> bool { + let arg = arg.trim(); + let flags = &arg.as_bytes()[usize::from(arg.starts_with('-'))..]; + if !arg.starts_with('-') + || arg.starts_with("--") + || flags.len() <= 1 + || !flags + .iter() + .all(|byte| matches!(byte, b'j' | b'l' | b'r' | b'b' | b'c')) + { + return false; + } + for &byte in flags { + match byte { + b'j' => options.json = true, + b'l' => options.legacy = true, + b'r' => options.remote = true, + b'b' => options.browser = true, + b'c' => options.compare = true, + _ => unreachable!(), + } + } + true +} + +fn render_remote_outcome( + cwd: &Path, + args: &[OsString], + options: &Options, + loaded: &workspace_show::LoadedComponent, +) -> io::Result> { + let report = format_remote_report(options, loaded)?; + let outcome = if let Some(root) = workspace::root(cwd) { + if options.json { + workspace_command::execute_json(&root, args, "show", |_| Ok(report))? + } else { + workspace_command::execute_then(&root, args, "show", |_| Ok(report))? + } + } else if options.json { + workspace_command::execute_json_scope(cwd, args, "show", |_| Ok(report))? + } else { + workspace_command::execute_with_scope(cwd, args, "show", &report)? + }; + Ok(Some(outcome)) +} + +#[inline] +fn format_remote_report( + options: &Options, + loaded: &workspace_show::LoadedComponent, +) -> io::Result { + let data = if options.json { + let value = + if options.legacy { render::legacy_json(loaded) } else { render::harmony_json(loaded) }; + serde_json::to_string_pretty(&value)? + } else { + render::text_report(loaded) + }; + Ok(workspace_command::results::Report { data, code: 0, details: None }) +} diff --git a/native/cli/src/commands/show_cmd/deps.rs b/native/cli/src/commands/show_cmd/deps.rs new file mode 100644 index 000000000000..9f15f3cd3103 --- /dev/null +++ b/native/cli/src/commands/show_cmd/deps.rs @@ -0,0 +1,379 @@ +//! Dependency detection and policy resolution for native `bit show`. +//! +//! Computes [`DepEntry`] component dependencies over [`Workspace`] and [`WorkspaceComponent`] with policy overrides. + +use super::deps_policy::{ + apply_component_json_policy, apply_model_dependencies, apply_policy_object, + apply_variant_policies, +}; +use bit_install::{ + config::{DEPENDENCY_RESOLVER_ASPECT, WorkspaceConfig, aspect_id_without_version}, + model::WorkspaceComponent, + workspace::{BitmapEntry, ModelData, Workspace}, +}; +use serde_json::{Value, json}; +use std::{ + fs, + path::{Component, Path, PathBuf}, +}; + +/// Resolved component or package dependency entry for `bit show`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct DepEntry { + pub id: String, + pub package_name: Option, + pub version: String, + pub lifecycle: &'static str, + pub dep_type: &'static str, + pub optional: bool, + pub relative_paths: Vec, +} + +/// Borrowed scope, component name, and package name identity for self-dependency filtering. +pub(super) struct Identity<'a> { + pub scope: &'a str, + pub name: &'a str, + pub package_name: &'a str, +} + +impl Identity<'_> { + /// Returns `true` when `candidate` refers to this component's own name, scoped ID, or package name. + #[inline] + pub(super) fn is_self(&self, candidate: &str) -> bool { + let stripped = strip_pkg_version(candidate.trim()); + if eq_or_subpath(stripped, self.package_name) || eq_or_subpath(stripped, self.name) { + return true; + } + let after_at = stripped.strip_prefix('@').unwrap_or(stripped); + let Some(rest) = after_at + .strip_prefix(self.scope) + .and_then(|rest| rest.strip_prefix('/')) + else { + return false; + }; + if eq_or_subpath(rest, self.name) { + return true; + } + stripped.starts_with('@') + && self.name.contains('/') + && eq_or_subpath(rest, &self.name.replace('/', ".")) + } +} + +#[inline] +fn eq_or_subpath(candidate: &str, target: &str) -> bool { + candidate == target + || candidate + .strip_prefix(target) + .is_some_and(|rest| rest.starts_with('/')) +} + +#[inline] +fn strip_pkg_version(raw: &str) -> &str { + if let Some(rest) = raw.strip_prefix('@') { + match rest.find('@') { + Some(idx) => &raw[..=idx], + None => raw, + } + } else { + raw.split('@').next().unwrap_or(raw) + } +} + +pub(super) struct CompContext<'a> { + pub root: &'a Path, + pub ws_config: &'a WorkspaceConfig, + pub ws: &'a Workspace, + pub entry: &'a BitmapEntry, + pub component: &'a WorkspaceComponent, + pub model: Option<&'a ModelData>, + pub scope: &'a str, +} + +pub(super) fn collect_dependencies(ctx: &CompContext<'_>) -> Vec { + let identity = Identity { + scope: ctx.scope, + name: &ctx.entry.id.name, + package_name: &ctx.component.package_name, + }; + let mut deps = Vec::new(); + if let Some(model_data) = ctx.model { + apply_model_dependencies(model_data, &identity, &mut deps); + } + scan_component_files(ctx, &identity, &mut deps); + apply_variant_policies(ctx.ws_config, ctx.entry, &identity, &mut deps); + apply_component_json_policy(&ctx.component.dir, &identity, &mut deps); + if let Some(cfg) = + crate::commands::update_cmd::bitmap_entry_aspect(ctx.entry, DEPENDENCY_RESOLVER_ASPECT) + { + apply_policy_object(&cfg["policy"], &identity, &mut deps); + } + deps +} + +#[inline] +fn is_dev_file(rel_file: &str, env_id: &str) -> bool { + let is_readme = aspect_id_without_version(env_id) == "teambit.mdx/readme"; + rel_file.contains(".spec.") + || rel_file.contains(".test.") + || (!is_readme && rel_file.contains(".docs.")) + || rel_file.contains(".composition.") + || rel_file.contains(".compositions.") +} + +fn scan_component_files(ctx: &CompContext<'_>, identity: &Identity<'_>, deps: &mut Vec) { + for rel_file in &ctx.component.files { + let file_lifecycle = + if is_dev_file(rel_file, &ctx.component.env_id) { "dev" } else { "runtime" }; + for raw_spec in scanned_specifiers(&ctx.component.dir, rel_file) { + apply_scanned_specifier(ctx, identity, rel_file, &raw_spec, file_lifecycle, deps); + } + } +} + +#[inline] +fn apply_scanned_specifier( + ctx: &CompContext<'_>, + identity: &Identity<'_>, + rel_file: &str, + raw_spec: &str, + lifecycle: &'static str, + deps: &mut Vec, +) { + if identity.is_self(raw_spec) { + return; + } + if raw_spec.starts_with('.') { + resolve_relative_import(ctx.ws, ctx.entry, rel_file, raw_spec, deps); + } else { + resolve_package_import(ctx, identity, raw_spec, lifecycle, deps); + } +} + +#[inline] +fn scanned_specifiers(comp_dir: &Path, rel_file: &str) -> Vec { + let Ok(source) = fs::read_to_string(comp_dir.join(rel_file)) else { + return Vec::new(); + }; + let Ok(scanned) = bit_dependency_scanner::scan_source(rel_file.to_owned(), source) else { + return Vec::new(); + }; + scanned["dependencies"] + .as_object() + .map(|specifiers| specifiers.keys().cloned().collect()) + .unwrap_or_default() +} + +fn resolve_relative_import( + ws: &Workspace, + entry: &BitmapEntry, + rel_file: &str, + raw_spec: &str, + deps: &mut Vec, +) { + let base_dir = Path::new(&entry.root_dir) + .join(rel_file) + .parent() + .map_or_else(PathBuf::new, Path::to_path_buf); + let resolved = normalize_rel_path(&base_dir.join(raw_spec)); + if path_within_dir(&resolved, &entry.root_dir) { + return; + } + let Some((_, other)) = ws + .active() + .find(|(_, comp)| comp.id != entry.id && path_within_dir(&resolved, &comp.root_dir)) + else { + return; + }; + add_workspace_component_dep(other, Some((rel_file, &other.main_file)), deps); +} + +#[inline] +fn path_within_dir(resolved: &str, root_dir: &str) -> bool { + let dir = root_dir.trim().trim_matches('/'); + !dir.is_empty() + && (resolved == dir + || resolved + .strip_prefix(dir) + .is_some_and(|rest| rest.starts_with('/'))) +} + +fn normalize_rel_path(path: &Path) -> String { + let mut parts: Vec<&str> = Vec::new(); + for part in path.components() { + match part { + Component::Normal(seg) => parts.extend(seg.to_str()), + Component::ParentDir => { + parts.pop(); + } + _ => {} + } + } + parts.join("/") +} + +fn resolve_package_import( + ctx: &CompContext<'_>, + identity: &Identity<'_>, + raw_spec: &str, + file_lifecycle: &'static str, + deps: &mut Vec, +) { + let pkg_name = extract_package_name(raw_spec); + if identity.is_self(pkg_name) || pkg_name.starts_with("@teambit/") { + return; + } + if let Some((_, other)) = ctx.ws + .active() + .find(|(_, comp)| { + let other_scope = comp.id.effective_scope().unwrap_or_default(); + comp.package_name == pkg_name || format!("@{other_scope}/{}", comp.id.name) == pkg_name + }) + { + add_workspace_component_dep(other, None, deps); + return; + } + if let Some(ver) = lookup_package_version(ctx.root, &ctx.component.dir, ctx.ws_config, pkg_name) + { + let existing_lifecycle = deps + .iter() + .find(|dep| dep.id == pkg_name || dep.package_name.as_deref() == Some(pkg_name)) + .map(|dep| dep.lifecycle); + let lifecycle = match existing_lifecycle { + Some("runtime" | "peer") if file_lifecycle == "dev" => { + existing_lifecycle.unwrap_or("runtime") + } + _ => file_lifecycle, + }; + upsert_dep( + deps, + DepEntry { + id: pkg_name.to_owned(), + package_name: Some(pkg_name.to_owned()), + version: ver, + lifecycle, + dep_type: "package", + optional: false, + relative_paths: Vec::new(), + }, + ); + } +} + +#[inline] +fn extract_package_name(specifier: &str) -> &str { + let specifier = specifier.trim(); + if specifier.starts_with('@') { + let mut indices = specifier.match_indices('/'); + let _ = indices.next(); + match indices.next() { + Some((idx, _)) => &specifier[..idx], + None => specifier, + } + } else { + specifier + .split('/') + .next() + .unwrap_or(specifier) + } +} + +fn add_workspace_component_dep( + other: &WorkspaceComponent, + rel_pair: Option<(&str, &str)>, + deps: &mut Vec, +) { + let ver = other.id.version.clone().unwrap_or_else(|| "0.0.1-new".to_owned()); + let id = match other.id.version.as_deref() { + Some(version) if !version.is_empty() => { + format!("{}@{version}", other.id.to_string_without_version()) + } + _ => other.id.to_string_without_version(), + }; + let relative_paths = match rel_pair { + Some((src, dst)) => vec![json!({ + "sourceRelativePath": src, + "destinationRelativePath": dst, + })], + None => Vec::new(), + }; + upsert_dep( + deps, + DepEntry { + id, + package_name: Some(other.package_name.clone()), + version: ver, + lifecycle: "runtime", + dep_type: "component", + optional: false, + relative_paths, + }, + ); +} + +fn lookup_package_version( + root: &Path, + comp_dir: &Path, + ws_config: &WorkspaceConfig, + pkg_name: &str, +) -> Option { + for field in ["dependencies", "devDependencies", "peerDependencies"] { + let ws_policy = &ws_config.dependency_resolver.policy[field][pkg_name]; + if let Some(ver) = ws_policy + .as_str() + .or_else(|| ws_policy["version"].as_str()) + .map(str::trim) + .filter(|ver| !ver.is_empty() && *ver != "-") + { + return Some(ver.to_owned()); + } + } + for base in [comp_dir, root] { + let pkg_json_path = base + .join("node_modules") + .join(pkg_name) + .join("package.json"); + if let Ok(bytes) = fs::read(pkg_json_path) + && let Ok(parsed) = bit_object_import::parse_jsonc(&bytes) + && let Some(ver) = parsed["version"] + .as_str() + .map(str::trim) + .filter(|ver| !ver.is_empty()) + { + return Some(ver.to_owned()); + } + } + None +} + +#[inline] +pub(super) fn remove_dep(deps: &mut Vec, target: &str) { + let target = target.trim(); + deps.retain(|dep| dep.id != target && dep.package_name.as_deref() != Some(target)); +} + +pub(super) fn upsert_dep(deps: &mut Vec, entry: DepEntry) { + if let Some(existing) = deps + .iter_mut() + .find(|dep| dep.id == entry.id || dep.package_name == entry.package_name) + { + if entry.dep_type == "component" { + existing.id = entry.id; + existing.dep_type = "component"; + } else if existing.dep_type == "component" && !entry.version.is_empty() { + let bare = existing.id + .split('@') + .next() + .unwrap_or(&existing.id); + existing.id = format!("{bare}@{}", entry.version); + } + existing.version = entry.version; + existing.lifecycle = entry.lifecycle; + existing.optional = entry.optional; + if !entry.relative_paths.is_empty() { + existing.relative_paths = entry.relative_paths; + } + } else { + deps.push(entry); + } +} diff --git a/native/cli/src/commands/show_cmd/deps_policy.rs b/native/cli/src/commands/show_cmd/deps_policy.rs new file mode 100644 index 000000000000..8fa7d82ca24a --- /dev/null +++ b/native/cli/src/commands/show_cmd/deps_policy.rs @@ -0,0 +1,373 @@ +//! Dependency policy resolution (model, variants, `component.json`, `.bitmap`) for native `bit show`. +//! +//! [`apply_version_dependencies`] applies [`ModelData`], [`WorkspaceConfig`], and [`BitmapEntry`] dependency policies onto [`DepEntry`] sets. + +use super::deps::{DepEntry, Identity, remove_dep, upsert_dep}; +use bit_install::{ + config::{DEPENDENCY_RESOLVER_ASPECT, WorkspaceConfig, aspect_id_without_version}, + jsonc, + workspace::{BitmapEntry, ModelData, by_root_dir_and_name}, +}; +use serde_json::Value; +use std::{fs, path::Path}; + +/// Applies legacy package/component dependency fields and model dependency resolver policies onto `deps`. +#[inline] +pub(super) fn apply_version_dependencies( + ver_val: &Value, + model: &ModelData, + identity: &Identity<'_>, + deps: &mut Vec, +) { + apply_legacy_pkg_fields(ver_val, identity, deps); + apply_legacy_comp_fields(ver_val, identity, deps); + apply_model_dependencies(model, identity, deps); +} + +/// Applies legacy `packageDependencies`, `devPackageDependencies`, and `peerPackageDependencies` fields onto `deps`. +fn apply_legacy_pkg_fields(ver_val: &Value, identity: &Identity<'_>, deps: &mut Vec) { + let pkg_fields = [ + ("packageDependencies", "runtime"), + ("devPackageDependencies", "dev"), + ("peerPackageDependencies", "peer"), + ]; + for (field, lifecycle) in pkg_fields { + if let Some(pkg_map) = ver_val[field].as_object() { + for (pkg_name, ver_item) in pkg_map { + apply_legacy_pkg_dep(pkg_name, ver_item, lifecycle, identity, deps); + } + } + } +} + +fn apply_legacy_comp_fields(ver_val: &Value, identity: &Identity<'_>, deps: &mut Vec) { + let comp_fields = + [("dependencies", "runtime"), ("devDependencies", "dev"), ("peerDependencies", "peer")]; + for (field, lifecycle) in comp_fields { + if let Some(comp_arr) = ver_val[field].as_array() { + for item in comp_arr { + apply_legacy_comp_dep(item, lifecycle, identity, deps); + } + } + } +} + +fn apply_legacy_pkg_dep( + pkg_name: &str, + ver_item: &Value, + lifecycle: &'static str, + identity: &Identity<'_>, + deps: &mut Vec, +) { + if identity.is_self(pkg_name) { + return; + } + let Some(ver) = ver_item + .as_str() + .map(str::trim) + .filter(|ver| !ver.is_empty()) + else { + return; + }; + upsert_dep( + deps, + DepEntry { + id: pkg_name.to_owned(), + package_name: Some(pkg_name.to_owned()), + version: ver.to_owned(), + lifecycle, + dep_type: "package", + optional: false, + relative_paths: Vec::new(), + }, + ); +} + +fn apply_legacy_comp_dep( + item: &Value, + lifecycle: &'static str, + identity: &Identity<'_>, + deps: &mut Vec, +) { + let id_obj = &item["id"]; + let Some(name) = id_obj["name"] + .as_str() + .map(str::trim) + .filter(|name| !name.is_empty()) + else { + return; + }; + let scope = id_obj["scope"].as_str().map_or("", str::trim); + let ver = id_obj["version"].as_str().map_or("", str::trim); + let bare_id = if scope.is_empty() { name.to_owned() } else { format!("{scope}/{name}") }; + if identity.is_self(&bare_id) { + return; + } + let full_id = if ver.is_empty() { bare_id } else { format!("{bare_id}@{ver}") }; + let pkg_name = resolve_legacy_pkg_name(item, scope, name); + let relative_paths = item["relativePaths"] + .as_array() + .cloned() + .unwrap_or_default(); + upsert_dep( + deps, + DepEntry { + id: full_id, + package_name: Some(pkg_name), + version: ver.to_owned(), + lifecycle, + dep_type: "component", + optional: false, + relative_paths, + }, + ); +} + +#[inline] +fn resolve_legacy_pkg_name(item: &Value, scope: &str, name: &str) -> String { + item["packageName"] + .as_str() + .map(str::trim) + .filter(|pkg| !pkg.is_empty()) + .map_or_else( + || { + let dotted = name.replace('/', "."); + if scope.is_empty() { dotted } else { format!("@{scope}/{dotted}") } + }, + str::to_owned, + ) +} + +pub(super) fn apply_model_dependencies( + model: &ModelData, + identity: &Identity<'_>, + deps: &mut Vec, +) { + let Some(ext) = model.extensions + .iter() + .find(|item| crate::commands::update_cmd::matches_aspect(item, DEPENDENCY_RESOLVER_ASPECT)) + else { + return; + }; + apply_policy_object(&ext["config"]["policy"], identity, deps); + if let Some(policy_arr) = ext["data"]["policy"].as_array() { + for item in policy_arr { + apply_model_policy_item(item, identity, deps); + } + } + if let Some(dep_arr) = ext["data"]["dependencies"].as_array() { + for item in dep_arr { + apply_model_dep_item(item, identity, deps); + } + } +} + +fn apply_model_policy_item(item: &Value, identity: &Identity<'_>, deps: &mut Vec) { + let Some(dep_id) = item["dependencyId"] + .as_str() + .map(str::trim) + .filter(|id| !id.is_empty()) + else { + return; + }; + if identity.is_self(dep_id) { + return; + } + let Some(ver) = item["value"]["version"] + .as_str() + .map(str::trim) + .filter(|ver| !ver.is_empty()) + else { + return; + }; + if ver == "-" { + remove_dep(deps, dep_id); + return; + } + let lifecycle = match item["lifecycleType"].as_str() { + Some("dev") => "dev", + Some("peer") => "peer", + _ => "runtime", + }; + let optional = item["optional"].as_bool().unwrap_or(false); + upsert_dep( + deps, + DepEntry { + id: dep_id.to_owned(), + package_name: Some(dep_id.to_owned()), + version: ver.to_owned(), + lifecycle, + dep_type: "package", + optional, + relative_paths: Vec::new(), + }, + ); +} + +#[inline] +fn apply_model_dep_item(item: &Value, identity: &Identity<'_>, deps: &mut Vec) { + if let Some(dep) = model_dep_entry(item, identity) { + upsert_dep(deps, dep); + } +} + +#[inline] +fn resolve_model_dep_id(item: &Value, is_comp: bool) -> Option { + let obj_id = crate::commands::graph_cmd::builder::format_dep_id(&item["componentId"]) + .or_else(|| crate::commands::graph_cmd::builder::format_dep_id(&item["id"])); + let raw = if is_comp { + obj_id + .as_deref() + .or_else(|| item["packageName"].as_str())? + } else { + item["packageName"].as_str().or(obj_id.as_deref())? + }; + let trimmed = raw.trim(); + (!trimmed.is_empty()).then(|| trimmed.to_owned()) +} + +fn model_dep_entry(item: &Value, identity: &Identity<'_>) -> Option { + let is_comp = item["__type"].as_str() == Some("component"); + let dep_id = resolve_model_dep_id(item, is_comp)?; + if identity.is_self(&dep_id) { + return None; + } + let ver = item["version"] + .as_str() + .or_else(|| item["componentId"]["version"].as_str()) + .or_else(|| item["id"]["version"].as_str()) + .map(str::trim)?; + let pkg_name = item["packageName"] + .as_str() + .map(str::trim) + .filter(|pkg| !pkg.is_empty()) + .unwrap_or(&dep_id) + .to_owned(); + let full_id = if is_comp && !dep_id.contains('@') && !ver.is_empty() { + format!("{dep_id}@{ver}") + } else { + dep_id + }; + let lifecycle = match item["lifecycle"].as_str() { + Some("dev") => "dev", + Some("peer") => "peer", + _ => "runtime", + }; + Some(DepEntry { + id: full_id, + package_name: Some(pkg_name), + version: ver.to_owned(), + lifecycle, + dep_type: if is_comp { "component" } else { "package" }, + optional: item["optional"].as_bool().unwrap_or(false), + relative_paths: Vec::new(), + }) +} + +pub(super) fn apply_variant_policies( + ws_config: &WorkspaceConfig, + entry: &BitmapEntry, + identity: &Identity<'_>, + deps: &mut Vec, +) { + if ws_config.variants.is_empty() { + return; + } + let Ok(variant) = by_root_dir_and_name( + &ws_config.variants, + entry.root_dir.trim_end_matches('/'), + &entry.id.name, + ) else { + return; + }; + for ext_map in variant.extensions.iter().rev() { + for (key, cfg) in ext_map { + if aspect_id_without_version(key) == DEPENDENCY_RESOLVER_ASPECT { + apply_policy_object(&cfg["policy"], identity, deps); + } + } + } +} + +#[inline] +pub(super) fn apply_component_json_policy( + dir: &Path, + identity: &Identity<'_>, + deps: &mut Vec, +) { + let Ok(text) = fs::read_to_string(dir.join("component.json")) else { + return; + }; + let Ok(parsed) = jsonc::parse(&text) else { + return; + }; + if let Some((_, cfg)) = + crate::commands::update_cmd::aspect_entry(&parsed["extensions"], DEPENDENCY_RESOLVER_ASPECT) + { + apply_policy_object(&cfg["policy"], identity, deps); + } +} + +pub(super) fn apply_policy_object( + policy: &Value, + identity: &Identity<'_>, + deps: &mut Vec, +) { + let fields = [ + ("dependencies", "runtime", false), + ("devDependencies", "dev", false), + ("peerDependencies", "peer", false), + ("optionalDependencies", "runtime", true), + ]; + for (field, lifecycle, default_opt) in fields { + let Some(pkg_map) = policy[field].as_object() else { + continue; + }; + for (pkg_name, val) in pkg_map { + apply_single_policy_entry(pkg_name, val, lifecycle, default_opt, identity, deps); + } + } +} + +fn apply_single_policy_entry( + pkg_name: &str, + val: &Value, + lifecycle: &'static str, + default_opt: bool, + identity: &Identity<'_>, + deps: &mut Vec, +) { + if identity.is_self(pkg_name) { + return; + } + let ver_opt = val + .as_str() + .map(|ver| (ver, default_opt)) + .or_else(|| { + let ver = val["version"].as_str()?; + let opt = default_opt || val["optional"].as_bool().unwrap_or(false); + Some((ver, opt)) + }); + let Some((ver, optional)) = ver_opt.map(|(ver, opt)| (ver.trim(), opt)) else { + return; + }; + if ver.is_empty() { + return; + } + if ver == "-" { + remove_dep(deps, pkg_name); + return; + } + upsert_dep( + deps, + DepEntry { + id: pkg_name.to_owned(), + package_name: Some(pkg_name.to_owned()), + version: ver.to_owned(), + lifecycle, + dep_type: "package", + optional, + relative_paths: Vec::new(), + }, + ); +} diff --git a/native/cli/src/commands/show_cmd/help.rs b/native/cli/src/commands/show_cmd/help.rs new file mode 100644 index 000000000000..5bd3f01a651f --- /dev/null +++ b/native/cli/src/commands/show_cmd/help.rs @@ -0,0 +1,105 @@ +//! Released `ShowCmd` help and argument errors before workspace loading. +//! +//! [`try_run`] formats `--help` ([`help_option`], [`GLOBAL_HELP`]) and missing-argument diagnostics with [`Chalk`]. + +use crate::{ + commands::{app_cmd::GLOBAL_HELP, color::Chalk, run_cmd::help_option}, + native::Outcome, +}; +use std::{ + ffi::OsString, + io::{self, Write}, + process::ExitCode, +}; + +/// Emits `ShowCmd` `--help` output or missing-ID usage diagnostics before workspace loading. +pub(super) fn try_run(args: &[OsString], argv: &[&str]) -> io::Result> { + let Some((code, error)) = presentation(argv) else { + return Ok(None); + }; + let chalk = Chalk::detect(args); + let mut output = command_help(chalk); + if let Some(error) = error + .map(str::trim) + .filter(|err| !err.is_empty()) + { + output.push('\n'); + output.push_str(&chalk.yellow(error)); + output.push('\n'); + } + io::stdout().lock().write_all(output.as_bytes())?; + Ok(Some(Outcome::Exit(code))) +} + +/// Returns `true` when `token` is a non-empty positional component ID argument. +#[inline] +fn is_positional_id(token: &str) -> bool { + let trimmed = token.trim(); + !trimmed.is_empty() && !trimmed.starts_with('-') +} + +#[inline] +fn split_id_from_argv<'a>(argv: &'a [&'a str]) -> (bool, &'a [&'a str]) { + match argv { + [id, rest @ ..] if is_positional_id(id) => (true, rest), + [rest @ .., id] if is_positional_id(id) => (true, rest), + rest => (false, rest), + } +} + +fn presentation(argv: &[&str]) -> Option<(ExitCode, Option<&'static str>)> { + let (id, flags) = split_id_from_argv(argv); + let (help, flags) = match flags { + [rest @ .., "--help" | "-h"] | ["--help" | "-h", rest @ ..] => (true, rest), + rest => (false, rest), + }; + if !flags + .iter() + .all(|flag| { + matches!( + flag.trim(), + "-j" | "--json" + | "-l" + | "--legacy" + | "-r" + | "--remote" + | "-b" + | "--browser" + | "-c" + | "--compare" + | "--no-color", + ) + }) + { + return None; + } + if help { + return Some((if id { ExitCode::SUCCESS } else { ExitCode::FAILURE }, None)); + } + argument_error(id, flags).map(|error| (ExitCode::FAILURE, Some(error))) +} + +#[inline] +fn argument_error(id: bool, flags: &[&str]) -> Option<&'static str> { + if !id { + Some("Not enough non-option arguments: got 0, need at least 1") + } else if flags == ["--no-color"] { + Some("Unknown arguments: no-color, noColor") + } else { + None + } +} + +#[inline] +fn command_help(chalk: Chalk) -> String { + let description = chalk.bold("display component metadata, dependencies, and configuration"); + let component = chalk.green(" component-name"); + let json = help_option(chalk, "-j", "--json"); + let legacy = help_option(chalk, "-l", "--legacy"); + let remote = help_option(chalk, "-r", "--remote"); + let browser = help_option(chalk, "-b", "--browser"); + let compare = help_option(chalk, "-c", "--compare"); + format!( + "bit show \n\n{description}\nshows detailed information about a component including its version, dependencies, environment, and other metadata.\nnote: to see file changes made in a specific version, use `bit diff --parent`.\n\nArguments:\n{component} component name or component id [required]\n\nOptions\n {json} return the component data in json format [boolean]\n {legacy} use the legacy bit show. [boolean]\n {remote} show data for a remote component [boolean]\n {browser} open the component page in the browser [boolean]\n {compare} legacy-only. compare current file system component to its latest tagged version [default=latest] [boolean]\n\n{GLOBAL_HELP}\n", + ) +} diff --git a/native/cli/src/commands/show_cmd/remote_show.rs b/native/cli/src/commands/show_cmd/remote_show.rs new file mode 100644 index 000000000000..e80ab7ed97a7 --- /dev/null +++ b/native/cli/src/commands/show_cmd/remote_show.rs @@ -0,0 +1,400 @@ +//! Remote and scope-level [`LoadedComponent`] loading ([`try_run`], [`ENVS_ASPECT`], [`REMOVE_ASPECT`]) for native `bit show` ([`Options`], [`render_remote_outcome`]). + +pub(super) use super::render_remote_outcome; +use super::{ + Options, + deps::{DepEntry, Identity}, + deps_policy::apply_version_dependencies, + workspace_show::LoadedComponent, +}; +use crate::{ + commands::{debug_cat::lookup::resolve_component_entry, status::cache}, + native::Outcome, + workspace, +}; +use bit_install::{ + config::aspect_id_without_version, + model::{ComponentId, WorkspaceComponent}, + workspace::{BitmapEntry, LaneFlags, ModelData, sha1_hex}, +}; +use serde_json::{Map, Value}; +use std::{ + ffi::OsString, + fs, io, + path::{Path, PathBuf}, +}; + +/// Aspect ID for the `teambit.envs/envs` aspect. +const ENVS_ASPECT: &str = "teambit.envs/envs"; +/// Aspect ID for the `teambit.component/remove` aspect. +const REMOVE_ASPECT: &str = "teambit.component/remove"; +/// Attempts to resolve and render a `--remote` `bit show` request against a local file remote scope. +pub(super) fn try_run( + cwd: &Path, + args: &[OsString], + options: &Options, +) -> io::Result> { + if !options.remote || options.compare || options.browser { + return Ok(None); + } + let Some((scope_name, comp_name, explicit_ver)) = split_remote_id(&options.id) else { + return Ok(None); + }; + let Some(remote_dir) = resolve_remote_scope_dir(cwd, scope_name) else { + return Ok(None); + }; + let Some(loaded) = load_scope_component(&remote_dir, Some(scope_name), comp_name, explicit_ver) + else { + return Ok(None); + }; + render_remote_outcome(cwd, args, options, &loaded) +} + +#[inline] +pub(super) fn load_from_local_scope(scope_dir: &Path, raw_id: &str) -> Option { + let raw_id = raw_id.trim(); + let (bare_id, explicit_ver) = raw_id + .rsplit_once('@') + .map_or((raw_id, None), |(id, ver)| (id.trim(), Some(ver.trim()))); + if bare_id.is_empty() || explicit_ver == Some("") { + return None; + } + load_scope_component(scope_dir, None, bare_id, explicit_ver) +} + +pub(super) fn remote_has_version(root: &Path, scope_name: &str, name: &str, ver: &str) -> bool { + let Some(remote_dir) = resolve_remote_scope_dir(root, scope_name) else { + return false; + }; + let bare_id = format!("{scope_name}/{name}"); + let comp_hash = resolve_component_entry(&remote_dir, &bare_id) + .map_or_else(|| sha1_hex(&bare_id), |(_, _, hash)| hash); + let Some((kind, comp_val)) = + bit_object_import::read_scope_object(&remote_dir, &comp_hash).ok().flatten() + else { + return false; + }; + kind == "Component" && resolve_scope_version(&comp_val, Some(ver)).is_some() +} + +#[inline] +fn split_remote_id(raw_id: &str) -> Option<(&str, &str, Option<&str>)> { + let raw_id = raw_id.trim().trim_matches('/'); + if raw_id.starts_with('@') { + return None; + } + let (bare_id, explicit_ver) = raw_id + .rsplit_once('@') + .map_or((raw_id, None), |(id, ver)| (id.trim(), Some(ver.trim()))); + if explicit_ver == Some("") { + return None; + } + let (scope_name, comp_name) = bare_id.split_once('/')?; + let (scope_name, comp_name) = (scope_name.trim(), comp_name.trim().trim_matches('/')); + if scope_name.is_empty() || comp_name.is_empty() || comp_name.contains("//") { + return None; + } + Some((scope_name, comp_name, explicit_ver)) +} + +fn resolve_remote_scope_dir(cwd: &Path, scope_name: &str) -> Option { + if let Some(url) = lookup_local_scope_remote(cwd, scope_name) { + return local_scope_from_url(&url); + } + if let Some(url) = lookup_global_remote(scope_name) { + return local_scope_from_url(&url); + } + let base = workspace::root(cwd).unwrap_or_else(|| cwd.to_path_buf()); + let sibling = base.parent()?.join(scope_name); + sibling + .join("objects") + .is_dir() + .then_some(sibling) +} + +fn lookup_local_scope_remote(cwd: &Path, scope_name: &str) -> Option { + let scope_json_path = workspace::root(cwd) + .map(|ws_root| workspace::scope_path(&ws_root).join("scope.json")) + .or_else(|| { + cwd.ancestors() + .find_map(|dir| { + [ + dir.join("scope.json"), + dir.join(".bit/scope.json"), + dir.join(".git/bit/scope.json"), + ] + .into_iter() + .find(|path| path.is_file()) + }) + })?; + let parsed = bit_object_import::parse_jsonc(&fs::read(scope_json_path).ok()?).ok()?; + parsed["remotes"][scope_name].as_str().map(str::to_owned) +} + +fn lookup_global_remote(scope_name: &str) -> Option { + let path = cache::cache_root().join("config").join("global-remotes.json"); + let parsed = bit_object_import::parse_jsonc(&fs::read(path).ok()?).ok()?; + parsed[scope_name].as_str().map(str::to_owned) +} + +fn local_scope_from_url(url: &str) -> Option { + let url = url + .trim() + .trim_matches(['"', '\'']) + .trim(); + if url.is_empty() + || url.starts_with("http://") + || url.starts_with("https://") + || url.starts_with("ssh://") + { + return None; + } + let path = PathBuf::from(url.strip_prefix("file://").unwrap_or(url)); + path.join("objects") + .is_dir() + .then_some(path) +} + +pub(super) fn load_scope_component( + scope_dir: &Path, + scope_hint: Option<&str>, + comp_name: &str, + explicit_ver: Option<&str>, +) -> Option { + let bare_id = scope_hint + .filter(|scope| !scope.is_empty()) + .map_or_else(|| comp_name.to_owned(), |scope| format!("{scope}/{comp_name}")); + let (idx_scope, resolved_name, comp_hash) = resolve_component_entry(scope_dir, &bare_id) + .or_else(|| resolve_component_entry(scope_dir, comp_name)) + .unwrap_or_else(|| { + (scope_hint.map(str::to_owned), comp_name.to_owned(), sha1_hex(&bare_id)) + }); + let (kind, comp_val) = + bit_object_import::read_scope_object(scope_dir, &comp_hash).ok().flatten()?; + if kind != "Component" { + return None; + } + let (version_str, ver_hash) = resolve_scope_version(&comp_val, explicit_ver)?; + let (ver_kind, ver_val) = + bit_object_import::read_scope_object(scope_dir, &ver_hash).ok().flatten()?; + if ver_kind != "Version" { + return None; + } + let scope = comp_val["scope"] + .as_str() + .or(idx_scope.as_deref()) + .or(scope_hint) + .unwrap_or_default() + .to_owned(); + let name = comp_val["name"] + .as_str() + .unwrap_or(&resolved_name) + .to_owned(); + Some(build_loaded_from_version(&comp_val, &ver_val, scope, &name, version_str)) +} + +fn build_loaded_from_version( + comp_val: &Value, + ver_val: &Value, + scope: String, + name: &str, + version_str: String, +) -> LoadedComponent { + let main_file = ver_val["mainFile"] + .as_str() + .unwrap_or("index.js") + .to_owned(); + let files = extract_version_files(ver_val); + let extensions = ver_val["extensions"] + .as_array() + .cloned() + .unwrap_or_default(); + let is_removed = check_is_removed(&extensions); + let binding_prefix = comp_val["bindingPrefix"].as_str().map(str::to_owned); + let model = ModelData { binding_prefix, extensions, files }; + let comp_id = ComponentId { + scope: (!scope.is_empty()).then(|| scope.clone()), + default_scope: None, + name: name.to_owned(), + version: Some(version_str), + }; + let package_name = model.package_name(&comp_id); + let identity = Identity { scope: &scope, name, package_name: &package_name }; + let mut deps: Vec = Vec::new(); + apply_version_dependencies(ver_val, &model, &identity, &mut deps); + let ModelData { extensions, files, .. } = model; + let entry = make_remote_bitmap_entry(&comp_id, &main_file); + let component = make_remote_workspace_component( + comp_id, + files, + main_file.clone(), + package_name, + &extensions, + ); + LoadedComponent { + entry, + component, + scope, + main_file, + deps, + model_extensions: extensions, + is_removed, + } +} + +fn make_remote_bitmap_entry(comp_id: &ComponentId, main_file: &str) -> BitmapEntry { + BitmapEntry { + key: comp_id.to_string_without_version(), + id: comp_id.clone(), + root_dir: String::new(), + main_file: main_file.to_owned(), + config: None, + lanes: LaneFlags { + on_lanes_only: false, + local_only: false, + available_on_current_lane: true, + }, + next_version: None, + } +} + +fn make_remote_workspace_component( + comp_id: ComponentId, + files: Vec, + main_file: String, + package_name: String, + extensions: &[Value], +) -> WorkspaceComponent { + WorkspaceComponent { + id: comp_id, + root_dir: String::new(), + dir: PathBuf::new(), + files, + main_file, + env_id: extract_env_id(extensions), + is_env: false, + package_name, + config: extract_config_map(extensions), + has_package_json: false, + } +} + +pub(in crate::commands) fn resolve_scope_version( + comp_val: &Value, + explicit_ver: Option<&str>, +) -> Option<(String, String)> { + if let Some(req) = + explicit_ver.filter(|ver| !ver.is_empty() && !matches!(*ver, "latest" | "head")) + { + if let Some(hash) = comp_val["versions"].get(req).and_then(Value::as_str) { + return Some((req.to_owned(), hash.to_owned())); + } + if let Some(hash) = comp_val["orphanedVersions"].get(req).and_then(Value::as_str) { + return Some((req.to_owned(), hash.to_owned())); + } + if req.len() == 40 && req.bytes().all(|byte| byte.is_ascii_hexdigit()) { + let tag = find_tag_for_hash(comp_val, req).unwrap_or_else(|| req.to_owned()); + return Some((tag, req.to_owned())); + } + return None; + } + let head = comp_val["head"].as_str()?; + let tag = find_tag_for_hash(comp_val, head).unwrap_or_else(|| head.to_owned()); + Some((tag, head.to_owned())) +} + +#[inline] +pub(crate) fn find_tag_for_hash(comp_val: &Value, target_hash: &str) -> Option { + ["versions", "orphanedVersions"] + .into_iter() + .find_map(|field| { + comp_val[field] + .as_object()? + .iter() + .find_map(|(tag, val)| (val.as_str() == Some(target_hash)).then(|| tag.clone())) + }) +} + +fn extract_version_files(ver_val: &Value) -> Vec { + ver_val["files"] + .as_array() + .into_iter() + .flatten() + .filter_map(|item| { + item.as_str() + .or_else(|| item["relativePath"].as_str()) + .or_else(|| item["path"].as_str()) + .map(str::to_owned) + }) + .collect() +} + +fn extract_env_id(extensions: &[Value]) -> String { + for ext in extensions { + if ext_id_str(ext).is_some_and(|name| aspect_id_without_version(&name) == ENVS_ASPECT) + && let Some(env_id) = ext["data"]["id"] + .as_str() + .or_else(|| ext["config"]["env"].as_str()) + .filter(|val| !val.is_empty()) + { + return env_id.to_owned(); + } + } + "teambit.harmony/node".to_owned() +} + +fn extract_config_map(extensions: &[Value]) -> Map { + let mut map = Map::new(); + for ext in extensions { + if let Some(name) = ext_id_str(ext) { + let cfg = ext + .get("config") + .cloned() + .unwrap_or_else(|| Value::Object(Map::new())); + map.insert(name, cfg); + } + } + map +} + +#[inline] +fn check_is_removed(extensions: &[Value]) -> bool { + extensions + .iter() + .any(|ext| { + ext_id_str(ext).is_some_and(|name| aspect_id_without_version(&name) == REMOVE_ASPECT) + && (ext["config"]["removed"] == true || ext["data"]["removed"] == true) + }) +} + +pub(super) fn ext_id_str(ext: &Value) -> Option { + if let Some(name) = ext["name"] + .as_str() + .or_else(|| ext["legacyId"].as_str()) + .or_else(|| ext["extensionId"].as_str()) + { + return Some(name.to_owned()); + } + let id = ext.get("extensionId")?.as_object()?; + let raw_name = id.get("name")?.as_str()?; + let (name, inline_ver) = raw_name + .rsplit_once('@') + .map_or((raw_name, None), |(bare, ver)| (bare, Some(ver).filter(|ver| !ver.is_empty()))); + let base = match id + .get("scope") + .and_then(Value::as_str) + .filter(|scope| !scope.is_empty()) + { + Some(scope) => format!("{scope}/{name}"), + None => name.to_owned(), + }; + let version = id + .get("version") + .and_then(Value::as_str) + .filter(|ver| !ver.is_empty()) + .or(inline_ver); + Some(match version { + Some(ver) => format!("{base}@{ver}"), + None => base, + }) +} diff --git a/native/cli/src/commands/show_cmd/render.rs b/native/cli/src/commands/show_cmd/render.rs new file mode 100644 index 000000000000..78c3ed8329e7 --- /dev/null +++ b/native/cli/src/commands/show_cmd/render.rs @@ -0,0 +1,343 @@ +//! JSON (`--json`, `--json --legacy`) and text table rendering for native `bit show`. +//! +//! Formats [`LoadedComponent`] and [`DepEntry`] metadata ([`PKG_ASPECT`], [`DEV_FILES_ASPECT`]) via [`legacy_json`] and table formatters. + +use super::{deps::DepEntry, remote_show::ext_id_str, workspace_show::LoadedComponent}; +use bit_install::config::{DEPENDENCY_RESOLVER_ASPECT, aspect_id_without_version}; +use serde_json::{Map, Value, json}; + +/// Aspect ID for the `teambit.pkg/pkg` aspect. +const PKG_ASPECT: &str = "teambit.pkg/pkg"; +/// Aspect ID for the `teambit.component/dev-files` aspect. +const DEV_FILES_ASPECT: &str = "teambit.component/dev-files"; + +/// Serializes `loaded` into the `bit show --json --legacy` consumer component JSON layout. +pub(super) fn legacy_json(loaded: &LoadedComponent) -> Value { + let comp_deps = component_dep_list(&loaded.deps, "runtime"); + let dev_comp_deps = component_dep_list(&loaded.deps, "dev"); + let peer_comp_deps = component_dep_list(&loaded.deps, "peer"); + let pkg_deps = package_dep_map(&loaded.deps, "runtime"); + let dev_pkg_deps = package_dep_map(&loaded.deps, "dev"); + let peer_pkg_deps = package_dep_map(&loaded.deps, "peer"); + let extensions = build_extensions_array(loaded, true); + let package_properties = extract_package_properties(loaded); + json!({ + "name": loaded.entry.id.name, + "scope": loaded.scope, + "version": loaded.entry.id.version, + "mainFile": loaded.main_file, + "rootDir": loaded.entry.root_dir, + "componentDir": loaded.entry.root_dir, + "files": loaded.component.files, + "dependencies": comp_deps, + "devDependencies": dev_comp_deps, + "peerDependencies": peer_comp_deps, + "packageDependencies": pkg_deps, + "devPackageDependencies": dev_pkg_deps, + "peerPackageDependencies": peer_pkg_deps, + "extensions": extensions, + "packageProperties": package_properties, + }) +} + +/// Serializes `loaded` into the Harmony `bit show --json` section array layout. +pub(super) fn harmony_json(loaded: &LoadedComponent) -> Value { + let deps_json = serialize_deps(&loaded.deps); + let extensions = build_extensions_array(loaded, false); + let dev_files = build_dev_files_map(loaded); + json!([ + { "title": "id", "json": loaded.full_id() }, + { "title": "name", "json": loaded.entry.id.name }, + { "title": "scope", "json": loaded.scope }, + { "title": "package name", "json": loaded.component.package_name }, + { "title": "env", "json": loaded.component.env_id }, + { "title": "main file", "json": loaded.main_file }, + { "title": "files", "json": loaded.component.files }, + { "title": "dev files", "json": dev_files }, + { "title": "dependencies", "json": deps_json }, + { "title": "configuration", "json": extensions }, + { "title": "extensions", "json": extensions }, + ]) +} + +pub(super) fn text_report(loaded: &LoadedComponent) -> String { + let mut lines = vec![ + format!("id: {}", loaded.full_id()), + format!("scope: {}", loaded.scope), + format!("name: {}", loaded.entry.id.name), + format!("packageName: {}", loaded.component.package_name), + format!("env: {}", loaded.component.env_id), + format!("mainFile: {}", loaded.main_file), + format!("files: {}", loaded.component.files.join(", ")), + ]; + if !loaded.deps.is_empty() { + for lifecycle in ["runtime", "dev", "peer"] { + let rendered = format_lifecycle_deps(&loaded.deps, lifecycle); + if !rendered.is_empty() { + lines.push(format!("{lifecycle} dependencies:\n{rendered}")); + } + } + } + if loaded.is_removed { + lines.push("removed: true".to_owned()); + } + lines.join("\n") +} + +fn format_lifecycle_deps(deps: &[DepEntry], lifecycle: &str) -> String { + let mut filtered = deps + .iter() + .filter(|dep| dep.lifecycle == lifecycle) + .collect::>(); + if filtered.is_empty() { + return String::new(); + } + filtered.sort_by(|left, right| left.id.cmp(&right.id)); + let labels = filtered + .iter() + .copied() + .map(dep_label) + .collect::>(); + let longest = labels + .iter() + .map(String::len) + .max() + .unwrap_or(50); + let pad_width = longest + 1; + let mut out = Vec::with_capacity(filtered.len() * 2); + for (dep, name_ver) in filtered.into_iter().zip(labels) { + let padded = format!("{name_ver:- String { + if dep.dep_type == "component" && dep.id.contains('@') { + dep.id.clone() + } else { + format!("{}@{}", dep.id, dep.version) + } +} + +#[inline] +fn component_dep_list(deps: &[DepEntry], lifecycle: &str) -> Vec { + deps.iter() + .filter(|dep| dep.lifecycle == lifecycle && dep.dep_type == "component") + .map(|dep| { + json!({ + "id": dep.id, + "relativePaths": dep.relative_paths, + "packageName": dep.package_name, + }) + }) + .collect() +} + +#[inline] +fn package_dep_map(deps: &[DepEntry], lifecycle: &str) -> Map { + deps.iter() + .filter(|dep| dep.lifecycle == lifecycle && dep.dep_type == "package") + .map(|dep| (dep.id.clone(), Value::String(dep.version.clone()))) + .collect() +} + +#[inline] +fn serialize_deps(deps: &[DepEntry]) -> Vec { + deps.iter() + .map(|dep| { + json!({ + "id": dep.id, + "version": dep.version, + "lifecycle": dep.lifecycle, + "type": dep.dep_type, + "optional": dep.optional, + "packageName": dep.package_name, + }) + }) + .collect() +} + +fn build_extensions_array(loaded: &LoadedComponent, legacy: bool) -> Vec { + let deps_json = serialize_deps(&loaded.deps); + let mut has_dep_resolver = false; + let mut has_dev_files = false; + let mut out = Vec::with_capacity(loaded.component.config.len() + loaded.model_extensions.len()); + for (ext_id, ext_cfg) in &loaded.component.config { + let bare = aspect_id_without_version(ext_id); + let is_dep_res = bare == DEPENDENCY_RESOLVER_ASPECT; + if is_dep_res { + has_dep_resolver = true; + } + if bare == DEV_FILES_ASPECT { + has_dev_files = true; + } + let data = resolve_extension_data(loaded, bare, is_dep_res, &deps_json); + out.push(format_extension_entry(ext_id, ext_cfg, &data, legacy)); + } + if !has_dep_resolver && !loaded.deps.is_empty() { + out.push(format_extension_entry( + DEPENDENCY_RESOLVER_ASPECT, + &json!({}), + &json!({ "dependencies": deps_json }), + legacy, + )); + } + if !has_dev_files && has_any_dev_files(loaded) { + out.push(format_extension_entry( + DEV_FILES_ASPECT, + &json!({}), + &json!({ "devFiles": build_dev_files_map(loaded) }), + legacy, + )); + } + append_unlisted_model_extensions(loaded, legacy, &mut out); + out +} + +#[inline] +fn resolve_extension_data( + loaded: &LoadedComponent, + bare_id: &str, + is_dep_res: bool, + deps_json: &[Value], +) -> Value { + if is_dep_res { + return json!({ "dependencies": deps_json }); + } + if bare_id == DEV_FILES_ASPECT { + return json!({ "devFiles": build_dev_files_map(loaded) }); + } + find_model_ext_data(&loaded.model_extensions, bare_id).unwrap_or_else(|| json!({})) +} + +#[inline] +fn find_model_ext_data(model_exts: &[Value], bare_id: &str) -> Option { + model_exts + .iter() + .find_map(|ext| { + let name = ext_id_str(ext)?; + (aspect_id_without_version(name.trim()) == bare_id) + .then(|| { + ext.get("data") + .filter(|val| !val.is_null()) + .cloned() + }) + .flatten() + }) +} + +fn append_unlisted_model_extensions(loaded: &LoadedComponent, legacy: bool, out: &mut Vec) { + for ext in &loaded.model_extensions { + if ext["config"].as_str() == Some("-") { + continue; + } + let Some(name) = ext_id_str(ext) else { + continue; + }; + let bare = aspect_id_without_version(&name); + let already_present = out + .iter() + .any(|existing| { + existing["name"] + .as_str() + .is_some_and(|existing_name| aspect_id_without_version(existing_name) == bare) + }); + if !already_present { + let cfg = ext.get("config").unwrap_or(&Value::Null); + let data = ext.get("data").unwrap_or(&Value::Null); + out.push(format_extension_entry(&name, cfg, data, legacy)); + } + } +} + +#[inline] +fn has_any_dev_files(loaded: &LoadedComponent) -> bool { + let is_readme = aspect_id_without_version(&loaded.component.env_id) == "teambit.mdx/readme"; + is_readme + || loaded.component.files + .iter() + .any(|file| { + file.contains(".spec.") + || file.contains(".test.") + || file.contains(".docs.") + || file.contains(".composition.") + || file.contains(".compositions.") + }) +} + +fn build_dev_files_map(loaded: &LoadedComponent) -> Value { + let tester = matching_files(&loaded.component.files, |file| { + file.contains(".spec.") || file.contains(".test.") + }); + let is_readme = aspect_id_without_version(&loaded.component.env_id) == "teambit.mdx/readme"; + let mut docs = if is_readme { + Vec::new() + } else { + matching_files(&loaded.component.files, |file| file.contains(".docs.")) + }; + let compositions = matching_files(&loaded.component.files, |file| { + file.contains(".composition.") || file.contains(".compositions.") + }); + let main_file = loaded.main_file.trim(); + if is_readme && !main_file.is_empty() { + let entry = Value::String(main_file.to_owned()); + if !docs.contains(&entry) { + docs.push(entry); + } + } + json!({ + "teambit.defender/tester": tester, + "teambit.docs/docs": docs, + "teambit.compositions/compositions": compositions, + }) +} + +#[inline] +fn matching_files(files: &[String], predicate: impl Fn(&str) -> bool) -> Vec { + files + .iter() + .map(|file| file.trim()) + .filter(|file| !file.is_empty() && predicate(file)) + .map(|file| Value::String(file.to_owned())) + .collect() +} + +#[inline] +fn format_extension_entry(ext_id: &str, config: &Value, data: &Value, legacy: bool) -> Value { + if legacy { + json!({ + "name": ext_id, + "extensionId": ext_id, + "config": config, + "data": data, + }) + } else { + json!({ + "id": ext_id, + "name": ext_id, + "config": config, + "data": data, + }) + } +} + +#[inline] +fn extract_package_properties(loaded: &LoadedComponent) -> Value { + for (ext_id, ext_cfg) in &loaded.component.config { + if aspect_id_without_version(ext_id) == PKG_ASPECT + && let Some(pkg_json) = ext_cfg + .get("packageJson") + .or_else(|| ext_cfg.get("packageJsonProps")) + .filter(|val| val.is_object()) + { + return pkg_json.clone(); + } + } + json!({}) +} diff --git a/native/cli/src/commands/show_cmd/tests.rs b/native/cli/src/commands/show_cmd/tests.rs new file mode 100644 index 000000000000..217bfbb9ba0e --- /dev/null +++ b/native/cli/src/commands/show_cmd/tests.rs @@ -0,0 +1,113 @@ +//! Unit tests for `bit show` [`Options`] CLI parsing ([`parse_options`]), [`missing_host_error`], and legacy/Harmony/text report rendering ([`legacy_json`], [`harmony_json`], [`text_report`]). +//! +//! Verifies [`TargetOutcome`] and [`load_target_outcome`] workspace and scope inspection. + +use super::{ + Options, missing_host_error, parse_options, + render::{harmony_json, legacy_json, text_report}, + workspace_show::{TargetOutcome, load_target_outcome}, +}; +use serde_json::json; +use std::{fs, path::Path}; + +/// Verifies `bit show` [`Options`] parsing via [`parse_options`] and host checks via [`missing_host_error`]. +#[test] +fn parses_show_options_and_detects_missing_host_errors() { + assert_eq!(parse_options(&[]), None); + assert_eq!(parse_options(&["sample", "--json", "--no-color"]), None); + let opts = parse_options(&["comp1", "-j", "-r", "-c"]).unwrap(); + assert_eq!( + opts, + Options { + id: "comp1".into(), + json: true, + legacy: false, + remote: true, + browser: false, + compare: true, + }, + ); + assert_eq!( + parse_options(&["comp1", "-jlr"]), + Some(Options { + id: "comp1".into(), + json: true, + legacy: true, + remote: true, + browser: false, + compare: false, + }), + ); + let temp = tempfile::tempdir().unwrap(); + assert!(missing_host_error(temp.path(), &opts).unwrap().contains("--legacy")); + let local_opts = parse_options(&["comp1"]).unwrap(); + assert!( + missing_host_error(temp.path(), &local_opts).unwrap().contains("Consider using --remote"), + ); +} + +/// Writes a sample `workspace.jsonc` and `.bitmap` fixture for `bit show` unit tests. +fn write_fixture_workspace(root: &Path) { + let ws_json = json!({ + "teambit.workspace/workspace": { "defaultScope": "my-scope" }, + "teambit.workspace/variants": { + "*": { "teambit.pkg/pkg": { "packageJson": { "private": true } } }, + }, + }); + let bitmap_json = json!({ + "comp1": { + "scope": "", + "version": "", + "mainFile": "index.js", + "rootDir": "comp1", + "config": { + "teambit.dependencies/dependency-resolver": { + "policy": { + "dependencies": { + "is-positive": { "optional": true, "version": "1.0.0" }, + }, + }, + }, + }, + }, + }); + fs::write(root.join("workspace.jsonc"), serde_json::to_string_pretty(&ws_json).unwrap()) + .unwrap(); + fs::write(root.join(".bitmap"), serde_json::to_string_pretty(&bitmap_json).unwrap()).unwrap(); + fs::create_dir_all(root.join("comp1")).unwrap(); + fs::write(root.join("comp1/index.js"), "import { a } from '@my-scope/comp1';\n").unwrap(); +} + +#[test] +fn renders_legacy_harmony_and_text_for_workspace_component() { + let temp = tempfile::tempdir().unwrap(); + write_fixture_workspace(temp.path()); + + let opts = parse_options(&["comp1", "--json", "--legacy"]).unwrap(); + let TargetOutcome::Loaded(loaded) = load_target_outcome(temp.path(), &opts) else { + panic!("fixture component should load"); + }; + let legacy = legacy_json(&loaded); + assert_eq!(legacy["name"], "comp1"); + assert_eq!( + legacy["dependencies"] + .as_array() + .unwrap() + .len(), + 0, + ); + assert_eq!(legacy["packageProperties"]["private"], json!(true)); + + let harmony = harmony_json(&loaded); + assert!(harmony.is_array()); + assert!(harmony.to_string().contains("comp1")); + + let text = text_report(&loaded); + assert!(text.contains("is-positive@1.0.0 (optional)"), "{text}"); +} + +#[test] +fn rejects_unknown_show_flags_or_multiple_ids() { + assert_eq!(parse_options(&["comp1", "comp2"]), None); + assert_eq!(parse_options(&["comp1", "--unknown"]), None); +} diff --git a/native/cli/src/commands/show_cmd/workspace_show.rs b/native/cli/src/commands/show_cmd/workspace_show.rs new file mode 100644 index 000000000000..544eae746108 --- /dev/null +++ b/native/cli/src/commands/show_cmd/workspace_show.rs @@ -0,0 +1,385 @@ +//! Workspace component loading and eligibility checks for native `bit show`. +//! +//! Loads workspace or scope components ([`Options`], [`LoadedComponent`], [`resolve_component_entry`]) and formats output via [`render`]. + +use super::{ + Options, + deps::{self, CompContext, DepEntry}, + remote_show, render, +}; +use crate::{ + commands::{ + debug_cat::lookup::resolve_component_entry, + inventory, + scope_set::selection::valid_scope_str, + workspace_command::{self, results::Report}, + }, + native::Outcome, + workspace, +}; +use bit_install::{ + config::{DEPENDENCY_RESOLVER_ASPECT, WorkspaceConfig, aspect_id_without_version}, + model::WorkspaceComponent, + workspace::{BitmapEntry, ModelData, ScopeObjects, Workspace, sha1_hex}, +}; +use serde_json::Value; +use std::{ffi::OsString, io, path::Path}; + +/// Embedded JSON array of built-in core aspect IDs. +const CORE_ASPECT_IDS: &str = + include_str!("../../../../../scopes/harmony/testing/load-aspect/core-aspects-ids.json"); +/// Diagnostic message emitted when `.bitmap` references component versions absent from local scope objects. +const IMPORT_PENDING: &str = r#"your workspace has outdated objects. please use "bit import" to pull the latest objects from the remote scope."#; + +pub(super) struct LoadedComponent { + pub entry: BitmapEntry, + pub component: WorkspaceComponent, + pub scope: String, + pub main_file: String, + pub deps: Vec, + pub model_extensions: Vec, + pub is_removed: bool, +} + +impl LoadedComponent { + #[inline] + pub(super) fn full_id(&self) -> String { + let base = if self.scope.is_empty() { + self.entry.id.name.clone() + } else { + format!("{}/{}", self.scope, self.entry.id.name) + }; + match self.entry.id.version.as_deref() { + Some(ver) if !ver.is_empty() && !matches!(ver, "latest" | "head") => { + format!("{base}@{ver}") + } + _ => base, + } + } +} + +pub(super) enum TargetOutcome { + Loaded(Box), + OutdatedObjects, + Declined, +} + +pub(super) fn try_run( + cwd: &Path, + args: &[OsString], + options: &Options, +) -> io::Result> { + if options.remote || options.compare || options.browser { + return Ok(None); + } + let Some(root) = workspace::root(cwd) else { + if cwd.join("scope.json").is_file() + && cwd.join("objects").is_dir() + && let Some(loaded) = remote_show::load_from_local_scope(cwd, &options.id) + { + return remote_show::render_remote_outcome(cwd, args, options, &loaded); + } + return Ok(None); + }; + if let Some(err_msg) = invalid_default_scope_error(&root) { + let outcome = + super::super::lint_cmd::emit_pattern_error(&root, args, "show", options.json, err_msg)?; + return Ok(Some(outcome)); + } + match load_target_outcome(&root, options) { + TargetOutcome::Loaded(loaded) => render_workspace_outcome(&root, args, options, &loaded), + TargetOutcome::OutdatedObjects => { + let outcome = super::super::lint_cmd::emit_pattern_error( + &root, + args, + "show", + options.json, + IMPORT_PENDING.into(), + )?; + Ok(Some(outcome)) + } + TargetOutcome::Declined => Ok(None), + } +} + +fn render_workspace_outcome( + root: &Path, + args: &[OsString], + options: &Options, + loaded: &LoadedComponent, +) -> io::Result> { + if options.json { + let payload = + if options.legacy { render::legacy_json(loaded) } else { render::harmony_json(loaded) }; + let data = serde_json::to_string_pretty(&payload)?; + return workspace_command::execute_json(root, args, "show", |_| { + Ok(Report { data, code: 0, details: None }) + }) + .map(Some); + } + let data = render::text_report(loaded); + workspace_command::execute_then(root, args, "show", |_| { + Ok(Report { data, code: 0, details: None }) + }) + .map(Some) +} + +#[inline] +fn invalid_default_scope_error(root: &Path) -> Option { + let ws_config = WorkspaceConfig::load(root).ok()?; + let default_scope = ws_config.workspace.default_scope.as_deref()?; + (!default_scope.is_empty() && !valid_scope_str(default_scope)).then(|| { + format!( + r#"error: "{default_scope}" is invalid, component scope names can only contain alphanumeric, lowercase characters, and the following ["-", "_", "$", "!"]"#, + ) + }) +} + +pub(super) fn load_target_outcome(root: &Path, options: &Options) -> TargetOutcome { + if root.join("merge-conflict").exists() { + return TargetOutcome::Declined; + } + let Ok(ws_config) = WorkspaceConfig::load(root) else { + return TargetOutcome::Declined; + }; + if !is_supported_workspace(&ws_config) { + return TargetOutcome::Declined; + } + let Ok(core_ids) = serde_json::from_str::>(CORE_ASPECT_IDS) else { + return TargetOutcome::Declined; + }; + let Ok(ws) = Workspace::load(root, &ws_config, &core_ids) else { + return TargetOutcome::Declined; + }; + if has_bitmap_peer_component(&ws) { + return TargetOutcome::Declined; + } + resolve_target_in_workspace(root, &ws_config, &ws, options) +} + +fn resolve_target_in_workspace( + root: &Path, + ws_config: &WorkspaceConfig, + ws: &Workspace, + options: &Options, +) -> TargetOutcome { + if let Some((bare_id, _)) = options.id.rsplit_once('@') { + if find_component(ws, bare_id).is_none() + && let Some(loaded) = remote_show::load_from_local_scope(&ws.scope_path, &options.id) + { + return TargetOutcome::Loaded(Box::new(loaded)); + } + return TargetOutcome::Declined; + } + let Some((entry, component)) = find_component(ws, &options.id) else { + if let Some(loaded) = remote_show::load_from_local_scope(&ws.scope_path, &options.id) { + return TargetOutcome::Loaded(Box::new(loaded)); + } + return TargetOutcome::Declined; + }; + build_workspace_target(root, ws_config, ws, entry, component) +} + +fn build_workspace_target( + root: &Path, + ws_config: &WorkspaceConfig, + ws: &Workspace, + entry: &BitmapEntry, + component: &WorkspaceComponent, +) -> TargetOutcome { + let scope = entry.id + .effective_scope() + .or(ws_config.workspace.default_scope.as_deref()) + .unwrap_or_default() + .to_owned(); + let (model, ver_val) = load_local_model(&ws.scope_path, &scope, entry); + if let Some(outcome) = missing_local_version_outcome(root, entry, ver_val.as_ref()) { + return outcome; + } + if !is_supported_component(entry, component) { + return TargetOutcome::Declined; + } + let main_file = resolve_main_file(entry, component); + let ctx = + CompContext { root, ws_config, ws, entry, component, model: model.as_ref(), scope: &scope }; + let deps = deps::collect_dependencies(&ctx); + let model_extensions = model.map(|data| data.extensions).unwrap_or_default(); + TargetOutcome::Loaded(Box::new(LoadedComponent { + entry: entry.clone(), + component: component.clone(), + scope, + main_file, + deps, + model_extensions, + is_removed: entry.is_removed(), + })) +} + +fn missing_local_version_outcome( + root: &Path, + entry: &BitmapEntry, + ver_val: Option<&Value>, +) -> Option { + let ver = entry.id.version + .as_deref() + .filter(|ver| !ver.is_empty())?; + if ver_val.is_some() { + return None; + } + let Some(scope) = entry.id + .effective_scope() + .filter(|scope| !scope.is_empty()) + else { + return Some(TargetOutcome::Declined); + }; + if remote_show::remote_has_version(root, scope, &entry.id.name, ver) { + Some(TargetOutcome::OutdatedObjects) + } else { + Some(TargetOutcome::Declined) + } +} + +#[inline] +fn load_local_model( + scope_dir: &Path, + scope: &str, + entry: &BitmapEntry, +) -> (Option, Option) { + let model = ScopeObjects::new(scope_dir) + .component_or_head(&entry.id) + .ok() + .flatten(); + let ver_val = read_local_version_object(scope_dir, scope, entry); + (model, ver_val) +} + +fn read_local_version_object(scope_dir: &Path, scope: &str, entry: &BitmapEntry) -> Option { + let bare_id = + if scope.is_empty() { entry.id.name.clone() } else { format!("{scope}/{}", entry.id.name) }; + let comp_hash = resolve_component_entry(scope_dir, &bare_id) + .or_else(|| resolve_component_entry(scope_dir, &entry.id.name)) + .map_or_else(|| sha1_hex(&bare_id), |(_, _, hash)| hash); + let (kind, comp_val) = + bit_object_import::read_scope_object(scope_dir, &comp_hash).ok().flatten()?; + if kind != "Component" { + return None; + } + let (_, ver_hash) = remote_show::resolve_scope_version(&comp_val, entry.id.version.as_deref())?; + let (ver_kind, ver_val) = + bit_object_import::read_scope_object(scope_dir, &ver_hash).ok().flatten()?; + (ver_kind == "Version").then_some(ver_val) +} + +fn is_supported_workspace(ws_config: &WorkspaceConfig) -> bool { + if ws_config.workspace.bitmap_auto_sync == Some(true) { + return false; + } + let Some(default_scope) = ws_config.workspace.default_scope.as_deref() else { + return false; + }; + if !valid_scope_str(default_scope) { + return false; + } + let has_variant_dep_resolver = ws_config.variants + .iter() + .any(|(_, cfg)| { + cfg.keys() + .any(|key| aspect_id_without_version(key) == DEPENDENCY_RESOLVER_ASPECT) + }); + let has_peer_policy = ws_config.dependency_resolver.policy + .get("peerDependencies") + .and_then(Value::as_object) + .is_some_and(|map| !map.is_empty()); + !has_variant_dep_resolver && !has_peer_policy +} + +#[inline] +fn has_bitmap_peer_component(ws: &Workspace) -> bool { + ws.entries + .iter() + .any(|entry| { + crate::commands::update_cmd::bitmap_entry_aspect(entry, DEPENDENCY_RESOLVER_ASPECT) + .is_some_and(|dep_cfg| { + dep_cfg.get("peer").is_some() + || dep_cfg + .get("policy") + .and_then(|pol| pol.get("peerDependencies")) + .is_some() + }) + }) +} + +fn find_component<'a>( + ws: &'a Workspace, + raw_id: &str, +) -> Option<(&'a BitmapEntry, &'a WorkspaceComponent)> { + let raw_id = raw_id.trim(); + let normalized = inventory::normalize(raw_id); + let dir = normalized + .strip_prefix("./") + .unwrap_or(&normalized) + .trim_end_matches('/'); + ws.entries + .iter() + .zip(&ws.components) + .find(|(entry, _)| entry.owns_its_dir() && matches_entry(entry, raw_id, dir)) + .or_else(|| { + ws.entries + .iter() + .zip(&ws.components) + .find(|(entry, _)| matches_entry(entry, raw_id, dir)) + }) +} + +#[inline] +fn matches_entry(entry: &BitmapEntry, raw_id: &str, dir: &str) -> bool { + entry.id.name == raw_id + || entry.key == raw_id + || (!entry.root_dir.is_empty() && entry.root_dir.trim_end_matches('/') == dir) + || entry.id.to_string_without_version() == raw_id +} + +#[inline] +fn is_supported_component(entry: &BitmapEntry, component: &WorkspaceComponent) -> bool { + if entry.id.version.is_some() { + return false; + } + let env_bare = aspect_id_without_version(&component.env_id); + if !matches!(env_bare, "teambit.harmony/node" | "teambit.mdx/readme" | "teambit.mdx/mdx") { + return false; + } + let has_special_aspect = component.config + .keys() + .any(|key| { + matches!( + aspect_id_without_version(key), + "teambit.component/internalize" + | "teambit.pipelines/builder" + | "teambit.component/deprecation", + ) + }); + if has_special_aspect { + return false; + } + true +} + +#[inline] +fn resolve_main_file(entry: &BitmapEntry, component: &WorkspaceComponent) -> String { + let trimmed = entry.main_file.trim(); + if !trimmed.is_empty() { + return trimmed.to_owned(); + } + for candidate in ["index.ts", "index.tsx", "index.js", "index.jsx"] { + if component.files + .iter() + .any(|file| file == candidate) + { + return candidate.to_owned(); + } + } + component.files + .first() + .cloned() + .unwrap_or_else(|| "index.js".to_owned()) +} diff --git a/native/cli/src/commands/snap.rs b/native/cli/src/commands/snap.rs new file mode 100644 index 000000000000..5f995fa5d688 --- /dev/null +++ b/native/cli/src/commands/snap.rs @@ -0,0 +1,185 @@ +//! Native `bit snap [component-pattern]`: the engine plans and writes the +//! snaps ([`resolve`]), with the snap [`options`] and [`Report`]. +//! +//! Coordinates [`COMMAND`], [`MESSAGE_WARNING`], [`Startup`] autosync, [`Planned`] execution, and [`versions`] output formatting. + +mod options; + +use super::{ + color::Chalk, + status::autosync::Startup, + workspace_command::{self, Planned, progress::Progress, resolve, results::Report, versions}, +}; +use crate::{commands::Command, native::Outcome, workspace}; +use std::{ + env, + ffi::OsString, + io::{self, Write}, + path::Path, +}; + +/// Descriptor for the native `bit snap` command. +pub const COMMAND: Command = Command { name: "snap", aliases: &[], run }; + +/// The warning `bit snap` prints without `--message`. +const MESSAGE_WARNING: &str = "--message will be mandatory in the next few releases. make sure to add a message with your snap, will be displayed in the version history"; + +/// Entry point for `bit snap`. +fn run(args: &[OsString]) -> io::Result { + let Some(argv) = workspace_command::utf8_args(args) else { + return Ok(Outcome::Declined("arguments are not UTF-8".to_string())); + }; + let Some((mut workflow, command_args)) = + resolve::workflow::parse(&argv[1..], resolve::Kind::Snap) + else { + return Ok(Outcome::Declined("outside the native snap tooling grammar".to_string())); + }; + let Some((policy, command_args)) = resolve::policy::parse(&command_args, false) else { + return Ok(Outcome::Declined("outside the native snapshot policy grammar".to_string())); + }; + let Some(options) = options::parse(&command_args) else { + return Ok(Outcome::Declined("outside the native snap grammar".to_string())); + }; + let cwd = env::current_dir()?; + let Some(root) = workspace::root(&cwd) else { + return outside_workspace_snap(&cwd, args, &options, &workflow); + }; + workflow.configure(&root, &argv[1..], resolve::Kind::Snap)?; + run_workspace(&root, args, &options, &workflow, &policy) +} + +/// Handles `bit snap` when invoked outside a workspace. +fn outside_workspace_snap( + cwd: &Path, + args: &[OsString], + options: &options::Options, + workflow: &resolve::workflow::Workflow, +) -> io::Result { + if env::var_os("CI") + .is_some_and(|val| { + let text = val.to_string_lossy(); + let trimmed = text + .trim() + .trim_matches(['"', '\'']) + .trim(); + !trimmed.is_empty() + && !["0", "false", "no", "off"] + .iter() + .any(|falsy| trimmed.eq_ignore_ascii_case(falsy)) + }) + { + return Ok(Outcome::Declined("not inside a workspace".to_string())); + } + let warn = should_warn_message(options, workflow); + workspace_command::execute_then_scope(cwd, args, "snap", |output| { + if warn { + output.warning(MESSAGE_WARNING); + } + Err(io::Error::other(super::log::OUTSIDE_WORKSPACE_ERR)) + }) +} + +#[inline] +fn should_warn_message(options: &options::Options, workflow: &resolve::workflow::Workflow) -> bool { + options.message + .as_deref() + .is_none_or(|message| message.trim().is_empty()) + && workflow.editor + .as_deref() + .is_none_or(|editor| editor.trim().is_empty()) +} + +fn run_workspace( + root: &Path, + args: &[OsString], + options: &options::Options, + workflow: &resolve::workflow::Workflow, + policy: &resolve::policy::Policy, +) -> io::Result { + let mut startup = Startup::default(); + let prepared = prepare(root, args, options, workflow, policy, &mut startup); + let warn = should_warn_message(options, workflow); + let (mut planned, report, progress) = match prepared { + Ok(prepared) => prepared, + Err(error) if startup.committed => return failure(root, args, warn, &startup, error), + Err(error) => return Ok(Outcome::Declined(format!("snap: {error}"))), + }; + if !startup.stdout.is_empty() { + let mut stdout = io::stdout(); + stdout.write_all(startup.stdout.as_bytes())?; + stdout.flush()?; + } + workspace_command::execute_then(root, args, "snap", |output| { + if warn { + output.warning(MESSAGE_WARNING); + } + resolve::render_events(output, &startup)?; + render_progress(output, progress.as_ref(), !startup.snapshot_events.is_empty()); + resolve::render_build_output(output, &startup, false)?; + workspace_command::commit(&mut planned, root)?; + super::status::cleanup_temporary(root)?; + Ok(report) + }) +} + +fn prepare( + root: &Path, + args: &[OsString], + options: &options::Options, + workflow: &resolve::workflow::Workflow, + policy: &resolve::policy::Policy, + startup: &mut Startup, +) -> io::Result<(Planned, Report, Option)> { + let mut request_options = options.request(); + policy.apply(&mut request_options); + let request = resolve::ResolveRequest { + kind: resolve::Kind::Snap, + options: request_options, + selection: resolve::Selection::Snap { + pattern: options.pattern + .as_deref() + .map(str::trim) + .filter(|pat| !pat.is_empty()), + unmerged: options.unmerged, + }, + }; + let planned = resolve::plan_snapshot(root, &request, startup, workflow)?; + let report = versions::format(&planned.plan["result"], Chalk::detect(args), &versions::SNAP)?; + let progress = Progress::parse_snap(planned.plan.get("progress"))?; + Ok((planned, report, progress)) +} + +#[inline] +fn failure( + root: &Path, + args: &[OsString], + warn: bool, + startup: &Startup, + error: io::Error, +) -> io::Result { + if !startup.stdout.is_empty() { + io::stdout().write_all(startup.stdout.as_bytes())?; + } + workspace_command::execute_then(root, args, "snap", |output| { + if warn { + output.warning(MESSAGE_WARNING); + } + resolve::render_build_output(output, startup, true)?; + Err(error) + }) +} + +#[inline] +fn render_progress( + output: &bit_install::output::Output, + progress: Option<&Progress>, + events: bool, +) { + if let Some(progress) = progress { + if events { + progress.render_graph(output); + } else { + progress.render_merge(output); + } + } +} diff --git a/native/cli/src/commands/snap/options.rs b/native/cli/src/commands/snap/options.rs new file mode 100644 index 000000000000..163f04bab19a --- /dev/null +++ b/native/cli/src/commands/snap/options.rs @@ -0,0 +1,149 @@ +//! The native snap grammar: ordinary snaps only. Build, editor and +//! hook-altering flags stay with the Node.js CLI. +//! +//! Defines [`Options`] and [`parse`] and serializes snap arguments into JSON [`Value`] payloads. + +use serde_json::Value; + +/// Parsed CLI options for the native `bit snap [component-pattern]` command. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct Options { + pub pattern: Option, + pub message: Option, + pub unmodified: bool, + pub unmerged: bool, + pub skip_auto_snap: bool, +} + +/// `args` starts after the command name. +#[must_use] +pub fn parse(args: &[&str]) -> Option { + let mut options = Options::default(); + let mut seen: Vec<&str> = Vec::with_capacity(4); + let mut index = 0; + while index < args.len() { + let raw = args[index]; + index += 1; + if raw == "--" { + options.trailing_positionals(&args[index..])?; + break; + } + let flag = canonical_flag(raw); + if matches!( + flag, + "--no-color" + | "--no-color=true" + | "--no-color=false" + | "--color" + | "--color=true" + | "--color=false" + | "--color=always" + | "--color=never", + ) { + continue; + } + if !flag.starts_with('-') { + options.positional(flag)?; + continue; + } + let canonical = canonical_flag(flag.split_once('=').map_or(flag, |(name, _)| name)); + (!seen.contains(&canonical)).then_some(())?; + seen.push(canonical); + index += options.flag(flag, args.get(index).copied())?; + } + Some(options) +} + +/// Maps short and negated `bit snap` flags to their canonical long option names. +#[inline] +fn canonical_flag(raw: &str) -> &str { + match raw.trim() { + "-m" => "--message", + "-u" | "--no-unmodified" => "--unmodified", + "--no-unmerged" => "--unmerged", + "--no-skip-auto-snap" => "--skip-auto-snap", + other => other, + } +} + +/// A bounded workspace pattern, including state predicates. +#[inline] +fn pattern(text: &str) -> bool { + !text.trim().is_empty() && text.len() <= 4096 && !text.contains('\0') +} + +impl Options { + #[inline] + fn trailing_positionals(&mut self, trailing: &[&str]) -> Option<()> { + for &pos in trailing { + (!pos.starts_with('-')).then_some(())?; + self.positional(pos)?; + } + Some(()) + } + + /// The one pattern. + #[inline] + fn positional(&mut self, text: &str) -> Option<()> { + if self.pattern.is_some() || !pattern(text) { + return None; + } + self.pattern = Some(text.trim().to_string()); + Some(()) + } + + /// One flag; the number of values it consumed. + fn flag(&mut self, flag: &str, next: Option<&str>) -> Option { + if let Some(val) = flag + .strip_prefix("--message=") + .or_else(|| flag.strip_prefix("-m=")) + { + if val.starts_with('-') { + return None; + } + self.message = Some(val.to_string()); + return Some(0); + } + match flag { + "--message" => { + let message = next.filter(|message| !message.starts_with('-'))?; + self.message = Some(message.to_string()); + return Some(1); + } + "--unmodified" | "--unmodified=true" | "-u=true" => self.unmodified = true, + "--unmodified=false" | "--no-unmodified" | "-u=false" => self.unmodified = false, + "--unmerged" | "--unmerged=true" => self.unmerged = true, + "--unmerged=false" | "--no-unmerged" => self.unmerged = false, + "--skip-auto-snap" | "--skip-auto-snap=true" => self.skip_auto_snap = true, + "--skip-auto-snap=false" | "--no-skip-auto-snap" => self.skip_auto_snap = false, + _ => return None, + } + Some(0) + } + + /// The engine's snap options. + #[must_use] + #[inline] + pub fn request(&self) -> Value { + let mut options = serde_json::Map::new(); + if let Some(pattern) = &self.pattern { + options.insert("pattern".into(), Value::String(pattern.clone())); + } + if let Some(message) = &self.message { + options.insert("message".into(), Value::String(message.clone())); + } + for (key, set) in [ + ("unmodified", self.unmodified), + ("unmerged", self.unmerged), + ("skipAutoSnap", self.skip_auto_snap), + ] { + if set { + options.insert(key.into(), Value::Bool(true)); + } + } + Value::Object(options) + } +} + +#[cfg(test)] +mod options_tests; diff --git a/native/cli/src/commands/snap/options/options_tests.rs b/native/cli/src/commands/snap/options/options_tests.rs new file mode 100644 index 000000000000..3b1bd28b317b --- /dev/null +++ b/native/cli/src/commands/snap/options/options_tests.rs @@ -0,0 +1,51 @@ +//! Unit tests for `bit snap` [`Options`] CLI parsing ([`parse`]) and selector grammar retention. +//! +//! Verifies `--message`, `--skip-auto-snap`, `--unmodified`, and `--unmerged` flag parsing. + +use super::{Options, parse}; +use serde_json::json; + +/// Verifies ordinary `bit snap` [`Options`] flag and pattern parsing via [`parse`]. +#[test] +fn ordinary_snaps() { + assert_eq!(parse(&[]), Some(Options::default())); + let options = parse(&["remote/one", "-m", "fix", "--skip-auto-snap"]).unwrap(); + assert_eq!(options.pattern.as_deref(), Some("remote/one")); + assert_eq!( + options.request(), + json!({ "pattern": "remote/one", "message": "fix", "skipAutoSnap": true }), + ); + assert!( + parse(&["-u", "--unmerged"]).is_some_and(|options| options.unmodified && options.unmerged), + ); + assert!( + parse(&["--message=inline", "--unmodified=true", "--no-color", "--no-color=false"]) + .is_some_and(|options| { + options.message.as_deref() == Some("inline") && options.unmodified + }), + ); +} + +/// Verifies that unsupported `bit snap` flags or duplicate arguments return `None`. +#[test] +fn everything_else_is_left_to_node() { + assert!(parse(&["--build"]).is_none()); + assert!(parse(&["-m"]).is_none()); + assert!(parse(&["-m", "--unmodified"]).is_none()); + assert!(parse(&["a", "b"]).is_none()); + assert!(parse(&["-u", "--unmodified"]).is_none()); +} + +#[test] +fn component_selector_grammar_is_retained_for_native_selection() { + for pattern in ["$modified", "a* AND !b*", "{foo,bar}/*", "@(foo|bar)/*"] { + let options = parse(&[pattern]).unwrap(); + assert_eq!(options.pattern.as_deref(), Some(pattern)); + } +} + +#[test] +fn double_dash_accepts_single_snap_pattern() { + let options = parse(&["-m", "msg", "--", "remote/one"]).unwrap(); + assert_eq!(options.pattern.as_deref(), Some("remote/one")); +} diff --git a/native/cli/src/commands/snap_distance.rs b/native/cli/src/commands/snap_distance.rs new file mode 100644 index 000000000000..151b1c31cf75 --- /dev/null +++ b/native/cli/src/commands/snap_distance.rs @@ -0,0 +1,398 @@ +//! Native `bit snap-distance [source-snap] [target-snap]` (`scopes/component/snapping/snap-distance-cmd.ts`, [`COMMAND`], [`Options`]) via [`compute_snap_distance`]. + +mod graph; +#[cfg(test)] +mod tests; + +use self::graph::{ + build_parents_map, collect_successors, compute_snap_distance, + load_version_history_with_local_versions, +}; +use super::{ + Command, + color::Chalk, + debug_cat::{ + emit_failure, emit_scope_not_found, emit_text, + lookup::{diagnose_component_lookup_error, resolve_component_entry}, + scope_dir_for, sha1_hex, + }, + graph_cmd::builder::resolve_ver_hash, + lane::scope_root::{self, ScopeRoot}, + workspace_command, +}; +use crate::native::Outcome; +use serde_json::{Value, json}; +use std::{env, ffi::OsString, fs, io, path::Path}; + +/// Descriptor for the native `bit snap-distance` command. +pub const COMMAND: Command = Command { name: "snap-distance", aliases: &[], run }; +/// Parsed command-line arguments for `bit snap-distance [source-snap] [target-snap]`. +#[derive(Debug, PartialEq, Eq)] +struct Options { + id: String, + source_snap: Option, + target_snap: Option, +} + +#[inline] +fn is_hex40(text: &str) -> bool { + let trimmed = text.trim(); + trimmed.len() == 40 && trimmed.bytes().all(|byte| byte.is_ascii_hexdigit()) +} + +#[inline] +fn is_ci_active() -> bool { + env::var_os("CI") + .is_some_and(|val| { + let text = val.to_string_lossy(); + let trimmed = text.trim(); + !trimmed.is_empty() + && !trimmed.eq_ignore_ascii_case("0") + && !trimmed.eq_ignore_ascii_case("false") + && !trimmed.eq_ignore_ascii_case("no") + }) +} + +fn parse_options(args: &[&str]) -> Option { + let positional = super::scope_set::collect_non_flag_tokens(args)?; + match positional.as_slice() { + [id] if !id.trim().is_empty() => { + Some(Options { id: id.trim().to_owned(), source_snap: None, target_snap: None }) + } + [id, src] if !id.trim().is_empty() && !src.trim().is_empty() => Some(Options { + id: id.trim().to_owned(), + source_snap: Some(src.trim().to_owned()), + target_snap: None, + }), + [id, src, dst] if !id.trim().is_empty() && is_hex40(src) && is_hex40(dst) => { + Some(Options { + id: id.trim().to_owned(), + source_snap: Some(src.trim().to_owned()), + target_snap: Some(dst.trim().to_owned()), + }) + } + _ => None, + } +} + +fn run(args: &[OsString]) -> io::Result { + let Some(argv) = workspace_command::utf8_args(args) else { + return Ok(Outcome::Declined("snap-distance arguments are not UTF-8".into())); + }; + let Some(opts) = parse_options(&argv[1..]) else { + return Ok(Outcome::Declined("outside native snap-distance grammar".into())); + }; + let Ok(Some(root)) = scope_root::discover() else { + return emit_scope_not_found(args, "snap-distance", false); + }; + if let Some(outcome) = validate_target(&root, args, &opts)? { + return Ok(outcome); + } + let Some(result) = evaluate_snap_distance(&root, &opts) else { + return Ok(Outcome::Declined("snap-distance delegated".into())); + }; + emit_text(&root, args, "snap-distance", true, serde_json::to_string_pretty(&result)?) +} + +fn validate_target( + root: &ScopeRoot, + args: &[OsString], + opts: &Options, +) -> io::Result> { + let scope_dir = scope_dir_for(root); + let bare = opts.id.rsplit_once('@').map_or(opts.id.as_str(), |(id, _)| id); + let missing_target = opts.source_snap.is_some() && opts.target_snap.is_none(); + if is_valid_scoped_id(&opts.id, bare) && missing_target { + return emit_missing_target_error(root, args).map(Some); + } + if resolve_component_entry(&scope_dir, bare).is_none_or(|(sc, _, _)| sc.is_none()) { + if !is_ci_active() + && let Some(err) = + diagnose_component_lookup_error(&scope_dir, &opts.id, bare, Chalk::detect(args)) + { + return emit_failure(root, args, "snap-distance", true, false, err).map(Some); + } + return Ok(Some(Outcome::Declined("snap-distance component lookup delegated".into()))); + } + missing_target.then(|| emit_missing_target_error(root, args)).transpose() +} + +#[inline] +fn is_valid_scoped_id(raw_id: &str, bare: &str) -> bool { + !raw_id.starts_with('@') + && bare + .split_once('/') + .is_some_and(|(sc, nm)| !sc.is_empty() && !nm.is_empty() && !nm.contains('/')) +} + +fn evaluate_snap_distance(root: &ScopeRoot, opts: &Options) -> Option { + let scope_dir = scope_dir_for(root); + let bare_id = opts.id.rsplit_once('@').map_or(opts.id.as_str(), |(id, _)| id); + let (Some(scope), name, comp_hash) = resolve_component_entry(&scope_dir, bare_id)? else { + return None; + }; + let vh_hash = sha1_hex(&format!("{scope}/{name}:VersionHistory")); + match (&opts.source_snap, &opts.target_snap) { + (Some(src), Some(dst)) => { + let vh = load_version_history_with_local_versions(&scope_dir, &vh_hash, &[src, dst])?; + compute_snap_distance(&vh, src, dst) + } + (None, None) => { + compute_local_remote_distance(root, &scope_dir, &scope, &name, &comp_hash, &vh_hash) + } + _ => None, + } +} + +fn emit_missing_target_error(root: &ScopeRoot, args: &[OsString]) -> io::Result { + if is_ci_active() { + return Ok(Outcome::Declined("CI snap-distance error stack".into())); + } + let msg = "either provide both source and target snaps or none".into(); + emit_failure(root, args, "snap-distance", true, false, msg) +} + +fn compute_local_remote_distance( + root: &ScopeRoot, + scope_dir: &Path, + scope: &str, + name: &str, + comp_hash: &str, + vh_hash: &str, +) -> Option { + let ws_ver = if root.bare { + None + } else { + let bytes = fs::read(root.path.join(".bitmap")).ok()?; + let bitmap = bit_object_import::parse_jsonc(&bytes).ok()?; + if is_on_non_main_lane(&bitmap) { + return None; + } + find_bitmap_version(&bitmap, scope, name) + }; + let (kind, model) = bit_object_import::read_scope_object(scope_dir, comp_hash).ok()??; + if kind != "Component" { + return None; + } + let local_head = ws_ver + .as_deref() + .and_then(|ver| resolve_ver_hash(&model, ver)) + .or_else(|| model["head"].as_str().map(str::to_owned)); + let remote_head = read_remote_main_head(scope_dir, scope, name)?; + distance_from_heads(scope_dir, vh_hash, local_head.as_deref(), remote_head.as_deref()) +} + +fn resolve_scope_local_head( + scope_dir: &Path, + scope: &str, + name: &str, + ws_version: &str, +) -> Option { + let (_, _, comp_hash) = resolve_component_entry(scope_dir, &format!("{scope}/{name}"))?; + let (kind, model) = bit_object_import::read_scope_object(scope_dir, &comp_hash).ok()??; + let local_head = (kind == "Component").then(|| resolve_ver_hash(&model, ws_version))??; + let (v_kind, _) = bit_object_import::read_scope_object(scope_dir, &local_head).ok()??; + (v_kind == "Version").then_some(local_head) +} + +pub(super) fn is_component_staged_on_main( + root: &Path, + scope: &str, + name: &str, + ws_version: &str, +) -> Option { + let scope_dir = bit_object_import::scope_path(root); + let local_head = resolve_scope_local_head(&scope_dir, scope, name, ws_version)?; + let remote_head = read_remote_main_head(&scope_dir, scope, name)?; + let vh_hash = sha1_hex(&format!("{scope}/{name}:VersionHistory")); + let dist = + distance_from_heads(&scope_dir, &vh_hash, Some(&local_head), remote_head.as_deref())?; + Some(!dist["snapsOnSourceOnly"].as_array()?.is_empty()) +} + +pub(super) fn is_component_staged_on_lane( + root: &Path, + bitmap_val: &Value, + scope: &str, + name: &str, + ws_version: &str, +) -> Option { + let scope_dir = bit_object_import::scope_path(root); + let local_head = resolve_scope_local_head(&scope_dir, scope, name, ws_version)?; + let (lane_scope, lane_name) = + bitmap_lane_scope_and_name(bitmap_val).filter(|(sc, _)| !sc.is_empty())?; + if let Some(remote_lane_head) = + read_remote_ref_head(&scope_dir, lane_scope, lane_name, scope, name)? + { + return Some(remote_lane_head != local_head); + } + let remote_main = read_remote_main_head(&scope_dir, scope, name)?; + Some(remote_main.as_deref() != Some(local_head.as_str())) +} + +pub(in crate::commands) fn bitmap_lane_scope_and_name(bitmap: &Value) -> Option<(&str, &str)> { + let id = bitmap.get("_bit_lane")?.get("id")?; + if let Some(raw) = id.as_str() { + let bare = raw + .trim() + .split('@') + .next() + .unwrap_or_default() + .trim(); + let (scope, name) = bare + .split_once('/') + .unwrap_or(("", bare)); + return (!name.trim().is_empty()).then_some((scope.trim(), name.trim())); + } + let name = id["name"] + .as_str() + .map(str::trim) + .filter(|val| !val.is_empty())?; + Some((id["scope"].as_str().map_or("", str::trim), name)) +} + +#[inline] +pub(in crate::commands) fn is_on_non_main_lane(bitmap: &Value) -> bool { + bitmap_lane_scope_and_name(bitmap).is_some_and(|(_, name)| name != "main") +} + +fn read_remote_ref_head( + scope_dir: &Path, + ref_scope: &str, + ref_name: &str, + comp_scope: &str, + comp_name: &str, +) -> Option> { + let Some(bytes) = read_remote_ref_bytes(scope_dir, ref_scope, ref_name)? else { + return Some(None); + }; + let entries: Value = bit_object_import::parse_jsonc(&bytes).ok()?; + let full = format!("{comp_scope}/{comp_name}"); + let items = entries + .as_array() + .or_else(|| entries["components"].as_array())?; + for item in items { + let item_scope = item["id"]["scope"] + .as_str() + .unwrap_or_default() + .trim(); + let matches = (item["id"]["name"].as_str().map(str::trim) == Some(comp_name) + && (item_scope.is_empty() || item_scope == comp_scope)) + || matches!(item["id"].as_str().map(str::trim), Some(id) if id == full || id == comp_name); + if matches { + return Some( + item["head"] + .as_str() + .map(str::trim) + .filter(|head| !head.is_empty()) + .map(str::to_owned), + ); + } + } + Some(None) +} + +fn read_remote_ref_bytes( + scope_dir: &Path, + ref_scope: &str, + ref_name: &str, +) -> Option>> { + for dir in ["remote", "remotes"] { + match fs::read( + scope_dir + .join("refs") + .join(dir) + .join(ref_scope) + .join(ref_name), + ) { + Ok(bytes) => return Some(Some(bytes)), + Err(err) if err.kind() == io::ErrorKind::NotFound => {} + Err(_) => return None, + } + } + Some(None) +} + +fn distance_from_heads( + scope_dir: &Path, + vh_hash: &str, + local_head: Option<&str>, + remote_head: Option<&str>, +) -> Option { + match (local_head, remote_head) { + (None, None) => Some(json!({ "snapsOnSourceOnly": [], "snapsOnTargetOnly": [] })), + (Some(lh), Some(rh)) if lh == rh => { + Some(json!({ "snapsOnSourceOnly": [], "snapsOnTargetOnly": [] })) + } + (Some(lh), Some(rh)) => { + let vh = load_version_history_with_local_versions(scope_dir, vh_hash, &[lh, rh])?; + compute_snap_distance(&vh, lh, rh) + } + (Some(lh), None) => { + let snaps = collect_head_hashes(scope_dir, vh_hash, lh)?; + Some(json!({ "snapsOnSourceOnly": snaps, "snapsOnTargetOnly": [] })) + } + (None, Some(rh)) => { + let snaps = collect_head_hashes(scope_dir, vh_hash, rh)?; + Some(json!({ "snapsOnSourceOnly": [], "snapsOnTargetOnly": snaps })) + } + } +} + +fn collect_head_hashes(scope_dir: &Path, vh_hash: &str, head: &str) -> Option> { + let vh = load_version_history_with_local_versions(scope_dir, vh_hash, &[head])?; + let parents_map = build_parents_map(&vh)?; + let snaps = collect_successors(&parents_map, head)?; + Some( + snaps + .into_iter() + .map(|snap| json!({ "hash": snap })) + .collect(), + ) +} + +fn find_bitmap_version(bitmap: &Value, scope: &str, name: &str) -> Option { + let full = format!("{scope}/{name}"); + bitmap + .as_object()? + .iter() + .find_map(|(key, row)| bitmap_row_version(key, row, scope, name, &full)) +} + +fn bitmap_row_version( + key: &str, + row: &Value, + scope: &str, + name: &str, + full: &str, +) -> Option { + if key.starts_with('$') || matches!(key, "_bit_lane" | "version") || !row.is_object() { + return None; + } + let key_id = key.rsplit_once('@').map_or(key, |(id, _)| id); + let row_name = super::update_cmd::bitmap_row_name(key, row); + let row_scope = row["scope"] + .as_str() + .filter(|val| !val.is_empty()) + .or_else(|| row["defaultScope"].as_str()) + .unwrap_or_default(); + let matches = + key_id == full || (row_name == name && (row_scope.is_empty() || row_scope == scope)); + if !matches { + return None; + } + row["version"] + .as_str() + .filter(|ver| !ver.is_empty()) + .map(str::to_owned) + .or_else(|| { + key.rsplit_once('@') + .filter(|(_, ver)| !ver.is_empty()) + .map(|(_, ver)| ver.to_owned()) + }) +} + +#[inline] +fn read_remote_main_head(scope_dir: &Path, scope: &str, name: &str) -> Option> { + read_remote_ref_head(scope_dir, scope, "main", scope, name) +} diff --git a/native/cli/src/commands/snap_distance/graph.rs b/native/cli/src/commands/snap_distance/graph.rs new file mode 100644 index 000000000000..aadcc6b53e25 --- /dev/null +++ b/native/cli/src/commands/snap_distance/graph.rs @@ -0,0 +1,234 @@ +//! Graph traversal and lowest-common-ancestor helpers for `bit snap-distance`. +//! +//! [`compute_snap_distance`] walks parent maps ([`HashMap`], [`HashSet`], [`VecDeque`]) to compute LCA distance. + +use serde_json::{Value, json}; +use std::{ + collections::{HashMap, HashSet, VecDeque}, + path::Path, +}; + +/// Computes the snap distance (`snapsOnSourceOnly`, `snapsOnTargetOnly`, `commonSnapBeforeDiverge`) between `source` and `target`. +pub(super) fn compute_snap_distance(vh_val: &Value, source: &str, target: &str) -> Option { + let source = source.trim(); + let target = target.trim(); + let parents_map = build_parents_map(vh_val)?; + if !parents_map.contains_key(source) || !parents_map.contains_key(target) { + return None; + } + if source == target { + return Some(json!({ "snapsOnSourceOnly": [], "snapsOnTargetOnly": [] })); + } + let source_arr = collect_successors(&parents_map, source)?; + let target_arr = collect_successors(&parents_map, target)?; + let target_set: HashSet<&str> = target_arr.iter().copied().collect(); + let source_set: HashSet<&str> = source_arr.iter().copied().collect(); + let common_snaps: Vec<&str> = source_arr + .iter() + .copied() + .filter(|snap| target_set.contains(snap)) + .collect(); + let closest_common = find_closest_common(&parents_map, source, target, &common_snaps)?; + let on_source_only: Vec<_> = source_arr + .into_iter() + .filter(|snap| !target_set.contains(snap)) + .map(|snap| json!({"hash": snap})) + .collect(); + let on_target_only: Vec<_> = target_arr + .into_iter() + .filter(|snap| !source_set.contains(snap)) + .map(|snap| json!({"hash": snap})) + .collect(); + Some(json!({ + "snapsOnSourceOnly": on_source_only, + "snapsOnTargetOnly": on_target_only, + "commonSnapBeforeDiverge": { "hash": closest_common }, + })) +} + +/// Builds a map from each version hash in `vh_val` to its immediate parent hashes. +pub(super) fn build_parents_map(vh_val: &Value) -> Option>> { + let versions = vh_val["versions"].as_array()?; + let mut parents_map = HashMap::with_capacity(versions.len()); + for entry in versions { + let hash = entry["hash"] + .as_str() + .map(str::trim) + .filter(|text| !text.is_empty())?; + let parents = match entry.get("parents") { + None | Some(Value::Null) => Vec::new(), + Some(Value::Array(arr)) => arr + .iter() + .map(parent_hash) + .collect::>>()?, + _ => return None, + }; + parents_map.insert(hash, parents); + } + Some(parents_map) +} + +#[inline] +fn parent_hash(parent: &Value) -> Option<&str> { + parent + .as_str() + .or_else(|| parent.get("hash").and_then(Value::as_str)) + .map(str::trim) + .filter(|text| !text.is_empty()) +} + +fn find_closest_common<'a>( + parents_map: &HashMap<&'a str, Vec<&'a str>>, + source: &'a str, + target: &'a str, + common_snaps: &[&'a str], +) -> Option<&'a str> { + if let [single] = common_snaps { + return Some(*single); + } + if common_snaps.is_empty() { + return None; + } + let common_set: HashSet<&str> = common_snaps.iter().copied().collect(); + let closest_source = bfs_first(parents_map, source, &common_set)?; + let closest_target = bfs_first(parents_map, target, &common_set)?; + if closest_source == closest_target { + return Some(closest_source); + } + let source_succ = collect_successors(parents_map, closest_source)?; + Some(if source_succ.contains(&closest_target) { closest_source } else { closest_target }) +} + +pub(super) fn collect_successors<'a>( + parents_map: &HashMap<&'a str, Vec<&'a str>>, + start: &'a str, +) -> Option> { + let mut visited = HashSet::new(); + let mut result = Vec::new(); + let mut stack = vec![start]; + while let Some(curr) = stack.pop() { + if !visited.insert(curr) { + continue; + } + result.push(curr); + push_unvisited_parents(parents_map, curr, &visited, &mut stack)?; + } + Some(result) +} + +#[inline] +fn push_unvisited_parents<'a>( + parents_map: &HashMap<&'a str, Vec<&'a str>>, + curr: &'a str, + visited: &HashSet<&'a str>, + stack: &mut Vec<&'a str>, +) -> Option<()> { + for &parent in parents_map.get(curr)?.iter().rev() { + if !parents_map.contains_key(parent) { + return None; + } + if !visited.contains(parent) { + stack.push(parent); + } + } + Some(()) +} + +fn bfs_first<'a>( + parents_map: &HashMap<&'a str, Vec<&'a str>>, + start: &'a str, + targets: &HashSet<&'a str>, +) -> Option<&'a str> { + let mut visited = HashSet::new(); + let mut queue = VecDeque::from([start]); + while let Some(curr) = queue.pop_front() { + if !visited.insert(curr) { + continue; + } + if targets.contains(curr) { + return Some(curr); + } + for &parent in parents_map.get(curr)? { + queue.push_back(parent); + } + } + None +} + +#[inline] +fn extract_vh_versions(val: &Value) -> Option> { + if let Some(arr) = val["versions"].as_array() { + return Some(arr.clone()); + } + Some( + val["versions"] + .as_object()? + .iter() + .filter_map(|(hash, item)| { + let hash = hash.trim(); + (!hash.is_empty()).then( + || json!({ "hash": hash, "parents": item.get("parents").unwrap_or(item) }), + ) + }) + .collect(), + ) +} + +pub(super) fn load_version_history_with_local_versions( + scope_dir: &Path, + vh_hash: &str, + heads: &[&str], +) -> Option { + let mut versions = match bit_object_import::read_scope_object(scope_dir, vh_hash) { + Ok(Some((kind, val))) if kind == "VersionHistory" => extract_vh_versions(&val)?, + Ok(None) => Vec::new(), + _ => return None, + }; + let mut known: HashSet = versions + .iter() + .filter_map(|ver| { + ver["hash"] + .as_str() + .map(str::trim) + .filter(|hash| !hash.is_empty()) + .map(str::to_owned) + }) + .collect(); + let mut stack: Vec = heads + .iter() + .map(|&head| head.trim()) + .filter(|head| !head.is_empty()) + .map(str::to_owned) + .collect(); + while let Some(hash) = stack.pop() { + if known.contains(&hash) { + continue; + } + let parents = read_version_parents(scope_dir, &hash)?; + for parent in &parents { + if !known.contains(parent) { + stack.push(parent.clone()); + } + } + known.insert(hash.clone()); + versions.push(json!({ "hash": hash, "parents": parents })); + } + Some(json!({ "versions": versions })) +} + +#[inline] +fn read_version_parents(scope_dir: &Path, hash: &str) -> Option> { + let (kind, ver) = bit_object_import::read_scope_object(scope_dir, hash).ok()??; + if kind != "Version" { + return None; + } + Some( + ver["parents"] + .as_array() + .into_iter() + .flatten() + .filter_map(parent_hash) + .map(str::to_owned) + .collect(), + ) +} diff --git a/native/cli/src/commands/snap_distance/tests.rs b/native/cli/src/commands/snap_distance/tests.rs new file mode 100644 index 000000000000..a4a1fa3ba15c --- /dev/null +++ b/native/cli/src/commands/snap_distance/tests.rs @@ -0,0 +1,72 @@ +//! Unit tests for `bit snap-distance` [`Options`] CLI argument parsing ([`parse_options`]) and common-ancestor computation ([`compute_snap_distance`]). +//! +//! Verifies 1-, 2-, and 3-argument positional parsing and parent-graph BFS distance calculation. + +use super::{Options, compute_snap_distance, parse_options}; +use serde_json::json; + +/// Verifies 1-, 2-, and 3-positional argument [`Options`] parsing via [`parse_options`]. +#[test] +fn parses_one_two_or_three_positional_arguments() { + let s1 = "1111111111111111111111111111111111111111"; + let s2 = "2222222222222222222222222222222222222222"; + assert_eq!( + parse_options(&["comp1", s1, s2]), + Some(Options { + id: "comp1".into(), + source_snap: Some(s1.into()), + target_snap: Some(s2.into()), + }), + ); + assert_eq!( + parse_options(&["comp1", s1]), + Some(Options { id: "comp1".into(), source_snap: Some(s1.into()), target_snap: None }), + ); + assert_eq!( + parse_options(&["comp1"]), + Some(Options { id: "comp1".into(), source_snap: None, target_snap: None }), + ); +} + +/// Verifies [`compute_snap_distance`] parent-graph traversal and common-ancestor identification. +#[test] +fn computes_distance_and_common_ancestor() { + let base = "0000000000000000000000000000000000000000"; + let src = "1111111111111111111111111111111111111111"; + let tgt = "2222222222222222222222222222222222222222"; + let vh = json!({ + "versions": [ + { "hash": base, "parents": [] }, + { "hash": src, "parents": [base] }, + { "hash": tgt, "parents": [base] } + ] + }); + let dist = compute_snap_distance(&vh, src, tgt).expect("distance"); + assert_eq!(dist["snapsOnSourceOnly"], json!([{ "hash": src }])); + assert_eq!(dist["snapsOnTargetOnly"], json!([{ "hash": tgt }])); + assert_eq!(dist["commonSnapBeforeDiverge"], json!({ "hash": base })); +} + +#[test] +fn bitmap_lane_helpers_accept_object_and_string_lane_ids() { + let obj_bitmap = json!({ + "_bit_lane": { + "id": {"scope": "my.scope", "name": "dev"}, + "exported": true + } + }); + let str_bitmap = json!({ + "_bit_lane": { + "id": "my.scope/dev", + "exported": true + } + }); + assert_eq!(super::bitmap_lane_scope_and_name(&obj_bitmap), Some(("my.scope", "dev"))); + assert_eq!(super::bitmap_lane_scope_and_name(&str_bitmap), Some(("my.scope", "dev"))); +} + +#[test] +fn rejects_empty_or_excessive_snap_distance_arguments() { + assert_eq!(parse_options(&[]), None); + assert_eq!(parse_options(&["a", "b", "c", "d"]), None); +} diff --git a/native/cli/src/commands/start_cmd.rs b/native/cli/src/commands/start_cmd.rs new file mode 100644 index 000000000000..504aa3a01bb5 --- /dev/null +++ b/native/cli/src/commands/start_cmd.rs @@ -0,0 +1,227 @@ +//! Native `bit start [component-pattern]` (`scopes/ui-foundation/ui/start.cmd.tsx`, alias `c`). +//! +//! Validates [`Options`] workspace and UI server flags ([`COMMAND`], [`NO_WORKSPACE_OR_SCOPE_ERR`]) before delegating or reporting into [`Outcome`]. + +#[cfg(test)] +mod tests; + +use super::{Command, workspace_command}; +use crate::{native::Outcome, workspace}; +use std::{env, ffi::OsString, io, path::Path}; + +/// Descriptor for the native `bit start` (`c`) command. +pub const COMMAND: Command = Command { name: "start", aliases: &["c"], run }; + +/// Error message emitted when `bit start` is run outside a workspace or scope. +const NO_WORKSPACE_OR_SCOPE_ERR: &str = "bit start can only be run inside a bit workspace or a bit scope - please ensure you are running the command in the correct directory"; + +/// Parsed command-line options for `bit start [component-pattern]`. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub(super) struct Options { + pub pattern: Option, + pub dev: bool, + pub port: Option, + pub rebuild: bool, + pub skip_ui_build: bool, + pub no_browser: bool, + pub skip_compilation: bool, + pub ui_root_name: Option, +} + +/// Entry point for `bit start`. +fn run(args: &[OsString]) -> io::Result { + let Some(argv) = workspace_command::utf8_args(args) else { + return Ok(Outcome::Declined("start arguments are not UTF-8".into())); + }; + if parse_options(&argv[1..]).is_none() { + return Ok(Outcome::Declined("outside native start grammar".into())); + } + if ci_enabled() { + return Ok(Outcome::Declined("CI start delegated".into())); + } + let cwd = env::current_dir()?; + if is_outside_workspace_and_scope(&cwd) { + return workspace_command::execute_without_loader_scope(&cwd, args, "start", |_| { + Err(io::Error::other(NO_WORKSPACE_OR_SCOPE_ERR)) + }); + } + Ok(Outcome::Declined("start server execution delegated".into())) +} + +#[must_use] +#[inline] +pub(super) fn is_outside_workspace_and_scope(cwd: &Path) -> bool { + workspace::root(cwd).is_none() + && !cwd + .ancestors() + .any(|dir| { + dir.join("scope.json").is_file() + || dir.join(".bit/scope.json").is_file() + || dir.join(".bit").is_dir() + || dir.join(".git/bit").is_dir() + }) +} + +#[must_use] +pub(super) fn parse_options(args: &[&str]) -> Option { + let mut options = Options::default(); + let mut idx = 0; + while idx < args.len() { + if args[idx] == "--" { + consume_trailing_start_pattern(&args[idx + 1..], &mut options.pattern)?; + break; + } + apply_start_token(args, &mut idx, &mut options)?; + idx += 1; + } + Some(options) +} + +#[inline] +fn ci_enabled() -> bool { + env::var_os("CI") + .is_some_and(|val| { + let text = val.to_string_lossy(); + let trimmed = text + .trim() + .trim_matches(['"', '\'']) + .trim(); + !trimmed.is_empty() + && !["0", "false", "no", "off"] + .iter() + .any(|falsy| trimmed.eq_ignore_ascii_case(falsy)) + }) +} + +#[inline] +fn consume_trailing_start_pattern(rest: &[&str], pattern: &mut Option) -> Option<()> { + for &val in rest { + let val = val.trim(); + (!val.is_empty() + && !val.starts_with('-') + && val.len() <= 4096 + && !val.contains('\0') + && pattern.replace(val.to_owned()).is_none()) + .then_some(())?; + } + Some(()) +} + +#[inline] +fn is_ignored_start_flag(arg: &str) -> bool { + matches!( + arg, + "--no-color" + | "--no-color=true" + | "--no-color=false" + | "--color" + | "--color=true" + | "--color=false" + | "--color=always" + | "--color=never" + | "-v" + | "--verbose" + | "--verbose=true" + | "--verbose=false" + | "--show-internal-urls" + | "--show-internal-urls=true" + | "--show-internal-urls=false" + | "--use-root-modules" + | "--use-root-modules=true" + | "--use-root-modules=false" + | "--use-source" + | "--use-source=true" + | "--use-source=false", + ) +} + +fn apply_start_token(args: &[&str], idx: &mut usize, options: &mut Options) -> Option<()> { + let arg = args[*idx]; + if is_ignored_start_flag(arg) { + return Some(()); + } + match arg { + "-d" | "--dev" | "--dev=true" => options.dev = true, + "--dev=false" | "--no-dev" => options.dev = false, + "-r" | "--rebuild" | "--rebuild=true" => options.rebuild = true, + "--rebuild=false" | "--no-rebuild" => options.rebuild = false, + "-s" | "--skip-ui-build" | "--skip-ui-build=true" => options.skip_ui_build = true, + "--skip-ui-build=false" | "--no-skip-ui-build" => options.skip_ui_build = false, + "-n" | "--no-browser" | "--no-browser=true" => options.no_browser = true, + "--no-browser=false" => options.no_browser = false, + "--skip-compilation" | "--skip-compilation=true" => options.skip_compilation = true, + "--skip-compilation=false" | "--no-skip-compilation" => options.skip_compilation = false, + "-p" | "--port" => { + options.port = Some(take_optional_arg(args, idx)); + } + "-u" | "--ui-root-name" => { + options.ui_root_name = Some(take_optional_arg(args, idx)); + } + _ => apply_inline_or_pattern(arg, options)?, + } + Some(()) +} + +#[inline] +fn take_optional_arg(args: &[&str], idx: &mut usize) -> String { + if let Some(&next) = args.get(*idx + 1) + && !next.trim().starts_with('-') + { + *idx += 1; + next.trim().to_owned() + } else { + String::new() + } +} + +fn apply_inline_or_pattern(arg: &str, options: &mut Options) -> Option<()> { + let trimmed = arg.trim(); + if let Some(val) = arg + .strip_prefix("--port=") + .or_else(|| arg.strip_prefix("-p=")) + { + options.port = Some(val.trim().to_owned()); + } else if let Some(val) = arg + .strip_prefix("--ui-root-name=") + .or_else(|| arg.strip_prefix("-u=")) + { + options.ui_root_name = Some(val.trim().to_owned()); + } else if apply_combined_short(arg, options) { + } else if !trimmed.starts_with('-') + && !trimmed.is_empty() + && trimmed.len() <= 4096 + && !trimmed.contains('\0') + && options.pattern.is_none() + { + options.pattern = Some(trimmed.to_owned()); + } else { + return None; + } + Some(()) +} + +#[inline] +fn apply_combined_short(arg: &str, options: &mut Options) -> bool { + let Some(flags) = arg.trim().strip_prefix('-') else { + return false; + }; + let bytes = flags.as_bytes(); + if bytes.len() < 2 + || bytes[0] == b'-' + || !bytes + .iter() + .all(|byte| matches!(byte, b'd' | b'r' | b's' | b'n' | b'v')) + { + return false; + } + for &byte in bytes { + match byte { + b'd' => options.dev = true, + b'r' => options.rebuild = true, + b's' => options.skip_ui_build = true, + b'n' => options.no_browser = true, + _ => {} + } + } + true +} diff --git a/native/cli/src/commands/start_cmd/tests.rs b/native/cli/src/commands/start_cmd/tests.rs new file mode 100644 index 000000000000..a0d7be2aa813 --- /dev/null +++ b/native/cli/src/commands/start_cmd/tests.rs @@ -0,0 +1,42 @@ +//! Unit tests for `bit start` CLI option parsing ([`parse_options`]) and missing workspace/scope detection ([`is_outside_workspace_and_scope`]). +//! +//! Verifies `--dev`, `--port`, `--rebuild`, `--skip-ui-build`, and `--skip-compilation` flags. + +use super::{is_outside_workspace_and_scope, parse_options}; + +/// Verifies `bit start` option parsing via [`parse_options`] and host checks via [`is_outside_workspace_and_scope`]. +#[test] +fn parses_start_options_and_detects_missing_workspace_or_scope() { + let opts = parse_options(&[ + "ui/*", + "-d", + "-p", + "3000", + "--rebuild", + "--skip-ui-build", + "-n", + "--skip-compilation", + ]) + .unwrap(); + assert_eq!(opts.pattern.as_deref(), Some("ui/*")); + assert!(opts.dev); + assert_eq!(opts.port.as_deref(), Some("3000")); + assert!(opts.rebuild); + assert!(opts.skip_ui_build); + assert!(opts.no_browser); + assert!(opts.skip_compilation); + + let combined = parse_options(&["-drsnv"]).unwrap(); + assert!(combined.dev && combined.rebuild && combined.skip_ui_build && combined.no_browser); + + let temp = tempfile::tempdir().unwrap(); + assert!(is_outside_workspace_and_scope(temp.path())); +} + +/// Verifies that an empty argument slice parses with `pattern: None` and default flags. +#[test] +fn empty_start_args_parse_with_no_pattern() { + let opts = parse_options(&[]).unwrap(); + assert!(opts.pattern.is_none()); + assert!(!opts.dev && !opts.rebuild); +} diff --git a/native/cli/src/commands/stash.rs b/native/cli/src/commands/stash.rs new file mode 100644 index 000000000000..5615e22696f0 --- /dev/null +++ b/native/cli/src/commands/stash.rs @@ -0,0 +1,391 @@ +//! Native stash orchestration; detached objects precede reset and new-component removal. +//! +//! Dispatches [`COMMAND`] `save`, `list`, and `load` subcommands ([`run`]) parsed by [`options`] with [`Startup`] autosync. +mod options; + +use super::{ + Command, + color::Chalk, + status::{ + autosync::Startup, + js::{array, decline, text}, + }, + workspace_command::{self, Request, format::Formatter, results::Report, stash::Save}, +}; +use crate::{native::Outcome, workspace}; +use bit_object_import::{ + component_remove::NewBatch, + stash_store::{self, PreparedLoad}, +}; +use options::Options; +use serde_json::{Value, json}; +use std::{env, ffi::OsString, io, path::Path}; + +/// Descriptor for the native `bit stash` command. +pub const COMMAND: Command = Command { name: "stash", aliases: &[], run }; +/// Entry point for `bit stash`. +fn run(args: &[OsString]) -> io::Result { + let Some(argv) = workspace_command::utf8_args(args) else { + return Ok(Outcome::Declined("stash arguments are not UTF-8".to_owned())); + }; + let Some(options) = options::parse(&argv[1..]) else { + return Ok(Outcome::Declined("outside the native stash grammar".to_owned())); + }; + let cwd = env::current_dir()?; + let Some(root) = workspace::root(&cwd) else { + return run_without_workspace(&cwd, args, &options); + }; + let command = match &options { + Options::Save { .. } => "save", + Options::List => "list", + Options::Unrecognized(_) => "stash", + _ => "load", + }; + let mut startup = Startup::default(); + let prepared = prepare(&root, options, &mut startup); + let prepared = match prepared { + Ok(prepared) => prepared, + Err(error) if startup.committed || super::engine::is_canonical(&error) => { + return failure(&root, args, command, &startup, error); + } + Err(error) => return Ok(Outcome::Declined(format!("stash: {error}"))), + }; + workspace_command::checkout_lifecycle::before(&startup)?; + workspace_command::execute_then(&root, args, command, |output| { + workspace_command::checkout_lifecycle::loaded(&startup); + execute(&root, args, prepared, output) + }) +} + +enum Prepared { + Save(Box), + List(Vec), + Load { + metadata: Box, + planned: Box, + tracked: Option, + count: usize, + }, + Unrecognized(String), + Error(String), +} + +fn prepare(root: &Path, options: Options, startup: &mut Startup) -> io::Result { + match options { + Options::Save { include_new, message } => { + Save::prepare(root, include_new, message.as_deref(), startup) + .map(|save| Prepared::Save(Box::new(save))) + } + Options::List => { + super::status::environments::quick(root, &workspace_command::home(), startup)?; + stash_store::list(&bit_install::config::scope_path(root)).map(Prepared::List) + } + Options::Unrecognized(sub) => { + super::status::environments::quick(root, &workspace_command::home(), startup)?; + Ok(Prepared::Unrecognized(sub)) + } + Options::Error(error) => { + super::status::environments::quick(root, &workspace_command::home(), startup)?; + Ok(Prepared::Error(error)) + } + Options::Load { id, checkout } => prepare_load(root, id.as_deref(), checkout, startup), + } +} + +fn load_options(rows: &[Value]) -> io::Result { + let mut versions = Vec::with_capacity(rows.len()); + let mut entries = Vec::with_capacity(rows.len()); + for row in rows { + let (scope, name) = stash_id_parts(&row["id"])?; + if name.is_empty() { + return Err(decline("stash id name")); + } + let hash = text(row.get("hash"))?; + if row["isNew"] == true { + entries.push(stash_new_entry(row, scope, name, hash)?); + } else { + let identity = scope.map_or_else(|| name.to_owned(), |sc| format!("{sc}/{name}")); + versions.push(json!({"identity":identity,"version":hash})); + } + } + Ok(json!({"versions":versions,"bitmapEntries":entries})) +} + +#[inline] +fn stash_id_parts(id: &Value) -> io::Result<(Option<&str>, &str)> { + if let Some(raw) = id.as_str() { + let raw = raw.trim(); + let bare = raw.rsplit_once('@').map_or(raw, |(prefix, _)| prefix.trim()); + return Ok(match bare.split_once('/') { + Some((scope, name)) => (Some(scope.trim()).filter(|val| !val.is_empty()), name.trim()), + None => (None, bare), + }); + } + Ok(( + id["scope"] + .as_str() + .map(str::trim) + .filter(|value| !value.is_empty()), + text(id.get("name"))?.trim(), + )) +} + +#[inline] +fn stash_new_entry(row: &Value, scope: Option<&str>, name: &str, hash: &str) -> io::Result { + let mut entry = row["bitmapEntry"].clone(); + if !entry.is_object() { + return Err(decline("stash bitmap entry")); + } + let mut id_obj = match scope { + Some(scope) => json!({"scope": scope, "name": name}), + None => json!({"name": name}), + }; + id_obj["version"] = json!(hash); + entry["id"] = id_obj; + Ok(entry) +} + +fn preflight_tracked(root: &Path, versions: &Value) -> io::Result<()> { + let versions = array(Some(versions), "stash tracked versions")?; + if versions.is_empty() { + return Ok(()); + } + let mut engine = super::engine::Engine::new()?; + let context: Value = engine.request(&json!({"kind":"statusContext","root":root,"home":workspace_command::home(),"history":false}))?; + let targets = array(context.get("targets"), "stash tracked targets")?; + for version in versions { + let target = targets + .iter() + .find(|target| target["identity"] == version["identity"]) + .ok_or_else(|| decline("stash tracked component requires canonical bitmap recovery"))?; + let directory = text( + target + .get("directory") + .or_else(|| target.get("rootDir")), + )? + .trim() + .trim_start_matches("./"); + if target["version"].as_str().is_none_or(str::is_empty) { + return Err(decline("stash tracked component requires canonical version recovery")); + } + if !root.join(directory).is_dir() { + return Err(decline("stash tracked component requires canonical path recovery")); + } + } + Ok(()) +} + +fn execute( + root: &Path, + args: &[OsString], + prepared: Prepared, + output: &bit_install::output::Output, +) -> io::Result { + let format = Formatter::new(Chalk::detect(args)); + let data = match prepared { + Prepared::Error(error) => return Err(io::Error::other(error)), + Prepared::Unrecognized(sub) => unrecognized_stash_report(args, &sub), + Prepared::List(rows) => list_report(&rows)?, + Prepared::Load { metadata, planned, tracked, count } => { + apply_load(root, &metadata, *planned, tracked)?; + format.success_summary(&format!( + "checked out {count} components according to the latest stash", + )) + } + Prepared::Save(save) => { + let count = apply_save(root, *save, output)?; + format.success_summary(&format!("stashed {count} components")) + } + }; + Ok(Report { data, code: 0, details: None }) +} + +#[inline] +fn unrecognized_stash_report(args: &[OsString], sub: &str) -> String { + let format = Formatter::new(Chalk::detect(args)); + format!( + r#"{} "{sub}" is not a subcommand of "stash", please run "bit stash --help" to list the subcommands"#, + format.error_symbol(), + ) +} + +fn run_without_workspace(cwd: &Path, args: &[OsString], options: &Options) -> io::Result { + if super::scope_rename::ci_active() { + return Ok(Outcome::Declined("not inside a workspace".to_owned())); + } + let (cmd, msg) = match options { + Options::Unrecognized(sub) => { + let report = + Report { data: unrecognized_stash_report(args, sub), code: 0, details: None }; + return workspace_command::execute_with_scope(cwd, args, "stash", &report); + } + Options::Save { .. } => ("save", super::log::OUTSIDE_WORKSPACE_ERR), + Options::Load { .. } => ("load", super::log::OUTSIDE_WORKSPACE_ERR), + Options::Error(err) => ("load", err.as_str()), + Options::List => ("list", "Cannot read properties of undefined (reading 'scope')"), + }; + workspace_command::execute_failure_scope(cwd, args, cmd, false, "", io::Error::other(msg)) +} + +#[inline] +fn list_report(rows: &[Value]) -> io::Result { + rows.iter() + .map(|row| { + let id = text(row.get("id"))?; + let count = array(row.get("components"), "stash list components")?.len(); + let message = row["message"] + .as_str() + .unwrap_or_else(|| if row["message"] == true { "true" } else { "" }); + Ok(format!("{id} ({count} components) {message}")) + }) + .collect::>>() + .map(|rows| rows.join("\n")) +} + +fn apply_load( + root: &Path, + metadata: &PreparedLoad, + mut planned: workspace_command::Planned, + tracked: Option, +) -> io::Result<()> { + metadata.validate()?; + workspace_command::commit(&mut planned, root)?; + if let Some(options) = tracked { + let environment = planned.stash_added(root)?; + let mut startup = Startup::default(); + let request = Request { operation: "checkout", options, to: None, verify_fresh: false }; + let mut tracked = workspace_command::plan_with( + root, + &request, + false, + false, + Some(&mut startup), + false, + environment, + )?; + workspace_command::checkout_lifecycle::before(&startup)?; + workspace_command::checkout_lifecycle::loaded(&startup); + workspace_command::commit(&mut tracked, root)?; + } + super::status::cleanup_temporary(root)?; + metadata.consume() +} + +fn apply_save( + root: &Path, + mut save: Save, + output: &bit_install::output::Output, +) -> io::Result { + if save.loaded > 0 { + output.status(&format!("loading {} component(s)", save.loaded)); + } + if !save.modified.is_empty() || !save.new.is_empty() { + output.status("importing missing dependencies..."); + } + save.persist(root)?; + reset(root, &mut save)?; + if !save.new.is_empty() { + let batch = NewBatch::prepare_with_components(root, &save.new, &save.removal_components())?; + batch.commit(&workspace_command::file_id())?; + } + super::status::cleanup_temporary(root)?; + Ok(save.modified.len() + save.new.len()) +} + +fn reset(root: &Path, save: &mut Save) -> io::Result<()> { + if save.modified.is_empty() { + return Ok(()); + } + let request = Request { + operation: "checkout", + options: json!({"to":"reset","stash":{"resetIds":save.modified},"skipDependencyInstallation":true}), + to: Some("reset"), + verify_fresh: false, + }; + let mut startup = Startup::default(); + let mut planned = workspace_command::plan_with( + root, + &request, + false, + false, + Some(&mut startup), + false, + save.take_environment(), + )?; + workspace_command::checkout_lifecycle::before(&startup)?; + workspace_command::checkout_lifecycle::loaded(&startup); + workspace_command::commit(&mut planned, root) +} + +fn prepare_load( + root: &Path, + id: Option<&str>, + mut checkout: Value, + startup: &mut Startup, +) -> io::Result { + let metadata = match PreparedLoad::prepare(&bit_install::config::scope_path(root), id) { + Ok(metadata) => metadata, + Err(error) if error.to_string() == "no stashed components found" => { + super::status::environments::quick(root, &workspace_command::home(), startup)?; + return Ok(Prepared::Error(error.to_string())); + } + Err(error) => return Err(error), + }; + let rows = array(metadata.data().get("stashCompsData"), "stash components")?; + let count = rows.len(); + checkout["stash"] = load_options(rows)?; + preflight_tracked(root, &checkout["stash"]["versions"])?; + checkout["skipDependencyInstallation"] = json!(true); + let tracked = checkout["stash"]["bitmapEntries"] + .as_array() + .is_some_and(|rows| !rows.is_empty()) + .then(|| { + let mut tracked = checkout.clone(); + tracked["stash"]["bitmapEntries"] = json!([]); + checkout["stash"]["versions"] = json!([]); + tracked + }); + let request = + Request { operation: "checkout", options: checkout, to: None, verify_fresh: false }; + let planned = workspace_command::plan_with_startup(root, &request, startup)?; + Ok(Prepared::Load { metadata: Box::new(metadata), planned: Box::new(planned), tracked, count }) +} + +fn failure( + root: &Path, + args: &[OsString], + command: &str, + startup: &Startup, + error: io::Error, +) -> io::Result { + if command != "save" { + return workspace_command::checkout_lifecycle::failure(root, args, command, startup, error); + } + workspace_command::checkout_lifecycle::before(startup)?; + let callback = error + .get_ref() + .and_then(|error| error.downcast_ref::()); + if let Some(callback) = callback { + print!("{}", callback.startup); + } + let failed = callback.map_or_else(String::new, |callback| callback.output.clone()); + workspace_command::execute_then(root, args, command, |output| { + if let Some(progress) = startup.build_progress + .as_ref() + .map(|progress| &progress["stash"]) + { + if let Some(count) = progress["loaded"] + .as_u64() + .filter(|count| *count > 0) + { + output.status(&format!("loading {count} component(s)")); + } + if progress["importing"] == true { + output.status("importing missing dependencies..."); + } + } + workspace_command::checkout_lifecycle::loaded(startup); + print!("{failed}"); + Err(error) + }) +} diff --git a/native/cli/src/commands/stash/options.rs b/native/cli/src/commands/stash/options.rs new file mode 100644 index 000000000000..157c363e8efa --- /dev/null +++ b/native/cli/src/commands/stash/options.rs @@ -0,0 +1,194 @@ +//! Published stash grammar; `--pattern` is a boolean in Bit 2.2.94. +//! +//! Defines [`Options`] and [`parse`] for `bit stash [save|list|load]` and serializes checkout [`Value`] options. +use serde_json::{Value, json}; + +/// Parsed subcommand and flags for `bit stash `. +pub(super) enum Options { + Save { include_new: bool, message: Option }, + List, + Load { id: Option, checkout: Value }, + Unrecognized(String), + Error(String), +} + +/// Parses `bit stash` CLI tokens into [`Options`]. +pub(super) fn parse(args: &[&str]) -> Option { + let filtered: Vec<&str> = args + .iter() + .copied() + .filter(|arg| { + !matches!( + *arg, + "--no-color" + | "--no-color=true" + | "--no-color=false" + | "--color" + | "--color=true" + | "--color=false" + | "--color=always" + | "--color=never", + ) + }) + .collect(); + let (command, rest) = filtered.split_first()?; + match command.trim() { + "save" | "s" => save(rest), + "list" if matches!(rest, [] | ["--"]) => Some(Options::List), + "load" | "pop" => load(rest), + sub if !sub.is_empty() && !sub.starts_with('-') && matches!(rest, [] | ["--"]) => { + Some(Options::Unrecognized(sub.to_owned())) + } + _ => None, + } +} + +/// Parses `bit stash save` flags (`--include-new`, `-m`/`--message`) into [`Options::Save`]. +fn save(args: &[&str]) -> Option { + let mut include_new = false; + let mut message = None; + let mut iter = args.iter().copied(); + while let Some(arg) = iter.next() { + if arg == "--" { + return iter + .next() + .is_none() + .then_some(Options::Save { include_new, message }); + } + apply_save_arg(&mut include_new, &mut message, arg, &mut iter)?; + } + Some(Options::Save { include_new, message }) +} + +fn apply_save_arg<'a>( + include_new: &mut bool, + message: &mut Option, + arg: &'a str, + iter: &mut impl Iterator, +) -> Option<()> { + if let Some(val) = arg + .strip_prefix("--message=") + .or_else(|| arg.strip_prefix("-m=")) + { + let trimmed = val.trim(); + if trimmed.is_empty() || trimmed.starts_with('-') { + return None; + } + *message = Some(trimmed.to_owned()); + return Some(()); + } + match arg { + "-i" | "--include-new" | "--include-new=true" | "-i=true" => *include_new = true, + "--include-new=false" | "--no-include-new" | "-i=false" => *include_new = false, + "-m" | "--message" => { + let value = iter + .next() + .map(str::trim) + .filter(|value| !value.is_empty() && !value.starts_with('-'))?; + *message = Some(value.to_owned()); + } + // The published boolean reaches idsByPattern; leave its error stack to Node. + _ => return None, + } + Some(()) +} + +fn load(args: &[&str]) -> Option { + let mut flags = LoadFlags::default(); + let mut iter = args.iter().copied(); + while let Some(arg) = iter.next() { + if arg == "--" { + for rest in iter { + let trimmed = rest.trim(); + (!trimmed.is_empty() + && !trimmed.starts_with('-') + && flags.id.replace(trimmed).is_none()) + .then_some(())?; + } + break; + } + flags.apply_arg(arg, &mut iter)?; + } + Some(flags.finish()) +} + +#[derive(Default)] +struct LoadFlags<'a> { + id: Option<&'a str>, + ours: bool, + theirs: bool, + manual: bool, + strategy: Option<&'a str>, +} +#[inline] +fn parse_strategy_token(raw: &str) -> Option<&str> { + let trimmed = raw + .trim() + .trim_matches(['"', '\'']) + .trim(); + (!trimmed.is_empty() && !trimmed.starts_with('-')).then_some(trimmed) +} + +impl<'a> LoadFlags<'a> { + fn apply_arg(&mut self, arg: &'a str, iter: &mut impl Iterator) -> Option<()> { + if self.boolean(arg) { + return Some(()); + } + if let Some(val) = arg + .strip_prefix("--auto-merge-resolve=") + .or_else(|| arg.strip_prefix("-r=")) + { + self.strategy = Some(parse_strategy_token(val)?); + return Some(()); + } + match arg { + "-r" | "--auto-merge-resolve" => { + self.strategy = Some(iter.next().and_then(parse_strategy_token)?); + } + value + if !value.starts_with('-') + && !value.trim().is_empty() + && self.id.is_none() => + { + self.id = Some(value.trim()); + } + _ => return None, + } + Some(()) + } + + #[inline] + fn boolean(&mut self, arg: &str) -> bool { + let (flag, val) = match arg { + "--force-ours" | "--force-ours=true" => (&mut self.ours, true), + "--force-ours=false" | "--no-force-ours" => (&mut self.ours, false), + "--force-theirs" | "--force-theirs=true" => (&mut self.theirs, true), + "--force-theirs=false" | "--no-force-theirs" => (&mut self.theirs, false), + "--manual" | "--manual=true" => (&mut self.manual, true), + "--manual=false" | "--no-manual" => (&mut self.manual, false), + _ => return false, + }; + *flag = val; + true + } + + #[inline] + fn finish(self) -> Options { + if self.ours && self.theirs { + return Options::Error( + "please use either --force-ours or --force-theirs, not both".to_owned(), + ); + } + if self.strategy.is_some_and(|value| !matches!(value, "ours" | "theirs" | "manual")) { + return Options::Error( + "--auto-merge-resolve must be one of the following: [ours, theirs, manual]" + .to_owned(), + ); + } + let mut checkout = json!({"forceOurs":self.ours,"forceTheirs":self.theirs}); + if let Some(strategy) = if self.manual { Some("manual") } else { self.strategy } { + checkout["strategy"] = json!(strategy); + } + Options::Load { id: self.id.map(str::to_owned), checkout } + } +} diff --git a/native/cli/src/commands/status.rs b/native/cli/src/commands/status.rs new file mode 100644 index 000000000000..c95c7cb5a577 --- /dev/null +++ b/native/cli/src/commands/status.rs @@ -0,0 +1,399 @@ +//! Native `bit status` (`s`): the workspace engine of `bit-object-import` +//! reads the workspace, models and dependency caches; this module computes +//! [`policy`], ignore and semver inputs via [`capture`] and [`COMMAND`] ([`read`], [`sort_rows`]), and formats [`report`] or `--json`. + +pub(super) mod autosync; +pub(super) mod cache; +pub(super) mod capture; +pub(super) mod environments; +pub(super) mod fresh; +pub(super) mod grammar; +pub(super) mod issues; +pub(super) mod js; +pub(super) mod policy; +pub(in crate::commands) mod report; + +pub(super) mod collate; + +mod lane_refresh; +mod loader; +mod reading; +mod sorting; +mod workspace_issues; + +use super::{color::Chalk, engine::Engine, workspace_command}; +use crate::{commands::Command, history::History, native::Outcome, workspace}; +use issues::Component; +use js::{array, decline, text}; +use reading::{ReadFailure, read}; +use serde_json::{Value, json}; +use sorting::sort_rows; +use std::{ + env, + ffi::OsString, + io::{self, Write}, + path::Path, + process::ExitCode, +}; + +/// Descriptor for the native `bit status` (`s`) command. +pub const COMMAND: Command = Command { name: "status", aliases: &["s"], run }; +/// `--json`, or the report with its flags. +enum Mode { + Json(report::Flags), + Report(report::Flags), + /// `--quick`, with `--json` or not. + Quick(bool), +} + +fn mode(args: &[OsString]) -> Option<(Mode, bool)> { + let arguments = workspace_command::utf8_args(args)?; + let (filtered, logged) = strip_log_flags(&arguments[1..]); + let filtered = filtered + .strip_suffix(&["--"]) + .unwrap_or(&filtered); + if filtered.contains(&"--") { + return None; + } + let (json, quick) = filtered + .iter() + .fold((false, false), |(json, quick), &arg| match arg { + "--json" | "-j" | "--json=true" | "-j=true" => (true, quick), + "--json=false" | "--no-json" | "-j=false" => (false, quick), + "--quick" | "--quick=true" => (json, true), + "--quick=false" | "--no-quick" => (json, false), + _ => (json, quick), + }); + let report_args: Vec<_> = filtered + .iter() + .copied() + .filter(|arg| !is_mode_flag(arg)) + .collect(); + let flags = report::parse(&report_args)?; + let selected = match (quick, json) { + (true, _) => Mode::Quick(json), + (false, true) => Mode::Json(flags), + (false, false) => Mode::Report(flags), + }; + Some((selected, logged)) +} + +#[inline] +fn is_mode_flag(arg: &str) -> bool { + matches!( + arg, + "--json" + | "-j" + | "--json=true" + | "--json=false" + | "--no-json" + | "-j=true" + | "-j=false" + | "--quick" + | "--quick=true" + | "--quick=false" + | "--no-quick" + | "--no-color" + | "--no-color=true" + | "--no-color=false" + | "--color" + | "--color=true" + | "--color=false" + | "--color=always" + | "--color=never", + ) +} + +/// Strip `--log`, `--log=`, or `--log ` from `args` and return whether logging was enabled. +fn strip_log_flags<'a>(args: &[&'a str]) -> (Vec<&'a str>, bool) { + const LEVELS: [&str; 12] = + ["trace", "debug", "info", "warn", "error", "fatal", "10", "20", "30", "40", "50", "60"]; + let mut out = Vec::with_capacity(args.len()); + let mut logged = false; + let mut iter = args.iter().copied().peekable(); + while let Some(arg) = iter.next() { + if arg == "--" { + out.push(arg); + out.extend(iter); + break; + } + if arg == "--log" { + logged = true; + if iter + .peek() + .is_some_and(|next| LEVELS.contains(next)) + { + iter.next(); + } + } else if arg.starts_with("--log=") { + logged = true; + } else { + push_status_arg(&mut out, arg); + } + } + (out, logged) +} + +#[inline] +fn push_status_arg<'a>(out: &mut Vec<&'a str>, arg: &'a str) { + if arg.starts_with('-') + && !arg.starts_with("--") + && arg.len() > 2 + && arg[1..] + .bytes() + .all(|byte| matches!(byte, b'j' | b'w' | b'l' | b'c')) + { + for byte in arg[1..].bytes() { + out.push(match byte { + b'j' => "-j", + b'w' => "-w", + b'l' => "-l", + _ => "-c", + }); + } + } else { + out.push(arg); + } +} + +fn run(args: &[OsString]) -> io::Result { + let Some((mode, logged)) = mode(args) else { + return Ok(Outcome::Declined("flags outside the native `status` grammar".to_string())); + }; + let Some(root) = workspace::root(&env::current_dir()?) else { + return Ok(Outcome::Declined("not inside a workspace".to_string())); + }; + if let Some(err_msg) = workspace_issues::config_error(&root) { + return workspace_command::execute_without_loader(&root, args, "status", |_| { + Err(io::Error::other(err_msg)) + }); + } + if logged { + writeln!(io::stdout(), r#"[*] started a new command: "status" with the following args:"#)?; + } + let home = env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" }).unwrap_or_default(); + match mode { + Mode::Json(flags) => json_status(&root, Path::new(&home), args, &flags), + Mode::Report(flags) => report_status(&root, Path::new(&home), args, &flags), + // `statusMini`: the file changes only. + Mode::Quick(json) => super::mini_status::quick(args, json), + } +} + +fn json_status( + root: &Path, + home: &Path, + args: &[OsString], + flags: &report::Flags, +) -> io::Result { + let (mut result, progress) = match read(root, home, flags, |_, _| Ok(())) { + Ok((result, (), progress)) => (result, progress), + Err(ReadFailure::Declined(error)) => { + return Ok(Outcome::Declined(format!("status: {error}"))); + } + Err(ReadFailure::Committed(error)) => return Err(error), + }; + report::color_issue_data(&mut result, Chalk::detect(args)); + let history = History::start(root, args); + let mut stdout = io::stdout().lock(); + stdout.write_all(progress.startup_stdout.as_bytes())?; + stdout.write_all(progress.load_stdout.as_bytes())?; + stdout.write_all(progress.issue_stdout.as_bytes())?; + stdout.write_all(serde_json::to_string_pretty(&result)?.as_bytes())?; + stdout.flush()?; + history.end(true); + Ok(Outcome::Exit(ExitCode::SUCCESS)) +} + +/// `StatusCmd.report`: the loader's lines, then the formatted status. +fn report_status( + root: &Path, + home: &Path, + args: &[OsString], + flags: &report::Flags, +) -> io::Result { + let (output, loaded, loads, progress) = match read(root, home, flags, |result, context| { + prepare_report(result, context, flags, Chalk::detect(args)) + }) { + Ok((_, (output, loaded, loads), progress)) => (output, loaded, loads, progress), + Err(ReadFailure::Declined(error)) => { + return Ok(Outcome::Declined(format!("status: {error}"))); + } + Err(ReadFailure::Committed(error)) => return Err(error), + }; + let report = + workspace_command::results::Report { data: output.data, code: output.code, details: None }; + io::stdout().write_all(progress.startup_stdout.as_bytes())?; + workspace_command::execute_with(root, args, "status", &report, |output| { + loader::render(output, loaded, loads, &progress) + }) +} + +fn prepare_report( + result: &Value, + context: &Value, + flags: &report::Flags, + chalk: Chalk, +) -> io::Result<(report::Output, usize, usize)> { + let output = report::report(result, context, flags, chalk)?; + let loaded = array(context.get("targets"), "targets")? + .iter() + .filter(|target| target["locallyRemoved"] != true) + .count(); + let loads = if loaded == 0 { + 0 + } else { + 3 + usize::from(!array(result.get("modifiedComponents"), "modified")?.is_empty()) + }; + Ok((output, loaded, loads)) +} + +#[inline] +fn active_components( + context: &Value, + components: &[issues::Component], +) -> io::Result> { + Ok(array(context.get("targets"), "targets")? + .iter() + .zip(components) + .filter(|(target, _)| target["locallyRemoved"] != true) + .map(|(_, component)| component.clone()) + .collect()) +} + +#[inline] +fn current_lane_id(context: &Value) -> io::Result { + if let Some(id) = context.get("currentLaneId") { + let trimmed = text(Some(id))?.trim(); + if !trimmed.is_empty() { + return Ok(trimmed.to_string()); + } + } + let scope = text( + context + .get("scope") + .and_then(|scope| scope.get("name")), + )? + .trim(); + Ok(format!("{scope}/main")) +} + +fn status_capture( + engine: &mut Engine, + root: &Path, + home: &Path, + context: &Value, + environments: Option, +) -> io::Result { + let targets = array(context.get("targets"), "targets")?; + capture::capture_with( + engine, + root, + home, + context, + targets, + capture::CaptureOptions { + environments, + missing_status_invalid: true, + ..Default::default() + }, + ) +} + +/// The rows `statusResult` reports, one per bitmap target. +fn result_targets( + context: &Value, + components: &[Component], + invalid: &std::collections::HashMap, + compared: &Value, + issues: &[Value], +) -> io::Result> { + let comparisons = array(compared.get("comparisons"), "comparisons")?; + let targets = array(context.get("targets"), "targets")?; + targets + .iter() + .zip(components) + .map(|(target, component)| { + let identity = &target["identity"]; + let modified = comparisons + .iter() + .find(|row| row["identity"] == *identity) + .and_then(|row| js::nullish(row.get("modified"))) + .cloned() + .unwrap_or(Value::Bool(false)); + let issues = issues + .iter() + .find(|row| row["id"] == *identity) + .map_or_else(|| json!([]), |row| row["issues"].clone()); + let mut row = json!({ + "identity": identity, + "version": target["version"], + "modified": modified, + "dependencies": component.dependencies, + "issues": issues, + "bitmapState": { + "localOnly": target["localOnly"], + "softTagged": target["softTagged"], + "locallyRemoved": target["locallyRemoved"], + }, + }); + if let Some(error) = identity + .as_str() + .and_then(|id| invalid.get(id)) + { + row["invalid"] = error.clone(); + } + Ok(row) + }) + .collect() +} + +/// Every version string the semver grammar must describe. +fn semver_values(context: &Value, targets: &[Value]) -> io::Result> { + let mut values = Vec::with_capacity(targets.len() * 2); + for target in targets { + values.push(text(target.get("version"))?.to_string()); + for dependency in array(target.get("dependencies"), "dependencies")? { + let ver = text(Some(dependency))?.rsplit_once('@').map_or("", |(_, ver)| ver); + values.push(ver.to_string()); + } + } + for model in array(context.get("models"), "models")? { + model_versions(model, &mut values)?; + } + for history in array(context.get("histories"), "histories")? { + values.push(nullish_text(history.get("remoteHead"))?); + } + Ok(values) +} + +/// A model's version names, orphaned versions and head. +#[inline] +fn model_versions(model: &Value, values: &mut Vec) -> io::Result<()> { + for field in ["versions", "orphanedVersions"] { + if let Some(versions) = js::nullish(model.get(field)) { + values.extend(js::object(versions)?.keys().cloned()); + } + } + values.push(nullish_text(model.get("head"))?); + Ok(()) +} + +/// `value ?? ''` for a string field. +#[inline] +fn nullish_text(value: Option<&Value>) -> io::Result { + js::nullish(value).map_or_else(|| Ok(String::new()), |val| text(Some(val)).map(str::to_string)) +} + +/// Consumer.onDestroy removes temporary versioning output after a completed read. +pub(super) fn cleanup_temporary(root: &Path) -> io::Result<()> { + let path = root.join(".bitTmp"); + match std::fs::symlink_metadata(&path) { + Ok(metadata) if metadata.is_dir() && !metadata.file_type().is_symlink() => { + std::fs::remove_dir_all(path) + } + Ok(_) => std::fs::remove_file(path), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error), + } +} diff --git a/native/cli/src/commands/status/autosync.rs b/native/cli/src/commands/status/autosync.rs new file mode 100644 index 000000000000..30db1c1aff2c --- /dev/null +++ b/native/cli/src/commands/status/autosync.rs @@ -0,0 +1,229 @@ +//! Independent native startup reconciliation for `workspace.bitmapAutoSync`. +//! +//! [`Startup`] and [`run_if_enabled`] synchronize [`Bitmap`] entries with [`Engine`] scope state before command execution. + +use super::{ + Engine, + js::{decline, text, without_version}, +}; +use bit_install::workspace::Bitmap; +use serde_json::{Value, json}; +use std::{fmt::Write as _, fs, io, path::Path, process::Command}; + +/// Accumulated startup reconciliation state and captured output from `workspace.bitmapAutoSync`. +#[derive(Default)] +pub(in crate::commands) struct Startup { + pub(in crate::commands) committed: bool, + pub(in crate::commands) stdout: String, + pub(in crate::commands) build_stdout: String, + pub(in crate::commands) build_progress: Option, + pub(in crate::commands) snapshot_events: Vec<(Value, String)>, + pub(in crate::commands) snapshot_tail: Option, +} + +/// Runs `.bitmap` auto-synchronization against `.git` when `workspace.bitmapAutoSync` is enabled. +#[inline] +pub(in crate::commands) fn run_if_enabled(root: &Path) -> io::Result<()> { + if !root.join(".git").exists() { + return Ok(()); + } + let config = bit_object_import::parse_jsonc(&fs::read(root.join("workspace.jsonc"))?)?; + if !crate::commands::build_cmd::is_bitmap_auto_sync(&config) { + return Ok(()); + } + let mut engine = Engine::new()?; + let mut startup = Startup::default(); + run(&mut engine, root, &mut startup) +} + +/// Executes `bitmapAutoSync` against `engine` and records progress and snapshot events into `startup`. +pub(in crate::commands) fn run( + engine: &mut Engine, + root: &Path, + startup: &mut Startup, +) -> io::Result<()> { + if !root.join(".git").exists() { + return Ok(()); + } + let config = bit_object_import::parse_jsonc(&fs::read(root.join("workspace.jsonc"))?)?; + let Some((_, workspace)) = + crate::commands::update_cmd::aspect_entry(&config, "teambit.workspace/workspace") + else { + return Ok(()); + }; + if workspace["bitmapAutoSync"] != true { + return Ok(()); + } + let bitmap = Bitmap::load(root, workspace["defaultScope"].as_str())?; + if bitmap.lane + .as_ref() + .is_some_and(|lane| lane.name.trim() != "main") + { + return Ok(()); + } + let sentinel = crate::workspace::scope_path(root).join("last-pull-sync"); + let previous = match fs::read_to_string(&sentinel) { + Ok(value) => Some(value), + Err(error) if error.kind() == io::ErrorKind::NotFound => None, + Err(error) => return Err(error), + }; + let Some(head) = git_head(root) else { + return Ok(()); + }; + if previous.as_deref().map(str::trim) == Some(&head) { + return Ok(()); + } + let ids = lookup_ids(&bitmap); + synchronize(engine, root, startup, &head, &ids) +} + +fn synchronize( + engine: &mut Engine, + root: &Path, + startup: &mut Startup, + head: &str, + ids: &[String], +) -> io::Result<()> { + if ids.is_empty() { + startup.committed = true; + let directory = crate::workspace::scope_path(root); + fs::create_dir_all(&directory)?; + return fs::write(directory.join("last-pull-sync"), head); + } + if presentation() { + writeln!( + &mut startup.stdout, + "- importing {} components bitmapAutoSync: latest scope HEAD", + ids.len(), + ) + .map_err(io::Error::other)?; + } + let Ok(prepared) = crate::commands::import::prepare_status_refresh(engine, root, ids) else { + return Ok(()); // Node logs a debug warning and retries on its next invocation. + }; + let token = text(prepared.get("token"))?; + startup.committed = true; + let response: Value = engine.request(&json!({"kind":"commandAutoSyncFinalize","root":root, + "token":token,"ids":ids.iter().map(|id|without_version(id)).collect::>(),"gitHead":head, + "bitmapHistoryFileId":crate::commands::workspace_command::file_id()}))?; + let count = response["synced"].as_u64().ok_or_else(|| decline("auto-sync count"))?; + if presentation() { + progress(startup, ids.len(), &prepared, count)?; + } + Ok(()) +} + +#[inline] +fn git_head(root: &Path) -> Option { + if let Some(head) = git_dir_head(&root.join(".git")) { + return Some(head); + } + let executable = crate::commands::diff::git::executable(root).ok()?; + let output = Command::new(executable) + .args(["rev-parse", "HEAD"]) + .current_dir(root) + .output() + .ok()?; + output.status + .success() + .then(|| String::from_utf8_lossy(&output.stdout).trim().to_owned()) + .filter(|head| !head.is_empty()) +} + +#[inline] +fn git_dir_head(git: &Path) -> Option { + if !git.is_dir() { + return None; + } + let head = fs::read_to_string(git.join("HEAD")).ok()?; + let head = head.trim(); + if let Some(reference) = head.strip_prefix("ref:") { + let reference = reference.trim(); + if reference.is_empty() || reference.starts_with('/') || reference.contains("..") { + return None; + } + let value = fs::read_to_string(git.join(reference)).ok()?; + let value = value.trim(); + (!value.is_empty()).then(|| value.to_owned()) + } else { + (!head.is_empty()).then(|| head.to_owned()) + } +} + +#[inline] +fn lookup_ids(bitmap: &Bitmap) -> Vec { + bitmap.entries + .iter() + .filter(|entry| entry.lanes.available_on_current_lane) + .filter_map(|entry| { + entry.id + .effective_scope() + .map(str::trim) + .filter(|scope| !scope.is_empty()) + .map(|scope| { + let version = entry.id.version + .as_deref() + .map(str::trim) + .filter(|version| !version.is_empty()) + .map_or_else(String::new, |version| format!("@{version}")); + format!("{scope}/{}{version}", entry.id.name.trim()) + }) + }) + .collect() +} + +fn progress(startup: &mut Startup, ids: usize, prepared: &Value, synced: u64) -> io::Result<()> { + let imported = prepared["refreshCount"] + .as_u64() + .unwrap_or(ids as u64); + writeln!( + &mut startup.stdout, + "- successfully imported {imported} components bitmapAutoSync: latest scope HEAD", + ) + .map_err(io::Error::other)?; + writeln!( + &mut startup.stdout, + "- importing {ids} components bitmapAutoSync: dependencies for sync", + ) + .map_err(io::Error::other)?; + writeln!( + &mut startup.stdout, + "- successfully imported {imported} objects bitmapAutoSync: dependencies for sync", + ) + .map_err(io::Error::other)?; + if synced > 0 { + writeln!( + &mut startup.stdout, + " bitmapAutoSync: synced {synced} component(s) to scope HEAD", + ) + .map_err(io::Error::other)?; + } + Ok(()) +} + +#[inline] +fn presentation() -> bool { + let mut json = false; + for arg in std::env::args().skip(1) { + if arg == "--" { + break; + } + match arg.as_str() { + "--json" | "--json=true" | "-j" | "-j=true" => json = true, + "--json=false" | "--no-json" | "-j=false" => json = false, + short + if short.starts_with('-') + && !short.starts_with("--") + && short.len() > 2 + && short[1..] + .bytes() + .all(|b| matches!(b, b'j' | b'w' | b'l' | b'c')) + && short.as_bytes().contains(&b'j') => + { + json = true; + } + _ => {} + } + } + !json +} diff --git a/native/cli/src/commands/status/cache.rs b/native/cli/src/commands/status/cache.rs new file mode 100644 index 000000000000..8dba1d586b83 --- /dev/null +++ b/native/cli/src/commands/status/cache.rs @@ -0,0 +1,70 @@ +//! The `no-fs-cache` feature, which makes the native capture detect dependencies +//! afresh without cache writes, and Bit's cache root ([`cache_root`]). +//! `dependencyCacheNeedsInvalidation` lives with the dependency cache in +//! `bit_install::dependencies::cache::invalidation`. +//! +//! [`no_fs_cache`] ([`NO_FS_CACHE`]) resolves feature flags from [`global_config_dir`] and environment variables. + +pub use bit_workspace_config::cache_root; + +use bit_workspace_config::global_config_dir; +use std::{env, fs, path::Path}; + +/// Feature flag name that disables filesystem dependency cache reads and writes. +const NO_FS_CACHE: &str = "no-fs-cache"; + +/// Whether `no-fs-cache` may be enabled in any config layer: `BIT_FEATURES`, +/// `BIT_CONFIG_FEATURES`, the workspace's config store, `scope.json` or the +/// global config. Layer precedence is not resolved: a mention anywhere declines. +#[must_use] +pub fn no_fs_cache(root: &Path) -> bool { + if ["BIT_FEATURES", "BIT_CONFIG_FEATURES"] + .iter() + .any(|name| { + env::var_os(name).is_some_and(|value| mentions_no_fs_cache(&value.to_string_lossy())) + }) + { + return true; + } + let files = [ + root.join("workspace.jsonc"), + crate::workspace::scope_path(root).join("scope.json"), + global_config_dir().join("config.json"), + ]; + files + .iter() + .any(|file| { + fs::read(file) + .is_ok_and(|bytes| { + bytes + .windows(NO_FS_CACHE.len()) + .any(|window| window.eq_ignore_ascii_case(NO_FS_CACHE.as_bytes())) + }) + }) +} + +#[inline] +fn mentions_no_fs_cache(value: &str) -> bool { + value + .split([',', ';', ':', ' ', '\t', '\n', '\r']) + .any(|feature| { + feature + .trim() + .trim_matches(['"', '\'']) + .trim() + .eq_ignore_ascii_case(NO_FS_CACHE) + }) +} + +/// Encodes `bytes` as a lowercase hexadecimal string. +#[must_use] +#[inline] +pub fn hex(bytes: &[u8]) -> String { + use std::fmt::Write; + bytes + .iter() + .fold(String::with_capacity(bytes.len() * 2), |mut text, byte| { + let _ = write!(text, "{byte:02x}"); + text + }) +} diff --git a/native/cli/src/commands/status/capture.rs b/native/cli/src/commands/status/capture.rs new file mode 100644 index 000000000000..eeb673e3ae21 --- /dev/null +++ b/native/cli/src/commands/status/capture.rs @@ -0,0 +1,390 @@ +//! The capture of the current workspace, shared by status, diff and the +//! workspace commands: each selected component's files and +//! dependencies, and the [`commit`] that writes what the command staged. +//! +//! Re-exports [`workspace_issues`], [`issue_callbacks`], [`commit_checkout_dependencies`], [`snapshot_data`], and [`commit`]. + +pub use callbacks::{ + callback_issue_stdout, callback_load_stdout, callback_startup_stdout, issue_callbacks, + workspace_issues, +}; +pub(in crate::commands) use callbacks::{restore_callback_host, take_callback_environment}; +pub use commit::{commit, commit_checkout_dependencies}; +pub use snapshot::{ + snapshot_build, snapshot_control_reads, snapshot_data, snapshot_editor, snapshot_host, + snapshot_hydrate, snapshot_package_manifests, snapshot_pre_snap, snapshot_registry, + snapshot_required, snapshot_workspace_issues, +}; + +mod bitmap; +mod callbacks; +mod commit; +mod dependencies; +mod identities; +mod missing; +mod order; +mod snapshot; +mod switch; +mod target; + +use super::{ + cache, + environments::Environments, + fresh, + issues::Component, + js::{self, array, decline, full_identity, text}, + policy, +}; +use crate::commands::{ + engine::Engine, + inventory::{Inventory, Options}, +}; +use bit_install::dependencies::cache::invalidation::{deps_dir, needs_invalidation}; +use identities::{base_identity, record}; +use serde_json::{Value, json}; +use std::{collections::HashMap, io, path::Path}; +/// Captured workspace component rows, dependency states, environment facts, and pending [`CacheWrites`]. +pub struct Capture { + /// Every selected target: identity, version, directory, files, current + /// metadata and peer dependencies. + pub captured: Vec, + pub comparisons: Vec, + pub components: Vec, + /// Environment callbacks used by the native diagnostic checks. + pub environment_facts: Value, + /// Workspace components whose current model version is removed remotely. + pub remotely_removed: Vec, + /// Bitmap targets whose directory is absent from the workspace. + pub invalid: HashMap, + /// `retainDependencyState`: each target's dependency state, by identity. + pub dependency_states: HashMap, + /// What the commit writes to the dependency cache. + pub cache: CacheWrites, +} + +/// The dependency cache entries a capture's commit writes. +#[derive(Default)] +pub struct CacheWrites { + /// Fresh detections whose cache entries are written on commit. + pending: Vec, + /// `dependencyCacheNeedsInvalidation`: every nested component is detected + /// afresh and the whole cache is replaced on commit. + reset: bool, + /// Cache entries the command computed for a projected version; they + /// replace the pending entries of the same component. + pub replacements: Vec, + /// Only the replacements are written: a merge whose snap failed. + pub replacements_only: bool, + /// `dependencyCacheVersions`: the version a pending entry is written under. + versions: HashMap, + /// Validate fresh reads, but never read from or write to the FS dependency cache. + no_fs_cache: bool, + environment_reads: Vec, + callbacks: callbacks::Callbacks, +} + +impl CacheWrites { + /// Tracking loads can acquire link issues before their generated packages exist. + #[inline] + pub(in crate::commands) fn block_dependencies( + &mut self, + identities: &std::collections::HashSet, + ) { + for entry in self.pending.iter_mut().chain(self.replacements.iter_mut()) { + if identities.contains(base_identity(entry.identity())) { + entry.block_cache(); + } + } + } + + /// `forceDependencyCacheReset`: the whole cache is replaced on commit. + #[inline] + pub fn force_reset(&mut self) { + self.reset = true; + } + + #[inline] + fn is_empty(&self) -> bool { + self.pending.is_empty() && self.replacements.is_empty() && !self.reset + } + + /// The pending entries under their written versions, unless replaced, + /// then the replacements. + fn entries(&self) -> Vec { + let replaced: Vec<&str> = self.replacements + .iter() + .map(|entry| base_identity(entry.identity())) + .collect(); + let pending = self.pending + .iter() + .filter(|_| !self.replacements_only) + .map(|entry| { + let identity = base_identity(entry.identity()); + self.versions + .get(identity) + .map_or_else( + || entry.clone(), + |version| entry.renamed(format!("{identity}@{version}")), + ) + }); + pending + .filter(|entry| !replaced.contains(&base_identity(entry.identity()))) + .chain(self.replacements.iter().cloned()) + .collect() + } +} + +/// The `captureOptions` of the write commands. +#[derive(Default)] +pub struct CaptureOptions { + /// A read-only aspect session initialized before the authoritative context snapshot. + pub(in crate::commands) environments: Option, + /// Permit read-only consumers to capture soft-tagged and local-only components normally. + pub allow_special_states: bool, + /// `verifyFreshDependencies`: cached dependencies are detected again, + /// and any difference declines. + pub verify: bool, + /// `resetDependencyCache`: the whole cache is replaced on commit. + pub reset: bool, + /// `dependencyCacheVersions`, by identity. + pub cache_versions: HashMap, + /// Recovery reset may prove a changed file from hashes when source parsing fails. + pub incomplete_reset: bool, + /// A missing tracked directory is an empty current tree for `checkout reset`. + pub missing_checkout_reset: bool, + /// Status reports a missing tracked directory as an invalid component. + pub missing_status_invalid: bool, +} + +impl CaptureOptions { + #[inline] + fn validate_targets(&self, targets: &[Value]) -> io::Result<()> { + if !self.missing_status_invalid + && !self.allow_special_states + && bitmap::has_special_state(targets) + { + return Err(decline("bitmap state requires the canonical workspace command")); + } + Ok(()) + } + + #[inline] + fn scan_behavior(&self) -> ScanBehavior { + ScanBehavior { + verify: self.verify, + callbacks: self.missing_status_invalid || self.allow_special_states, + incomplete_reset: self.incomplete_reset, + missing_checkout_reset: self.missing_checkout_reset, + missing_status_invalid: self.missing_status_invalid, + } + } +} + +impl Capture { + /// `capture.dependencyReadSet`: the read sets of every fresh detection. + #[must_use] + #[inline] + pub fn dependency_read_set(&self) -> Vec { + self.cache.pending + .iter() + .flat_map(|entry| entry.read_set().iter().cloned()) + .chain(self.cache.environment_reads.iter().cloned()) + .collect() + } +} + +/// Capture files and dependencies using the caller's read and cache policy. +pub fn capture_with( + engine: &mut Engine, + root: &Path, + home: &Path, + context: &Value, + targets: &[Value], + mut capture_options: CaptureOptions, +) -> io::Result { + let mut environments = capture_options.environments.take().unwrap_or_else(Environments::new); + capture_environment(engine, root, home, context, targets, capture_options, &mut environments) + .map_err(|error| environments.failure(error)) +} + +fn capture_environment( + engine: &mut Engine, + root: &Path, + home: &Path, + context: &Value, + targets: &[Value], + capture_options: CaptureOptions, + environments: &mut Environments, +) -> io::Result { + capture_options.validate_targets(targets)?; + let context = policy::pending_context(root, context)?; + let snapshot = environments.is_snapshot(); + let checkout = environments.is_checkout(); + validate_write(root, &context, &capture_options, environments)?; + let context = environments.context_for(root, &context, targets)?; + let context = &context; + let options: Options = + serde_json::from_value(context["options"].clone()).map_err(io::Error::other)?; + let inventory = Inventory::new(&options)?; + let mut behavior = capture_options.scan_behavior(); + behavior.callbacks |= snapshot || checkout; + let mut captured = initialize(root, context, targets.len(), capture_options)?; + let mut facts = None; + let scan = Scan { + engine, + root, + home, + context, + inventory: &inventory, + facts: &mut facts, + environments, + behavior, + }; + switch::scan(scan, targets, &mut captured)?; + if snapshot { + order::snapshot(root, &mut captured)?; + } + if environments.is_import() { + commit::publish_import_dependencies(engine, root, &mut captured)?; + } + if behavior.callbacks && captured.cache.callbacks.host.is_none() { + validate_documentation(engine, context)?; + environments.callbacks(context, &mut captured, behavior.missing_status_invalid)?; + } + Ok(captured) +} + +#[inline] +fn validate_documentation(engine: &mut Engine, context: &Value) -> io::Result<()> { + engine.validate_read_set( + context["documentationReadSet"] + .as_array() + .map_or(&[], Vec::as_slice), + "documentation sources changed before callbacks", + ) +} + +#[inline] +fn validate_write( + root: &Path, + context: &Value, + options: &CaptureOptions, + environments: &Environments, +) -> io::Result<()> { + if !environments.is_snapshot() + && !environments.is_checkout() + && !options.missing_status_invalid + && !options.allow_special_states + { + environments.require_write(root, context)?; + } + Ok(()) +} + +fn initialize( + root: &Path, + context: &Value, + target_count: usize, + capture_options: CaptureOptions, +) -> io::Result { + let no_fs_cache = cache::no_fs_cache(root); + let reset = !no_fs_cache + && !context["importCacheInitialized"].as_bool().unwrap_or(false) + && target_count > 0 + && (capture_options.reset || needs_invalidation(root, &deps_dir(root))?); + Ok(Capture { + captured: Vec::with_capacity(target_count), + comparisons: Vec::with_capacity(target_count), + components: Vec::with_capacity(target_count), + environment_facts: { + let mut facts = context["environmentFacts"].clone(); + facts["checkoutCacheMisses"] = json!({}); + facts + }, + remotely_removed: Vec::new(), + invalid: HashMap::new(), + dependency_states: HashMap::with_capacity(target_count), + cache: CacheWrites { + reset, + no_fs_cache, + versions: capture_options.cache_versions, + environment_reads: context["environmentReadSet"] + .as_array() + .cloned() + .unwrap_or_default(), + ..CacheWrites::default() + }, + }) +} + +struct Scan<'s> { + engine: &'s mut Engine, + root: &'s Path, + home: &'s Path, + context: &'s Value, + inventory: &'s Inventory, + /// Loaded on the first fresh detection: it may run Node once. + facts: &'s mut Option, + behavior: ScanBehavior, + environments: &'s mut Environments, +} + +#[derive(Clone, Copy)] +struct ScanBehavior { + callbacks: bool, + verify: bool, + incomplete_reset: bool, + missing_checkout_reset: bool, + missing_status_invalid: bool, +} + +impl Scan<'_> { + /// The component's files, relative to its directory. + fn files(&mut self, directory: &str) -> io::Result> { + let trimmed = directory + .trim() + .trim_start_matches("./") + .trim_end_matches('/'); + let directory = if trimmed.is_empty() { "." } else { trimmed }; + let others: Vec<_> = if directory == "." { + array(self.context.get("targets"), "targets")? + .iter() + .filter_map(|row| { + row.get("directory") + .or_else(|| row.get("rootDir")) + .and_then(Value::as_str) + }) + .map(|dir| { + dir.trim() + .trim_start_matches("./") + .trim_end_matches('/') + }) + .filter(|dir| !dir.is_empty() && *dir != ".") + .map(|dir| self.root.join(dir)) + .collect() + } else { + Vec::new() + }; + let begin = json!({ + "kind": "inventoryBegin", + "root": self.root.join(directory), + "workspace": self.root, + "excludeDirs": others, + }); + let manifest = self.inventory.scan(self.engine, begin, "inventoryFinish")?; + array(manifest.get("files"), "inventory files")? + .iter() + .map(|file| text(file.get("relativePath")).map(|path| path.trim().to_string())) + .collect() + } +} + +/// `{ ...request, ...policy.input }`. +#[inline] +pub fn spread(request: &mut Value, input: &Value) -> io::Result<()> { + let request = request.as_object_mut().ok_or_else(|| decline("status request"))?; + for (key, value) in input.as_object().ok_or_else(|| decline("status policy input"))? { + request.insert(key.clone(), value.clone()); + } + Ok(()) +} diff --git a/native/cli/src/commands/status/capture/bitmap.rs b/native/cli/src/commands/status/capture/bitmap.rs new file mode 100644 index 000000000000..2c00ff4ec62b --- /dev/null +++ b/native/cli/src/commands/status/capture/bitmap.rs @@ -0,0 +1,65 @@ +//! Bitmap states that need the canonical capture for write commands. +//! +//! [`capture_special`] and [`has_special_state`] handle special `.bitmap` entries into [`Capture`] via [`missing`]. + +use super::{Capture, missing, text}; +use serde_json::Value; +use std::io; + +/// Captures locally removed or pending-import `.bitmap` targets into `captured` when applicable. +#[inline] +pub(super) fn capture_special( + context: &Value, + target: &Value, + captured: &mut Capture, +) -> io::Result { + if missing::status_removed(target, captured)? { + return Ok(true); + } + missing::status_pending(context, target, captured) +} + +/// Returns `true` when any target in `targets` is marked `localOnly`, `softTagged`, or `locallyRemoved`. +#[inline] +pub(super) fn has_special_state(targets: &[Value]) -> bool { + targets + .iter() + .any(|target| { + target["localOnly"] == true + || target["softTagged"] == true + || target["locallyRemoved"] == true + }) +} + +/// Appends `target`'s identity to `captured.remotely_removed` when `extensions` marks it removed. +pub(super) fn record_remote( + enabled: bool, + target: &Value, + extensions: &[Value], + captured: &mut Capture, +) -> io::Result<()> { + if !enabled || !extensions.iter().any(is_remotely_removed_extension) { + return Ok(()); + } + let identity = text( + target + .get("identity") + .filter(|val| !val.is_null()) + .or_else(|| target.get("id")), + )? + .trim(); + if !identity.is_empty() + && !captured.remotely_removed + .iter() + .any(|existing| existing == identity) + { + captured.remotely_removed.push(identity.to_string()); + } + Ok(()) +} + +#[inline] +fn is_remotely_removed_extension(extension: &Value) -> bool { + crate::commands::update_cmd::matches_aspect(extension, "teambit.component/remove") + && (extension["config"]["removed"] == true || extension["data"]["removed"] == true) +} diff --git a/native/cli/src/commands/status/capture/callbacks.rs b/native/cli/src/commands/status/capture/callbacks.rs new file mode 100644 index 000000000000..fee6ded3b543 --- /dev/null +++ b/native/cli/src/commands/status/capture/callbacks.rs @@ -0,0 +1,162 @@ +//! Retain permitted callback state and output alongside the native capture. +//! +//! [`Callbacks`] stores retained host sessions, temporary directories, and [`CacheWrites`] (`retain_snapshot_data`) for [`Capture`]. + +use super::{CacheWrites, Capture}; +use serde_json::Value; +use std::io; + +/// Retained host session, temporary callback directories, and buffered stdout for [`Capture`]. +#[derive(Default)] +pub(super) struct Callbacks { + pub(super) host: Option, + directories: Vec, + pub(super) snapshot_data: std::collections::HashMap, + pub(super) load_stdout: std::sync::Mutex, + pub(super) issue_stdout: std::sync::Mutex, + pub(super) startup_stdout: std::sync::Mutex, +} + +impl CacheWrites { + /// Records snapshot callback data for `identity` inside `self.callbacks`. + #[inline] + pub(in crate::commands::status) fn retain_snapshot_data( + &mut self, + identity: &str, + data: Value, + ) { + self.callbacks.snapshot_data.insert(identity.trim().to_owned(), data); + } + + #[inline] + pub(in crate::commands::status) fn retain_callback_directory( + &mut self, + directory: tempfile::TempDir, + ) { + self.callbacks.directories.push(directory); + } + + #[inline] + pub(in crate::commands::status) fn retain_callback_host( + &mut self, + host: Option, + ) { + self.callbacks.host = host; + } + + #[inline] + pub(in crate::commands::status) fn retain_callback_proofs(&mut self, proofs: &[Value]) { + self.environment_reads.reserve(proofs.len()); + for proof in proofs { + if !self.environment_reads.contains(proof) { + self.environment_reads.push(proof.clone()); + } + } + } + + #[inline] + pub(in crate::commands::status) fn retain_callback_stdout(&self, output: &str) { + if !output.is_empty() { + self.callbacks.load_stdout + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .push_str(output); + } + } + + #[inline] + pub(in crate::commands::status) fn retain_callback_startup(&self, output: &str) { + if !output.is_empty() { + self.callbacks.startup_stdout + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .push_str(output); + } + } +} + +/// Custom issue slots consume the complete Rust issue facts on hydrated classes. +pub fn issue_callbacks( + context: &Value, + capture: &Capture, + rows: &[Value], +) -> io::Result> { + match &capture.cache.callbacks.host { + Some(host) => { + let (rows, stdout) = + super::super::environments::callbacks::issue_callbacks(host, rows, context)?; + if !stdout.is_empty() { + capture.cache.callbacks.issue_stdout + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .push_str(&stdout); + } + Ok(rows) + } + None => Ok(rows.to_vec()), + } +} + +/// Workspace loading failures retained by the published provider host. +pub fn workspace_issues(capture: &Capture) -> io::Result> { + let Some(host) = &capture.cache.callbacks.host else { + return Ok(Vec::new()); + }; + let result = host.call("statusWorkspaceIssues", &serde_json::json!({})); + capture.cache.retain_callback_stdout(&host.take_stdout()); + let raw: Vec = serde_json::from_value(result?).map_err(io::Error::other)?; + let mut issues = Vec::with_capacity(raw.len()); + for issue in raw { + let trimmed = issue.trim(); + if !trimmed.is_empty() + && !issues + .iter() + .any(|existing| existing == trimmed) + { + let owned = if trimmed.len() == issue.len() { issue } else { trimmed.to_owned() }; + issues.push(owned); + } + } + Ok(issues) +} + +/// Output produced by permitted Harmony callbacks, buffered until command preflight completes. +#[must_use] +#[inline] +pub fn callback_load_stdout(capture: &Capture) -> String { + capture.cache.callbacks.load_stdout + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone() +} + +#[must_use] +#[inline] +pub fn callback_issue_stdout(capture: &Capture) -> String { + capture.cache.callbacks.issue_stdout + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone() +} + +#[must_use] +#[inline] +pub fn callback_startup_stdout(capture: &Capture) -> String { + capture.cache.callbacks.startup_stdout + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone() +} + +/// Transfer the retained provider session to a later native operation in the same command. +#[inline] +pub(in crate::commands) fn take_callback_environment( + capture: &mut Capture, +) -> Option { + capture.cache.callbacks.host.take().map(super::super::environments::Environments::from_host) +} + +#[inline] +pub(in crate::commands) fn restore_callback_host(capture: &mut Capture, host: crate::host::Host) { + capture.cache.callbacks.host = Some(host); +} diff --git a/native/cli/src/commands/status/capture/commit.rs b/native/cli/src/commands/status/capture/commit.rs new file mode 100644 index 000000000000..d61af5f722ab --- /dev/null +++ b/native/cli/src/commands/status/capture/commit.rs @@ -0,0 +1,98 @@ +//! Validate all read receipts before publishing capture caches and history. +//! +//! [`commit`] publishes [`Capture`] dependency caches and staged history records through [`Engine`] and [`fresh`]. + +#[cfg(test)] +#[path = "commit_tests.rs"] +mod tests; + +use super::{Capture, Engine, Path, Value, array, decline, fresh, io, json}; + +/// Commits `capture` (`capture.commit()`): validates read receipts and staged histories, writes dependency caches, then writes histories. +pub fn commit( + engine: &mut Engine, + root: &Path, + context: &Value, + capture: &Capture, +) -> io::Result<()> { + engine.validate_read_set(&capture.cache.environment_reads, "environment facts changed")?; + let plans = array(context.get("pendingHistory"), "history plans")?; + if !plans.is_empty() { + let validated: Value = engine.request( + &json!({"kind": "statusHistoryValidate", "root": root, "plans": plans}), + )?; + if validated["fresh"] != true { + return Err(decline("status history changed")); + } + } + if capture.environment_facts["importDependencyCachePublished"] != true + && !capture.cache.is_empty() + { + commit_pending_cache(engine, root, capture)?; + } + if plans.is_empty() { + return Ok(()); + } + let committed: Value = + engine.request(&json!({"kind": "statusHistoryCommit", "root": root, "plans": plans}))?; + let written = committed.get("written").and_then(Value::as_u64); + if committed["ok"] == false || written.is_none_or(|written| written > plans.len() as u64) { + return Err(decline("status history commit receipt")); + } + Ok(()) +} + +/// Publishes pending dependency cache entries from `capture` through [`fresh::commit`]. +#[inline] +fn commit_pending_cache(engine: &mut Engine, root: &Path, capture: &Capture) -> io::Result<()> { + let entries = capture.cache.entries(); + let reset = checkout_reset(capture); + if !entries.is_empty() || reset { + fresh::commit(engine, root, &entries, reset, !capture.cache.no_fs_cache)?; + } + Ok(()) +} + +/// `DependencyLoader` saves successful reads before checkout validates merge conflicts. +pub fn commit_checkout_dependencies( + engine: &mut Engine, + root: &Path, + capture: &Capture, +) -> io::Result<()> { + if capture.cache.callbacks.host.is_none() || capture.cache.is_empty() { + return Ok(()); + } + let entries = capture.cache.entries(); + if !entries.is_empty() { + // The retained loader can invalidate the old cache and publish a fresh + // environment entry after capture's initial reset decision. Keep that + // published entry when the workspace is no longer stale. + let reset = checkout_reset(capture); + fresh::commit(engine, root, &entries, reset, !capture.cache.no_fs_cache)?; + } + Ok(()) +} + +/// Published `DependenciesLoader.load` saves eligible data before Harmony's onLoad slots. +pub(super) fn publish_import_dependencies( + engine: &mut Engine, + root: &Path, + capture: &mut Capture, +) -> io::Result<()> { + crate::commands::add::restrict_dependency_cache(root, capture); + engine.validate_read_set(&capture.dependency_read_set(), "import dependency facts changed")?; + if !capture.cache.is_empty() { + let entries = capture.cache.entries(); + if !entries.is_empty() || capture.cache.reset { + fresh::commit(engine, root, &entries, capture.cache.reset, !capture.cache.no_fs_cache)?; + } + } + // Keep every retained read proof and immutable pending entry, without publishing twice. + capture.environment_facts["importDependencyCachePublished"] = json!(true); + Ok(()) +} + +#[inline] +fn checkout_reset(capture: &Capture) -> bool { + capture.cache.reset && capture.environment_facts["checkoutLoaderCacheChecked"] != true +} diff --git a/native/cli/src/commands/status/capture/commit_tests.rs b/native/cli/src/commands/status/capture/commit_tests.rs new file mode 100644 index 000000000000..ec92a2af6da4 --- /dev/null +++ b/native/cli/src/commands/status/capture/commit_tests.rs @@ -0,0 +1,63 @@ +//! Unit tests for dependency-cache reset gating ([`checkout_reset`]) and [`fresh`] checks during checkout [`Capture`] commit. +//! +//! Verifies [`CacheWrites`], [`deps_dir`], and [`put`] cache invalidation across [`Engine`] commits. + +use super::{Engine, checkout_reset, fresh}; +use crate::commands::status::capture::{CacheWrites, Capture}; +use bit_install::dependencies::cache::{invalidation::deps_dir, put}; +use serde_json::json; +use std::{ + collections::HashMap, + fs, + time::{Duration, SystemTime}, +}; + +/// Builds a test [`Capture`] fixture with [`CacheWrites`] reset enabled and `loader_checked` status. +fn capture(loader_checked: bool) -> Capture { + Capture { + captured: Vec::new(), + comparisons: Vec::new(), + components: Vec::new(), + environment_facts: json!({"checkoutLoaderCacheChecked":loader_checked}), + remotely_removed: Vec::new(), + invalid: HashMap::new(), + dependency_states: HashMap::new(), + cache: CacheWrites { reset: true, ..CacheWrites::default() }, + } +} + +/// Verifies that [`fresh::commit`] retains loader cache entries when [`checkout_reset`] sees a checked loader capture. +#[test] +fn checkout_commit_keeps_the_entry_written_by_the_retained_loader_after_invalidation() { + let workspace = tempfile::tempdir().unwrap(); + let root = workspace.path(); + let cache = deps_dir(root); + let id = "scope/env/provider@0.0.1"; + let bitmap = root.join(".bitmap"); + fs::write(&bitmap, "{}").unwrap(); + fs::File::options() + .write(true) + .open(&bitmap) + .unwrap() + .set_modified(SystemTime::now() + Duration::from_hours(1)) + .unwrap(); + // The published loader checked invalidation and saved the env's real + // detection. A later bitmap timestamp cannot start a second reset. + put(&cache, id, "actual loader entry").unwrap(); + let mut engine = Engine::new().unwrap(); + let checked = capture(true); + fresh::commit(&mut engine, root, &[], checkout_reset(&checked), true).unwrap(); + // Checkout publishes the same capture once before planning and again + // immediately before applying the workspace write. + fresh::commit(&mut engine, root, &[], checkout_reset(&checked), true).unwrap(); + let saved = bit_object_import::read_dependency_cache(&cache, id).unwrap().unwrap(); + assert_eq!(saved["data"], "actual loader entry"); + fresh::commit(&mut engine, root, &[], checkout_reset(&capture(false)), true).unwrap(); + assert!(!cache.exists()); +} + +#[test] +fn checkout_reset_respects_checkout_loader_cache_checked_flag() { + assert!(!checkout_reset(&capture(true))); + assert!(checkout_reset(&capture(false))); +} diff --git a/native/cli/src/commands/status/capture/dependencies.rs b/native/cli/src/commands/status/capture/dependencies.rs new file mode 100644 index 000000000000..c2dae3388f74 --- /dev/null +++ b/native/cli/src/commands/status/capture/dependencies.rs @@ -0,0 +1,164 @@ +//! Dependency cache observations and fresh verification for a captured component. +//! +//! Extends [`Scan`] (`dependencies`) to resolve component dependencies via [`fresh`], [`policy`], and [`missing`] into [`Capture`]. +use super::{ + Capture, Scan, Value, decline, fresh, full_identity, io, json, missing, policy, spread, text, +}; + +impl Scan<'_> { + /// Resolves observed or freshly detected dependencies for `target` and records pending cache writes in `captured`. + pub(super) fn dependencies( + &mut self, + target: &Value, + directory: &str, + files: &[String], + policy: &policy::Policy, + captured: &mut Capture, + ) -> io::Result> { + let mut dependencies = self.observed(target, directory, files, policy, captured)?; + if dependencies.get("needsDetection") == Some(&Value::Bool(true)) { + let (detected, pending) = match self.detect(target, files, policy) { + Ok(value) => value, + Err(error) + if self.behavior.incomplete_reset + && error + .to_string() + .contains( + "dependency graph extraction requires canonical fallback", + ) => + { + missing::record_incomplete(target, directory, files, captured)?; + return Ok(None); + } + Err(error) => return Err(error), + }; + dependencies = detected; + captured.cache.pending.push(pending); + } else if self.behavior.verify { + let (detected, pending) = self.verified(target, files, policy, &dependencies)?; + dependencies = detected; + captured.cache.pending.push(pending); + } + Ok(Some(dependencies)) + } + + /// Returns the cached dependency state for `target` or `{ "needsDetection": true }` when invalidation applies. + fn observed( + &mut self, + target: &Value, + directory: &str, + files: &[String], + policy: &policy::Policy, + captured: &mut Capture, + ) -> io::Result { + let identity = text( + target + .get("identity") + .filter(|val| !val.is_null()) + .or_else(|| target.get("id")), + )? + .trim(); + let is_root = is_root_directory(directory); + let mut dependencies = if is_root + || captured.cache.no_fs_cache + || captured.cache.reset + || (!self.environments.is_checkout() + && !self.context["environmentFacts"][identity].is_null()) + { + json!({ "needsDetection": true }) + } else { + self.cached(target, directory, files, policy)? + }; + if self.environments.is_checkout() { + captured.environment_facts["checkoutCacheMisses"][identity] = + json!(dependencies["needsDetection"] == true); + if !self.context["environmentFacts"][identity].is_null() { + dependencies = json!({"needsDetection":true}); + } + } + Ok(dependencies) + } + + /// `verifyFreshDependencies`: the cached dependencies detected again; + /// any difference in metadata or issues declines. + #[inline] + fn verified( + &mut self, + target: &Value, + files: &[String], + policy: &policy::Policy, + cached: &Value, + ) -> io::Result<(Value, fresh::Pending)> { + let (detected, pending) = self.detect(target, files, policy)?; + let detected_meta = policy::current_metadata(target, policy, &detected)?; + let cached_meta = policy::current_metadata(target, policy, cached)?; + if detected_meta != cached_meta || detected.get("issues") != cached.get("issues") { + return Err(decline("cached dependencies differ from a fresh detection")); + } + Ok((detected, pending)) + } + + /// `dependencyPolicyCached`. + #[inline] + fn cached( + &mut self, + target: &Value, + directory: &str, + files: &[String], + policy: &policy::Policy, + ) -> io::Result { + let component_root = self.root.join(directory); + let mut request = json!({ + "kind": "dependencyPolicyCached", + "root": self.root, + "componentRoot": component_root, + "identity": full_identity(target)?, + "files": files.iter().map(|file| component_root.join(file)).collect::>(), + }); + spread(&mut request, &policy.input)?; + self.engine.request(&request) + } + + /// `readFreshDependencies`. + fn detect( + &mut self, + target: &Value, + files: &[String], + policy: &policy::Policy, + ) -> io::Result<(Value, fresh::Pending)> { + if self.facts.is_none() { + *self.facts = Some(fresh::Facts::new(self.home)?); + } + let facts = self.facts.as_ref().ok_or_else(|| decline("status facts"))?; + let detection = fresh::Detection { root: self.root, context: self.context, facts }; + let is_root_dir = target["directory"] + .as_str() + .or_else(|| target["rootDir"].as_str()) + .is_some_and(is_root_directory); + let custom = if is_root_dir { + Value::Null + } else { + self.environments.detectors(self.root, self.context, target, files)? + }; + match detection.read_with_detectors(self.engine, target, files, policy, custom.clone()) { + Err(error) + if error + .to_string() + .contains("dependency graph extraction requires canonical fallback") => + { + let custom = self.environments.parser(self.root, target, files, &custom)?; + detection.read_with_detectors(self.engine, target, files, policy, custom) + } + result => result, + } + } +} + +#[inline] +fn is_root_directory(dir: &str) -> bool { + let normalized = dir + .trim() + .trim_start_matches("./") + .trim_end_matches('/'); + normalized.is_empty() || normalized == "." +} diff --git a/native/cli/src/commands/status/capture/identities.rs b/native/cli/src/commands/status/capture/identities.rs new file mode 100644 index 000000000000..92ab317bff6b --- /dev/null +++ b/native/cli/src/commands/status/capture/identities.rs @@ -0,0 +1,102 @@ +//! Identity projection from detected dependency lifecycles. +//! +//! [`record`] populates [`Component`] comparison and [`full_identity`] entries inside [`Capture`]. + +use super::{Capture, Component, array, decline, full_identity, js, policy, text}; +use serde_json::{Value, json}; +use std::io; + +/// Records the comparison metadata, dependency state, and [`Component`] row of one captured `target` into `captured`, including [`peer_identities`] and [`dependency_identities`]. +pub(super) fn record( + target: &Value, + directory: &str, + files: &[String], + policy: &policy::Policy, + dependencies: &Value, + captured: &mut Capture, +) -> io::Result<()> { + let current = policy::current_metadata(target, policy, dependencies)?; + let version = target + .get("version") + .cloned() + .unwrap_or(Value::Null); + let mut entry = json!({ + "identity": target["identity"], + "version": version, + "directory": directory, + "files": files, + "current": current, + }); + if let Some(version) = target.get("trackingVersion") { + entry["trackingVersion"] = version.clone(); + } + captured.dependency_states.insert( + text(target.get("identity"))?.trim().to_string(), + dependencies["state"].clone(), + ); + if js::truthy(Some(&version)) { + captured.comparisons.push(entry.clone()); + } + let mut row = entry; + row["peerDependencies"] = json!(peer_identities(dependencies)?); + captured.captured.push(row); + captured.components.push(Component { + ignored: policy.ignored_issues()?, + identity: full_identity(target)?, + package_name: policy.package_name.clone(), + dependencies: dependency_identities(dependencies)?, + issues: policy.issues(dependencies)?, + }); + Ok(()) +} + +/// The identities of the peer lifecycle. +#[inline] +pub(super) fn peer_identities(dependencies: &Value) -> io::Result> { + let state = &dependencies["state"]; + let entries = array(state.get("entries"), "dependency entries")?; + let lifecycles = array(state.get("components"), "dependency lifecycles")?; + let peers = array(lifecycles.get(2), "peer dependencies")?; + peers + .iter() + .map(|index| entry_identity_at(entries, index)) + .collect() +} + +/// Every dependency identity, in lifecycle order. +#[inline] +pub(super) fn dependency_identities(dependencies: &Value) -> io::Result> { + let state = &dependencies["state"]; + let entries = array(state.get("entries"), "dependency entries")?; + let mut identities = Vec::with_capacity(entries.len()); + for indices in array(state.get("components"), "dependency lifecycles")? { + for index in array(Some(indices), "dependency indices")? { + identities.push(entry_identity_at(entries, index)?); + } + } + Ok(identities) +} + +#[inline] +fn entry_identity_at(entries: &[Value], index: &Value) -> io::Result { + let entry = index + .as_u64() + .and_then(|offset| entries.get(offset as usize)) + .ok_or_else(|| decline("status dependency entry"))?; + let identity = text(entry.get("identity"))?.trim(); + if identity.is_empty() { + return Err(decline("status dependency entry identity")); + } + Ok(identity.to_string()) +} + +/// The identity without its `@version`. +#[must_use] +#[inline] +pub(super) fn base_identity(identity: &str) -> &str { + let identity = identity.trim(); + identity + .rfind('@') + .filter(|&separator| separator > 0) + .map_or(identity, |separator| identity[..separator].trim()) +} diff --git a/native/cli/src/commands/status/capture/missing.rs b/native/cli/src/commands/status/capture/missing.rs new file mode 100644 index 000000000000..5f538a158796 --- /dev/null +++ b/native/cli/src/commands/status/capture/missing.rs @@ -0,0 +1,211 @@ +//! Missing current directories and intentionally incomplete reset captures. +//! +//! [`status_removed`] and [`status_pending`] record removed or absent [`Component`] entries in [`Capture`]. + +use super::{Capture, Component, decline, full_identity, text}; +use serde_json::{Value, json}; +use std::{io, path::Path}; + +/// Records a locally removed `target` into `captured.components` and returns `Ok(true)`. +#[inline] +pub(super) fn status_removed(target: &Value, captured: &mut Capture) -> io::Result { + if target["locallyRemoved"] != true { + return Ok(false); + } + captured.components.push(Component { + ignored: Vec::new(), + identity: full_identity(target)?, + package_name: String::new(), + dependencies: Vec::new(), + issues: Value::Null, + }); + Ok(true) +} + +/// Records a `ComponentsPendingImport` invalid entry when a scoped versioned `target` lacks a local model. +pub(super) fn status_pending( + context: &Value, + target: &Value, + captured: &mut Capture, +) -> io::Result { + if target["scoped"] != true + || target["version"] + .as_str() + .map(str::trim) + .is_none_or(str::is_empty) + { + return Ok(false); + } + let identity = text(target.get("identity"))?; + if super::policy::model_of(context, identity).is_some() { + return Ok(false); + } + record_status_invalid( + target, + json!({"isUserError":true,"name":"ComponentsPendingImport"}), + captured, + )?; + Ok(true) +} + +#[inline] +pub(super) fn capture( + root: &Path, + enabled: bool, + target: &Value, + directory: &str, + captured: &mut Capture, +) -> io::Result { + if !enabled || !missing(&root.join(directory))? { + return Ok(false); + } + record_incomplete(target, directory, &[], captured)?; + Ok(true) +} + +pub(super) fn status_invalid( + root: &Path, + target: &Value, + directory: &str, + captured: &mut Capture, +) -> io::Result { + let normalized = directory + .trim() + .trim_start_matches("./") + .trim_end_matches('/'); + if normalized.is_empty() || normalized == "." || !missing(&root.join(directory))? { + return Ok(false); + } + record_status_invalid(target, component_not_found(directory), captured)?; + Ok(true) +} + +pub(super) fn status_invalid_files( + root: &Path, + target: &Value, + directory: &str, + files: &[String], + captured: &mut Capture, +) -> io::Result { + if directory != "." && files.is_empty() { + let error = if has_any_file(&root.join(directory))? { + json!({"isUserError":true,"name":"IgnoredDirectory"}) + } else { + component_not_found(directory) + }; + record_status_invalid(target, error, captured)?; + return Ok(true); + } + let main = text(target.get("mainFile"))?.trim().trim_start_matches("./"); + if contains_main_file(files, main) || !missing(&root.join(directory).join(main))? { + return Ok(false); + } + record_status_invalid( + target, + json!({ + "isUserError":true,"name":"MainFileRemoved","mainFile":main, + "id":text(target.get("identity"))?, + }), + captured, + )?; + Ok(true) +} + +fn has_any_file(dir: &Path) -> io::Result { + let entries = match std::fs::read_dir(dir) { + Ok(entries) => entries, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(false), + Err(error) => return Err(error), + }; + for entry in entries { + let entry = entry?; + let file_type = entry.file_type()?; + if file_type.is_file() || file_type.is_symlink() { + return Ok(true); + } + if file_type.is_dir() && has_any_file(&entry.path())? { + return Ok(true); + } + } + Ok(false) +} + +#[inline] +fn component_not_found(directory: &str) -> Value { + json!({"isUserError":true,"name":"ComponentNotFoundInPath","code":127,"path":directory}) +} + +#[inline] +fn record_status_invalid(target: &Value, error: Value, captured: &mut Capture) -> io::Result<()> { + let identity = text(target.get("identity"))?.trim(); + captured.invalid.insert(identity.to_owned(), error); + captured.components.push(Component { + ignored: Vec::new(), + identity: full_identity(target)?, + package_name: String::new(), + dependencies: Vec::new(), + issues: Value::Null, + }); + Ok(()) +} + +#[inline] +fn contains_main_file(files: &[String], main: &str) -> bool { + files + .iter() + .any(|file| file.trim().trim_start_matches("./") == main) +} + +pub(super) fn main_file( + root: &Path, + enabled: bool, + target: &Value, + directory: &str, + files: &[String], + captured: &mut Capture, +) -> io::Result { + let main = target + .get("mainFile") + .and_then(Value::as_str) + .map(|main| main.trim().trim_start_matches("./")) + .filter(|main| !main.is_empty()); + if main.is_some_and(|main| contains_main_file(files, main)) { + return Ok(false); + } + if enabled + && let Some(main) = main + && missing(&root.join(directory).join(main))? + { + record_incomplete(target, directory, files, captured)?; + return Ok(true); + } + Err(decline("status main file not captured")) +} + +#[inline] +fn missing(path: &Path) -> io::Result { + match path.symlink_metadata() { + Ok(_) => Ok(false), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(true), + Err(error) => Err(error), + } +} + +#[inline] +pub(super) fn record_incomplete( + target: &Value, + directory: &str, + files: &[String], + captured: &mut Capture, +) -> io::Result<()> { + let main_file = text(target.get("mainFile"))?; + captured.captured.push(json!({ + "identity": target["identity"], + "version": target["version"], + "directory": directory, + "files": files, + "current": { "mainFile": main_file }, + "peerDependencies": [], + })); + Ok(()) +} diff --git a/native/cli/src/commands/status/capture/order.rs b/native/cli/src/commands/status/capture/order.rs new file mode 100644 index 000000000000..2f3f49625e6b --- /dev/null +++ b/native/cli/src/commands/status/capture/order.rs @@ -0,0 +1,48 @@ +//! Snapshot consumers follow the published loader's breadth-first source traversal. +//! +//! [`snapshot`] reorders component source file lists in [`Capture`] ([`array`], [`decline`]) to match breadth-first traversal. +use super::{Capture, array, decline, text}; +use serde_json::{Value, json}; +use std::{io, path::Path}; + +/// Reorders component source file lists in `capture` to match the breadth-first traversal order expected by snapshots. +#[inline] +pub(super) fn snapshot(root: &Path, capture: &mut Capture) -> io::Result<()> { + for target in capture.captured.iter_mut().chain(&mut capture.comparisons) { + let raw_dir = target["directory"] + .as_str() + .or_else(|| target["rootDir"].as_str()) + .map(str::trim) + .filter(|dir| !dir.is_empty()) + .ok_or_else(|| decline("snapshot directory"))?; + let trimmed = raw_dir.trim_start_matches("./").trim_end_matches('/'); + let directory = if trimmed.is_empty() { "." } else { trimmed }; + let files = array(target.get("files"), "snapshot source manifest")? + .iter() + .map(snapshot_file_path) + .collect::>>()?; + target["files"] = json!(crate::commands::diff::order::captured( + &root.join(directory), + &files, + target.get("sourceOverlays"), + )?); + } + Ok(()) +} + +/// Extracts and normalizes a non-empty relative file path from a string or object manifest entry. +#[inline] +fn snapshot_file_path(file: &Value) -> io::Result { + let value = if file.is_string() { + Some(file) + } else { + file.get("relativePath") + .filter(|val| !val.is_null()) + .or_else(|| file.get("path")) + }; + let path = text(value)?.trim().trim_start_matches("./"); + if path.is_empty() { + return Err(decline("snapshot file path")); + } + Ok(path.to_owned()) +} diff --git a/native/cli/src/commands/status/capture/snapshot.rs b/native/cli/src/commands/status/capture/snapshot.rs new file mode 100644 index 000000000000..714a89805c82 --- /dev/null +++ b/native/cli/src/commands/status/capture/snapshot.rs @@ -0,0 +1,260 @@ +//! Published pre-snapshot callback facts, without `JavaScript` VCS publication. +//! +//! [`snapshot_host`] and [`snapshot_hydrate`] prepare environment task discovery for [`Capture`] and [`Engine`]. + +use super::{super::js::text, Capture, Engine, Value, array, decline, io, json}; + +/// Prepares default-environment task discovery via [`crate::host::Host`] after native snapshot preflight succeeds, before [`snapshot_hydrate`]. +pub fn snapshot_host( + capture: &Capture, + root: &std::path::Path, + params: &Value, +) -> io::Result> { + if capture.cache.callbacks.host.is_some() + || params["soft"] == true + || capture.captured.is_empty() + { + return Ok(None); + } + let crate::discovery::Target::Entry(cli) = crate::discovery::find()? else { + return Err(decline("snapshot task discovery needs installed Harmony")); + }; + Ok(Some(crate::host::Host::new(cli, root.to_owned(), false))) +} + +/// Retain hosted default component metadata before planned tasks are requested. +#[inline] +pub fn snapshot_hydrate( + capture: &mut Capture, + context: &Value, + host: crate::host::Host, +) -> io::Result<()> { + let result = + super::super::environments::callbacks::load(&host, context, capture, false, true, false); + capture.cache.retain_callback_stdout(&host.take_stdout()); + capture.cache.retain_callback_host(Some(host)); + result.map(|_| ()) +} + +/// Per-component aspect data returned by hydrated snapshot callbacks. +#[must_use] +#[inline] +pub fn snapshot_data<'a>(capture: &'a Capture, identity: &str) -> Option<&'a Value> { + capture.cache.callbacks.snapshot_data.get(identity.trim()) +} + +/// Validate the native capture before invoking the selected components' pre-snap slots. +/// The returned physical-source changes require a native material refresh before publication. +pub fn snapshot_pre_snap( + engine: &mut Engine, + capture: &mut Capture, + selected: &[String], + automatic: &[String], + params: &Value, +) -> io::Result { + let valid: Value = engine.request( + &json!({"kind":"dependencyFreshValidate","readSet":capture.dependency_read_set()}), + )?; + if valid["fresh"] != true { + return Err(decline("snapshot callback inputs changed")); + } + let directory = tempfile::Builder::new().prefix("bit-native-presnap-").tempdir()?; + let Some(host) = capture.cache.callbacks.host.take() else { + return Ok(json!({"changedWorkspaceSources":[]})); + }; + let result = host.call("snapshotPreSnap", &json!({"selected":selected,"automatic":automatic,"params":params,"directory":directory.path()})); + capture.cache.retain_callback_stdout(&host.take_stdout()); + capture.cache.callbacks.host = Some(host); + let output = result?; + capture.cache.retain_callback_directory(directory); + capture.cache.retain_callback_stdout(output["stdout"].as_str().unwrap_or_default()); + replace_source_proofs(capture, &output["value"])?; + for row in array(output["value"].get("components"), "pre-snapshot components")? { + super::super::environments::callbacks::apply(capture, row)?; + } + Ok(output["value"].clone()) +} + +#[inline] +fn replace_source_proofs(capture: &mut Capture, output: &Value) -> io::Result<()> { + for change in array(output.get("changedWorkspaceSources"), "pre-snapshot source changes")? { + replace_single_proof(capture, &change["before"], &change["after"]); + } + Ok(()) +} + +#[inline] +fn replace_single_proof(capture: &mut Capture, before: &Value, after: &Value) { + for entry in capture.cache.pending.iter_mut().chain(&mut capture.cache.replacements) { + entry.replace_source_proof(before, after); + } + for proof in &mut capture.cache.environment_reads { + if proof == before { + *proof = after.clone(); + } + } +} + +/// Whether lifecycle callbacks require the published aspect host. +#[must_use] +#[inline] +pub fn snapshot_required(capture: &Capture, _context: &Value, params: &Value) -> bool { + capture.cache.callbacks.host.is_some() || snapshot_params_require_host(params) +} + +#[inline] +fn snapshot_params_require_host(params: &Value) -> bool { + let editor_enabled = params["editor"].as_bool() == Some(true) + || params["editor"] + .as_str() + .is_some_and(|editor| { + let trimmed = editor + .trim() + .trim_matches(['"', '\'']) + .trim(); + !trimmed.is_empty() + && !["false", "0", "no", "off"] + .iter() + .any(|falsy| trimmed.eq_ignore_ascii_case(falsy)) + }); + let build_enabled = params["build"].as_bool() == Some(true) + || params["build"] + .as_str() + .is_some_and(|build| { + let trimmed = build + .trim() + .trim_matches(['"', '\'']) + .trim(); + trimmed == "1" || trimmed.eq_ignore_ascii_case("true") + }); + editor_enabled || build_enabled +} + +/// Run the published message editor without loading or serializing a component graph. +pub fn snapshot_editor( + capture: &Capture, + selected: &[String], + automatic: &[String], + editor: &Value, + root: &std::path::Path, +) -> io::Result { + let target = crate::discovery::find()?; + let crate::discovery::Target::Entry(cli) = target else { + return Err(decline("snapshot editor needs installed Harmony")); + }; + let host = crate::host::Host::new(cli, root.to_owned(), false); + let host = capture.cache.callbacks.host.as_ref().unwrap_or(&host); + let result = host.call("snapshotEditor", &json!({"selected":selected,"automatic":automatic,"editor":editor,"scope":bit_object_import::scope_path(root)})); + capture.cache.retain_callback_stdout(&host.take_stdout()); + let output = result?; + capture.cache.retain_callback_stdout(output["stdout"].as_str().unwrap_or_default()); + Ok(output["value"].clone()) +} + +/// Build pipelines consume staged native objects and return only metadata and artifact paths. +pub fn snapshot_build( + capture: &mut Capture, + root: &std::path::Path, + staged_objects: &Value, + versions: &Value, + params: &Value, +) -> io::Result { + let directory = tempfile::Builder::new().prefix("bit-native-build-").tempdir()?; + if capture.cache.callbacks.host.is_none() { + let crate::discovery::Target::Entry(cli) = crate::discovery::find()? else { + return Err(decline("snapshot build needs installed Harmony")); + }; + capture.cache.callbacks.host = Some(crate::host::Host::new(cli, root.to_owned(), false)); + } + let host = + capture.cache.callbacks.host.as_ref().ok_or_else(|| decline("snapshot builder host"))?; + let result = host.call("snapshotBuild", &json!({"stagedObjects":staged_objects,"versions":versions,"params":params,"directory":directory.path()})); + capture.cache.retain_callback_stdout(&host.take_stdout()); + let output = result?; + let mut value = output["value"].clone(); + value["artifactRoot"] = json!(directory.path()); + capture.cache.retain_callback_directory(directory); + capture.cache.retain_callback_stdout(output["stdout"].as_str().unwrap_or_default()); + Ok(value) +} + +/// Registry existence facts use the released registry and credential adapters only. +pub fn snapshot_registry( + capture: &mut Capture, + root: &std::path::Path, + candidates: &Value, +) -> io::Result { + if capture.cache.callbacks.host.is_none() { + let crate::discovery::Target::Entry(cli) = crate::discovery::find()? else { + return Err(decline("registry facts need installed Harmony")); + }; + capture.cache.callbacks.host = Some(crate::host::Host::new(cli, root.to_owned(), false)); + } + let host = capture.cache.callbacks.host.as_ref().ok_or_else(|| decline("registry host"))?; + let result = host.call("registryPublished", &json!({"candidates":candidates})); + capture.cache.retain_callback_stdout(&host.take_stdout()); + let output = result?; + capture.cache.retain_callback_stdout(output["stdout"].as_str().unwrap_or_default()); + Ok(output["value"].clone()) +} + +/// Apply registered package metadata callbacks to bases generated and retained by Rust. +pub fn snapshot_package_manifests(capture: &Capture, plans: &Value) -> io::Result { + let host = capture.cache.callbacks.host + .as_ref() + .ok_or_else(|| decline("snapshot package callback host"))?; + let result = host.call("snapshotPackageManifests", &json!({"plans":plans})); + capture.cache.retain_callback_stdout(&host.take_stdout()); + let output = result?; + capture.cache.retain_callback_stdout(output["stdout"].as_str().unwrap_or_default()); + Ok(output["value"].clone()) +} + +/// Only blocking workspace issues participate in snapshot gates. +pub fn snapshot_workspace_issues( + capture: &Capture, + root: &std::path::Path, +) -> io::Result> { + let mut issues = Vec::new(); + if bit_install::config::MergeConfig::load(root)?.has_conflict("WORKSPACE") { + issues.push(format!( + "unable to parse the merge-conflict file {} as it has unresolved conflicts", + root.join("merge-conflict").display(), + )); + } + if let Some(host) = capture.cache.callbacks.host.as_ref() { + let result = host.call("snapshotWorkspaceIssues", &json!({})); + capture.cache.retain_callback_stdout(&host.take_stdout()); + let extra: Vec = serde_json::from_value(result?).map_err(io::Error::other)?; + for issue in extra { + let trimmed = issue.trim(); + if !trimmed.is_empty() + && !issues + .iter() + .any(|existing| existing == trimmed) + { + issues.push(trimmed.to_owned()); + } + } + } + Ok(issues) +} + +/// Adopt control proofs returned by the native retained publication owner. +pub fn snapshot_control_reads(capture: &mut Capture, reads: &Value) -> io::Result<()> { + for after in array(Some(reads), "published control reads")? { + let path = text(after.get("path"))?.trim(); + if path.is_empty() { + return Err(decline("published control read path")); + } + let before = capture + .dependency_read_set() + .into_iter() + .filter(|proof| proof["path"].as_str().map(str::trim) == Some(path)) + .collect::>(); + for before in before { + replace_single_proof(capture, &before, after); + } + } + Ok(()) +} diff --git a/native/cli/src/commands/status/capture/switch.rs b/native/cli/src/commands/status/capture/switch.rs new file mode 100644 index 000000000000..352c3799cf0a --- /dev/null +++ b/native/cli/src/commands/status/capture/switch.rs @@ -0,0 +1,222 @@ +//! Interleave switch source capture with the published ordered class loader. +//! +//! [`scan`] coordinates [`Engine`], [`Environments`], [`Inventory`], and [`Scan`] into [`Capture`]. + +use super::{ + Capture, Engine, Environments, Inventory, Path, Scan, ScanBehavior, Value, array, decline, + fresh, io, text, +}; +use std::collections::{HashMap, HashSet}; + +/// Active switch capture session interleaving source capture with ordered Harmony class loading. +struct Session<'a> { + engine: &'a mut Engine, + root: &'a Path, + home: &'a Path, + context: &'a Value, + inventory: &'a Inventory, + environments: &'a mut Environments, + behavior: ScanBehavior, + facts: &'a mut Option, +} + +/// Scans `targets` into `capture`, interleaving source capture with ordered class loading when checkout callbacks apply. +#[inline] +pub(super) fn scan(scan: Scan<'_>, targets: &[Value], capture: &mut Capture) -> io::Result { + let Scan { + engine, + root, + home, + context, + inventory, + facts, + environments, + behavior, + } = scan; + Session { engine, root, home, context, inventory, environments, behavior, facts }.run( + targets, capture, + ) +} + +impl Session<'_> { + fn run(&mut self, targets: &[Value], capture: &mut Capture) -> io::Result { + let Some(order) = self.order(targets)? else { + self.plain(targets, capture)?; + return Ok(false); + }; + super::validate_documentation(&mut *self.engine, self.context)?; + let Some(stage) = self.environments.switch_begin(self.context, capture, &order)? else { + self.plain(targets, capture)?; + return Ok(false); + }; + let result = self + .ordered(targets, capture, &order) + .and_then(|()| restore_target_order(targets, capture)); + if let Err(error) = result { + return Err(self.environments.switch_abort(capture, error)); + } + self.environments.switch_finish(capture, stage)?; + Ok(true) + } + + fn order(&self, targets: &[Value]) -> io::Result>> { + if !self.environments.is_checkout() || !self.behavior.callbacks { + return Ok(None); + } + let Some(order) = self.context["switchStatusLoadOrder"].as_array() else { + return Ok(None); + }; + let order = order + .iter() + .map(|row| row_identity(row).map(str::to_owned)) + .collect::>>()?; + let target_ids = targets + .iter() + .map(row_identity) + .collect::>>()?; + if order.len() != targets.len() + || target_ids.len() != targets.len() + || order + .iter() + .collect::>() + .len() + != order.len() + || order + .iter() + .any(|id| !target_ids.contains(id.as_str())) + { + return Err(decline("switch callback load order changed")); + } + Ok(Some(order)) + } + + #[inline] + fn plain(&mut self, targets: &[Value], capture: &mut Capture) -> io::Result<()> { + for target in targets { + self.target(target, capture)?; + } + Ok(()) + } + + fn ordered( + &mut self, + targets: &[Value], + capture: &mut Capture, + order: &[String], + ) -> io::Result<()> { + let by_id = targets + .iter() + .map(|row| Ok((row_identity(row)?, row))) + .collect::>>()?; + let mut scanned = HashSet::with_capacity(targets.len()); + for identity in order { + self.scan_once(identity, &by_id, &mut scanned, capture)?; + let response = self.environments.switch_one(self.context, capture, identity)?; + self.complete(identity, response, &by_id, &mut scanned, capture)?; + } + Ok(()) + } + + fn complete( + &mut self, + identity: &str, + mut response: Value, + by_id: &HashMap<&str, &Value>, + scanned: &mut HashSet, + capture: &mut Capture, + ) -> io::Result<()> { + loop { + if response.get("row").is_some() { + return self.environments.switch_accept(capture, &response, identity); + } + let needed = array(response.get("needsRows"), "switch callback needed targets")?; + if needed.is_empty() { + return Err(decline("switch callback continuation has no targets")); + } + for item in needed { + let needed_id = text(Some(item))?.trim(); + if needed_id.is_empty() { + return Err(decline("switch callback needed target identity")); + } + self.scan_once(needed_id, by_id, scanned, capture)?; + self.environments.switch_stage_row(self.context, capture, needed_id)?; + } + response = self.environments.switch_resume(capture)?; + } + } + + #[inline] + fn scan_once( + &mut self, + identity: &str, + by_id: &HashMap<&str, &Value>, + scanned: &mut HashSet, + capture: &mut Capture, + ) -> io::Result<()> { + let identity = identity.trim(); + if !scanned.contains(identity) { + scanned.insert(identity.to_owned()); + let target = + by_id.get(identity).ok_or_else(|| decline("switch callback target absent"))?; + self.target(target, capture)?; + } + Ok(()) + } + + #[inline] + fn target(&mut self, target: &Value, capture: &mut Capture) -> io::Result<()> { + Scan { + engine: &mut *self.engine, + root: self.root, + home: self.home, + context: self.context, + inventory: self.inventory, + facts: &mut *self.facts, + environments: &mut *self.environments, + behavior: self.behavior, + } + .target(target, capture) + } +} + +fn restore_target_order(targets: &[Value], capture: &mut Capture) -> io::Result<()> { + let order = targets + .iter() + .enumerate() + .map(|(index, target)| Ok((super::base_identity(row_identity(target)?), index))) + .collect::>>()?; + let known = |identity: &str| order.contains_key(super::base_identity(identity)); + if capture.captured + .iter() + .chain(&capture.comparisons) + .any(|row| !row_identity(row).is_ok_and(known)) + || capture.components + .iter() + .any(|row| !known(&row.identity)) + { + return Err(decline("switch captured target identity changed")); + } + let key_of = |row: &Value| order[super::base_identity(row_identity(row).unwrap_or_default())]; + capture.captured.sort_by_key(key_of); + capture.comparisons.sort_by_key(key_of); + capture.components.sort_by_key(|row| order[super::base_identity(&row.identity)]); + Ok(()) +} + +#[inline] +fn row_identity(row: &Value) -> io::Result<&str> { + let identity = if let Some(value) = row.as_str() { + value.trim() + } else { + text( + row.get("identity") + .filter(|val| !val.is_null()) + .or_else(|| row.get("id")), + )? + .trim() + }; + if identity.is_empty() { + return Err(decline("switch callback target identity")); + } + Ok(identity) +} diff --git a/native/cli/src/commands/status/capture/target.rs b/native/cli/src/commands/status/capture/target.rs new file mode 100644 index 000000000000..723cf7b28fff --- /dev/null +++ b/native/cli/src/commands/status/capture/target.rs @@ -0,0 +1,100 @@ +//! Capture one workspace target after bitmap, filesystem and policy checks. +//! +//! Implements [`Scan::target`] using [`bitmap`], [`missing`], [`policy`], and [`record`] on [`Capture`] and [`Value`]. + +use super::{Capture, Scan, bitmap, missing, policy, record, text}; +use serde_json::Value; +use std::io; + +impl Scan<'_> { + /// Captures one workspace `target` (policy, source files, and dependencies) into `captured`. + pub(super) fn target(mut self, target: &Value, captured: &mut Capture) -> io::Result<()> { + if self.behavior.missing_status_invalid + && bitmap::capture_special(self.context, target, captured)? + { + return Ok(()); + } + let policy = policy::status_policy(self.context, target)?; + let directory = text( + target + .get("directory") + .filter(|val| !val.is_null()) + .or_else(|| target.get("rootDir")), + )? + .trim(); + if directory.is_empty() { + return Err(super::decline("target directory is empty")); + } + if self.behavior.missing_status_invalid + && missing::status_invalid(self.root, target, directory, captured)? + { + return Ok(()); + } + if missing::capture( + self.root, + self.behavior.missing_checkout_reset, + target, + directory, + captured, + )? { + return Ok(()); + } + let files = self.files(directory)?; + self.loaded_target(target, directory, files, &policy, captured) + } + + /// Validates source file presence and records detected dependencies for `target` into `captured`. + fn loaded_target( + &mut self, + target: &Value, + directory: &str, + files: Vec, + policy: &policy::Policy, + captured: &mut Capture, + ) -> io::Result<()> { + let files = files + .into_iter() + .filter_map(normalize_target_file) + .collect::>(); + if self.behavior.missing_status_invalid + && missing::status_invalid_files(self.root, target, directory, &files, captured)? + { + return Ok(()); + } + if missing::main_file( + self.root, + self.behavior.missing_checkout_reset, + target, + directory, + &files, + captured, + )? { + return Ok(()); + } + let Some(mut dependencies) = + self.dependencies(target, directory, &files, policy, captured)? + else { + return Ok(()); + }; + super::super::environments::diagnostics::issues(self.context, target, &mut dependencies)?; + record(target, directory, &files, policy, &dependencies, captured)?; + bitmap::record_remote( + self.behavior.missing_status_invalid, + target, + &policy.extensions, + captured, + ) + } +} + +#[inline] +fn normalize_target_file(file: String) -> Option { + let trimmed = file.trim().trim_start_matches("./"); + if trimmed.is_empty() { + None + } else if trimmed.len() == file.len() { + Some(file) + } else { + Some(trimmed.to_owned()) + } +} diff --git a/native/cli/src/commands/status/collate.rs b/native/cli/src/commands/status/collate.rs new file mode 100644 index 000000000000..18cb7b36a127 --- /dev/null +++ b/native/cli/src/commands/status/collate.rs @@ -0,0 +1,85 @@ +//! `a.localeCompare(b)` for component ids: ICU's root collation, restricted +//! to the characters ids and versions use. Locales whose collation tailors +//! these characters (Danish puts uppercase first, Estonian moves `z`, Turkish +//! splits `i`, Czech contracts `ch`) and any other character decline. +//! +//! [`root_locale`] and [`ROOT_LANGUAGES`] produce [`Ordering`] results matching V8 `String.prototype.localeCompare`. + +#[cfg(test)] +mod collate_tests; + +use std::{cmp::Ordering, env}; + +/// Languages whose collation equals the root collation for `[A-Za-z0-9._/@+-]`. +const ROOT_LANGUAGES: &[&str] = &[ + "", "c", "posix", "en", "de", "fr", "it", "pt", "nl", "es", "ja", "zh", "ko", "ru", "uk", "pl", +]; + +/// Returns `true` when this process's default locale (`LC_ALL`, `LC_MESSAGES`, `LANG`) collates IDs like the ICU root locale ([`compare`], [`supported`]). +#[must_use] +pub fn root_locale() -> bool { + let locale = ["LC_ALL", "LC_MESSAGES", "LANG"] + .iter() + .find_map(|name| { + let raw = env::var_os(name)?; + let text = raw.to_string_lossy(); + let trimmed = text + .trim() + .trim_matches(['"', '\'']) + .trim(); + (!trimmed.is_empty()).then(|| trimmed.to_owned()) + }) + .unwrap_or_default(); + let language = locale + .trim_start_matches(['_', '.', '@', '-', ':']) + .split(['_', '.', '@', '-', ':']) + .next() + .unwrap_or_default() + .trim(); + ROOT_LANGUAGES + .iter() + .any(|root_lang| language.eq_ignore_ascii_case(root_lang)) +} + +/// Primary weight in root collation order: punctuation, then digits, then +/// letters regardless of case. `None` for characters this module does not handle. +#[inline] +fn primary(character: char) -> Option { + Some(match character { + '_' => 0, + '-' => 1, + '.' => 2, + '@' => 3, + '/' => 4, + '+' => 5, + '0'..='9' => 10 + (character as u8 - b'0'), + 'a'..='z' => 20 + (character as u8 - b'a'), + 'A'..='Z' => 20 + (character as u8 - b'A'), + _ => return None, + }) +} + +/// Whether every character of `text` has a known weight. +#[must_use] +#[inline] +pub fn supported(text: &str) -> bool { + text.chars() + .all(|character| primary(character).is_some()) +} + +/// The root collation order of two supported strings: primary weights +/// first, then case, lowercase before uppercase. +#[must_use] +#[inline] +pub fn compare(left: &str, right: &str) -> Ordering { + let left = left.trim(); + let right = right.trim(); + left.chars() + .filter_map(primary) + .cmp(right.chars().filter_map(primary)) + .then_with(|| { + left.chars() + .map(|character| character.is_ascii_uppercase()) + .cmp(right.chars().map(|character| character.is_ascii_uppercase())) + }) +} diff --git a/native/cli/src/commands/status/collate/collate_tests.rs b/native/cli/src/commands/status/collate/collate_tests.rs new file mode 100644 index 000000000000..5bbc2608b9fe --- /dev/null +++ b/native/cli/src/commands/status/collate/collate_tests.rs @@ -0,0 +1,64 @@ +//! Unit tests for root-locale component ID collation ([`compare`]) matching `JavaScript` `String.prototype.localeCompare`. +//! +//! Verifies punctuation, digit, and case ordering against Node 22 root-locale collation. + +use super::compare; + +/// Verifies [`compare`] matches `[...ids].sort((a, b) => a.localeCompare(b))` in Node 22 with `LANG=en_US.UTF-8`. +#[test] +fn sorts_like_node_root_collation() { + let mut ids = vec![ + "my.scope/alpha", + "my.scope/Alpha", + "my.scope/beta", + "my.scope/a-b", + "my.scope/a_b", + "my.scope/a.b", + "my.scope/a/b", + "my.scope/ab", + "my.scope/a1", + "my.scope/a10", + "my.scope/a2", + "my-scope/alpha", + "my_scope/alpha", + "My.scope/alpha", + "org.x/aa", + "org.x/z", + "org.x/Z", + "a@1.0.0", + "a/b@1.0.0", + "A", + ]; + ids.sort_by(|left, right| compare(left, right)); + assert_eq!( + ids, + [ + "A", + "a@1.0.0", + "a/b@1.0.0", + "my_scope/alpha", + "my-scope/alpha", + "my.scope/a_b", + "my.scope/a-b", + "my.scope/a.b", + "my.scope/a/b", + "my.scope/a1", + "my.scope/a10", + "my.scope/a2", + "my.scope/ab", + "my.scope/alpha", + "my.scope/Alpha", + "My.scope/alpha", + "my.scope/beta", + "org.x/aa", + "org.x/z", + "org.x/Z", + ], + ); +} + +/// Verifies that identical component ID strings compare as [`std::cmp::Ordering::Equal`] in [`compare`]. +#[test] +fn equal_strings_compare_as_equal_in_root_collation() { + assert_eq!(compare("my.scope/alpha", "my.scope/alpha"), std::cmp::Ordering::Equal); +} diff --git a/native/cli/src/commands/status/environments.rs b/native/cli/src/commands/status/environments.rs new file mode 100644 index 000000000000..45a59af63ba6 --- /dev/null +++ b/native/cli/src/commands/status/environments.rs @@ -0,0 +1,379 @@ +//! Environment callbacks supply policy and detector facts; Rust owns workspace sources. +//! +//! Re-exports [`hydration_rows`], [`read_context`], [`read_inherited_context`], [`read_snapshot_context`], [`dev_files`], [`quick`], [`callbacks`], and [`diagnostics`]. + +pub(super) mod callbacks; +pub(super) mod diagnostics; + +pub(in crate::commands) use callbacks::component_rows as hydration_rows; +pub(in crate::commands) use context::{ + read_context, read_inherited_context, read_snapshot_context, +}; +pub(super) use modes::dev_files; +pub(in crate::commands) use quick::run as quick; + +mod builtin; +mod context; +mod dependencies; +mod facts; +mod modes; +mod quick; +mod snapshot; +mod switch; +mod tracking; + +use super::js::{array, decline, text}; +use crate::{ + discovery::{self, Target}, + host::Host, +}; +use bit_install::{ + model::{ComponentId, WorkspaceComponent}, + policy::{EnvJsoncReader, scope::Scope}, +}; +use serde_json::{Value, json}; +use std::{io, path::Path}; + +/// Retained environment callback session managing optional [`Host`] invocations and built-in service facts. +pub(in crate::commands) struct Environments { + host: Option, + issue_facts: bool, + snapshot: bool, + snapshot_build: bool, + snapshot_loads: Vec, + checkout: bool, + import: Option, + builtin_services: builtin::Services, +} + +impl Environments { + #[inline] + pub(super) fn new() -> Self { + Self { + host: None, + issue_facts: true, + snapshot: false, + snapshot_build: false, + snapshot_loads: Vec::new(), + checkout: false, + import: None, + builtin_services: builtin::Services::Disabled, + } + } + + fn component_facts( + &mut self, + root: &Path, + reader: &EnvJsoncReader<'_>, + component: &WorkspaceComponent, + core: &[String], + aspects: &[String], + ) -> io::Result { + if self.builtin_services != builtin::Services::Disabled + && builtin::supported(component, core, aspects) + { + return Ok(builtin::facts(component, self.issue_facts)); + } + let needs_issues = self.issue_facts; + facts::read(self.host(root)?, reader, component, core, aspects, needs_issues) + } + + fn host(&mut self, root: &Path) -> io::Result<&Host> { + if self.host.is_none() { + let Target::Entry(cli) = discovery::find()? else { + return Err(decline("environment needs installed Harmony")); + }; + self.host = Some(Host::new( + cli, + root.to_owned(), + crate::commands::color::Chalk::detect(&std::env::args_os().collect::>()) + .enabled(), + )); + } + self.host.as_ref().ok_or_else(|| decline("environment host")) + } + + pub(in crate::commands) fn merge_load_apps( + &mut self, + root: &Path, + component_ids: &[String], + ) -> io::Result { + let component_ids = component_ids + .iter() + .map(String::as_str) + .map(str::trim) + .filter(|id| !id.is_empty()) + .collect::>(); + if component_ids.is_empty() { + return Ok(String::new()); + } + let host = self.host(root)?; + host.call("mergeLoadApps", &json!({"componentIds":component_ids}))?; + Ok(host.take_stdout()) + } + + pub(in crate::commands) fn failure(&self, error: io::Error) -> io::Error { + if !self.checkout + || error + .get_ref() + .is_some_and( + ::is::< + crate::host::callback_error::CallbackError, + >, + ) + { + return error; + } + match &self.host { + Some(host) => crate::host::callback_error::CallbackError::wrap( + error, + host.take_stdout(), + String::new(), + ), + None => error, + } + } + + pub(super) fn callbacks( + &mut self, + context: &Value, + capture: &mut super::capture::Capture, + status: bool, + ) -> io::Result<()> { + if let Some(host) = &self.host { + if let Err(error) = + callbacks::load(host, context, capture, status, self.snapshot, self.checkout) + { + if !self.checkout { + return Err(error); + } + return Err(crate::host::callback_error::CallbackError::wrap( + error, + super::capture::callback_startup_stdout(capture), + format!( + "{}{}", + super::capture::callback_load_stdout(capture), + host.take_stdout(), + ), + )); + } + capture.cache.retain_callback_host(self.host.take()); + } + Ok(()) + } + + #[inline] + pub(super) fn require_native_write(root: &Path, context: &Value) -> io::Result<()> { + Self::require_native_write_for( + root, + context, + array(context.get("targets"), "native write environments")?, + ) + } + + pub(in crate::commands) fn require_native_write_for( + root: &Path, + context: &Value, + targets: &[Value], + ) -> io::Result<()> { + let core = core_ids()?; + let aspects = root_aspects(context, &core); + for target in targets { + if needs_facts(&component(root, context, target)?, &core, &aspects) { + return Err(decline( + "write command environment callbacks require canonical lifecycle", + )); + } + } + Ok(()) + } + + pub(super) fn context_for( + &mut self, + root: &Path, + context: &Value, + targets: &[Value], + ) -> io::Result { + let mut enriched = context.clone(); + let components = array(context.get("targets"), "environment targets")? + .iter() + .map(|target| component(root, context, target)) + .collect::>>()?; + let core = core_ids()?; + let aspects = root_aspects(context, &core); + let selected_components = components + .iter() + .filter(|component| selected(component, targets)) + .cloned() + .collect::>(); + self.prime(root, &selected_components, &core, &aspects)?; + let reader = EnvJsoncReader { + components: &components, + scope: Scope::find(root).map(Scope::recording), + }; + let mut proofs = context["environmentReadSet"] + .as_array() + .cloned() + .unwrap_or_default(); + proofs.reserve(components.len() * 2); + proofs.extend(local_proofs(&components)?); + for component in &selected_components { + if needs_facts(component, &core, &aspects) { + let facts = self.component_facts(root, &reader, component, &core, &aspects)?; + store_component_facts(&mut enriched, component, facts); + } + } + if let Some(scope) = &reader.scope { + proofs.extend(scope.read_set()); + } + enriched["environmentFacts"]["snapshotEnvironmentLoads"] = json!(self.snapshot_loads); + enriched["environmentReadSet"] = json!(proofs); + enriched["importCacheInitialized"] = json!(self.import_cache_initialized()); + Ok(enriched) + } +} + +#[inline] +fn store_component_facts(enriched: &mut Value, component: &WorkspaceComponent, facts: Value) { + if facts["loadFailures"] + .as_array() + .is_some_and(|arr| !arr.is_empty()) + { + enriched["environmentFacts"]["loadFailures"] = facts["loadFailures"].clone(); + } + enriched["environmentFacts"][component.id.to_string_without_version()] = facts; +} + +fn component(root: &Path, context: &Value, target: &Value) -> io::Result { + let target = super::policy::environment_target(context, target)?; + let config = target["config"]["extensions"] + .as_object() + .cloned() + .unwrap_or_default(); + let env_id = crate::commands::update_cmd::aspect_entry( + &target["config"]["extensions"], + "teambit.envs/envs", + ) + .and_then(|(_, cfg)| cfg["env"].as_str()) + .map_or_else(|| stored_env(context, &target), str::to_owned); + let identity = text(target.get("identity"))?; + let (scope, name) = + identity.split_once('/').ok_or_else(|| decline("environment component identity"))?; + let directory = text(target.get("directory"))?; + let dir = root.join(directory); + Ok(WorkspaceComponent { + id: ComponentId { + scope: Some(scope.to_owned()), + default_scope: None, + name: name.to_owned(), + version: target["version"].as_str().map(str::to_owned), + }, + root_dir: directory.to_owned(), + files: Vec::new(), + main_file: target["mainFile"] + .as_str() + .unwrap_or_default() + .to_owned(), + package_name: String::new(), + env_id, + is_env: dir.join("env.jsonc").exists() || dir.join("env.json").exists(), + has_package_json: false, + dir, + config, + }) +} + +#[inline] +fn stored_env(context: &Value, target: &Value) -> String { + stored_extensions(context, target) + .and_then(|extensions| { + extensions + .iter() + .find(|extension| { + crate::commands::update_cmd::matches_aspect(extension, "teambit.envs/envs") + }) + }) + .and_then(|env| env["data"]["id"].as_str()) + .map(str::trim) + .filter(|id| !id.is_empty()) + .unwrap_or("teambit.harmony/node") + .to_owned() +} + +fn stored_extensions<'a>(context: &'a Value, target: &Value) -> Option<&'a Vec> { + let identity = target["identity"].as_str().map_or("", str::trim); + let model = super::policy::model_of(context, identity); + let version = target["version"].as_str().map_or("", str::trim); + let hash = model + .and_then(|model| { + if matches!(version, "" | "latest" | "head") { + model["head"].as_str() + } else { + model["versions"][version] + .as_str() + .or_else(|| model["orphanedVersions"][version].as_str()) + } + }) + .unwrap_or(version); + context["versions"] + .as_array()? + .iter() + .find(|row| row[0] == hash) + .and_then(|row| row[1]["extensions"].as_array()) +} + +#[inline] +fn local_proofs(components: &[WorkspaceComponent]) -> io::Result> { + components + .iter() + .flat_map(|component| [component.dir.join("env.jsonc"), component.dir.join("env.json")]) + .map(|path| bit_install::policy::scope::read_proof(&path)) + .collect() +} + +fn root_aspects(context: &Value, core: &[String]) -> Vec { + context["workspace"] + .as_object() + .into_iter() + .flat_map(|workspace| workspace.iter()) + .filter(|(id, val)| { + let bare = bit_install::policy::split_version(id).0; + id.contains('/') + && **val != Value::Bool(false) + && val.as_str() != Some("-") + && !(core.contains(id) || core.iter().any(|item| item == bare)) + && !bit_install::policy::is_core_env(id) + }) + .map(|(id, _)| id.clone()) + .collect() +} + +#[inline] +fn needs_facts(component: &WorkspaceComponent, core: &[String], aspects: &[String]) -> bool { + component.is_env + || bit_install::policy::split_version(&component.env_id).0 != "teambit.harmony/node" + || !aspects.is_empty() + || component.config + .keys() + .any(|id| { + let bare = bit_install::policy::split_version(id).0; + (bit_install::policy::is_core_env(id) && bare != "teambit.harmony/node") + || !(core.contains(id) || core.iter().any(|item| item == bare)) + }) +} + +#[inline] +fn selected(component: &WorkspaceComponent, targets: &[Value]) -> bool { + let bare = component.id.to_string_without_version(); + targets + .iter() + .any(|target| target["identity"].as_str().map(str::trim) == Some(bare.as_str())) +} + +#[inline] +fn core_ids() -> io::Result> { + serde_json::from_str(include_str!( + "../../../../../scopes/harmony/testing/load-aspect/core-aspects-ids.json" + )) + .map_err(io::Error::other) +} diff --git a/native/cli/src/commands/status/environments/builtin.rs b/native/cli/src/commands/status/environments/builtin.rs new file mode 100644 index 000000000000..0304f1411966 --- /dev/null +++ b/native/cli/src/commands/status/environments/builtin.rs @@ -0,0 +1,133 @@ +//! Fixed built-in React facts for qualified component service loading. +//! +//! Defines [`Services`] and [`supported`] to resolve built-in environment facts for [`WorkspaceComponent`] and [`Environments`]. +use super::{ + Environments, WorkspaceComponent, array, component, core_ids, needs_facts, root_aspects, +}; +use serde_json::{Value, json}; +use std::{io, path::Path}; + +/// Built-in component service level supported without spawning a Harmony host. +#[derive(Clone, Copy, PartialEq, Eq)] +pub(super) enum Services { + Disabled, + Tools, + Compiler, +} + +/// Returns `true` when `component` uses only the built-in `teambit.react/react` environment and core aspects. +#[inline] +pub(super) fn supported( + component: &WorkspaceComponent, + core: &[String], + aspects: &[String], +) -> bool { + !component.is_env + && bit_install::policy::split_version(component.env_id.trim()).0 == "teambit.react/react" + && aspects.is_empty() + && component.config + .keys() + .all(|id| { + let trimmed = id.trim(); + let bare = bit_install::policy::split_version(trimmed).0; + (!bit_install::policy::is_core_env(trimmed) || bare == "teambit.react/react") + && core + .iter() + .any(|item| item == trimmed || item == bare) + }) +} + +/// Synthesizes fixed `teambit.react/react` environment policy and compiler facts for `component`. +pub(super) fn facts(component: &WorkspaceComponent, issues: bool) -> Value { + let explicit = component.config + .get("teambit.envs/envs") + .or_else(|| { + component.config + .iter() + .find_map(|(key, val)| { + (bit_install::policy::split_version(key).0 == "teambit.envs/envs").then_some( + val, + ) + }) + }) + .and_then(|config| config["env"].as_str()) + .is_some_and(|env| !env.trim().is_empty()); + let multiple = if explicit { + Vec::new() + } else { + component.config + .iter() + .filter(|(id, val)| { + val.as_str() != Some("-") + && **val != Value::Bool(false) + && bit_install::policy::is_core_env(id) + }) + .map(|(id, _)| id.clone()) + .collect() + }; + json!({"envId":"teambit.react/react","envPolicy":{ + "dependencies":{"react":"-","react-dom":"-","core-js":"^3.0.0"}, + "devDependencies":{"react":"-","react-dom":"-","@types/mocha":"-","@types/node":"12.20.4", + "@types/react":"^19.0.0","@types/react-dom":"^19.0.0","@types/jest":"^26.0.0", + "@babel/runtime":"7.20.0","@types/testing-library__jest-dom":"5.9.5"}, + "peerDependencies":{"react":"^17.0.0 || ^18.0.0 || ^19.0.0","react-dom":"^17.0.0 || ^18.0.0 || ^19.0.0"}}, + "compiler":if issues {json!({"hasCompiler":true,"distDir":"dist"})} else {Value::Null}, + "nonLoaded":false,"devPatterns":{"devPatterns":null,"tests":null,"docs":null,"compositions":null}, + "multiple":multiple,"manifestPatterns":null,"registeredEnvPolicy":[],"aspectIds":[],"envOwnPolicy":{},"loadFailures":[]}) +} + +impl Environments { + #[inline] + pub(in crate::commands) fn for_builtin_services() -> Self { + Self { builtin_services: Services::Tools, ..Self::new() } + } + + #[inline] + pub(in crate::commands) fn for_compiler_services() -> Self { + Self { builtin_services: Services::Compiler, ..Self::new() } + } + + pub(in crate::commands) fn require_service( + root: &Path, + context: &Value, + targets: &[Value], + ) -> io::Result<()> { + validate_service_root(root)?; + let core = core_ids()?; + let aspects = root_aspects(context, &core); + for target in targets { + let component = component(root, context, target)?; + if needs_facts(&component, &core, &aspects) && !supported(&component, &core, &aspects) { + return Err(io::Error::other( + "component service requires custom environment lifecycle", + )); + } + } + Ok(()) + } + + #[inline] + pub(in crate::commands::status) fn require_write( + &self, + root: &Path, + context: &Value, + ) -> io::Result<()> { + if self.builtin_services == Services::Disabled { + Self::require_native_write(root, context) + } else { + Self::require_service(root, context, array(context.get("targets"), "service targets")?) + } + } +} + +#[inline] +fn validate_service_root(root: &Path) -> io::Result<()> { + if root.as_os_str().is_empty() + || root + .to_str() + .is_some_and(|path| path.trim().is_empty()) + { + return Err(io::Error::other("component service workspace root is empty")); + } + Ok(()) +} diff --git a/native/cli/src/commands/status/environments/callbacks.rs b/native/cli/src/commands/status/environments/callbacks.rs new file mode 100644 index 000000000000..48ade95abc36 --- /dev/null +++ b/native/cli/src/commands/status/environments/callbacks.rs @@ -0,0 +1,339 @@ +//! Harmony class hydration and callback results without transporting source bodies. +//! +//! [`load`] invokes [`Host`] environment callbacks and populates [`Capture`] metadata. + +use crate::{ + commands::status::{ + capture::Capture, + js::{array, decline, text}, + }, + host::Host, +}; +use serde_json::{Value, json}; +use std::io; + +/// Invokes `statusLoadCallbacks` on `host` when environment load or issue callbacks apply, updating `capture`. +pub(in crate::commands::status) fn load( + host: &Host, + context: &Value, + capture: &mut Capture, + status: bool, + snapshot: bool, + checkout: bool, +) -> io::Result { + let info = callback_info(host, context, capture)?; + if !snapshot && info["onLoad"] != true && info["issues"] != true && info["classLoad"] != true { + return Ok(false); + } + let components = component_rows(capture, context)?; + let directory = tempfile::Builder::new().prefix("bit-native-callback-").tempdir()?; + let rows = host.call( + "statusLoadCallbacks", + &json!({"components":components,"status":status,"snapshot":snapshot,"checkout":checkout,"configured":info["onLoad"] == true || info["classLoad"] == true,"loadOrder":context["switchStatusLoadOrder"],"directory":directory.path()}), + )?; + if checkout { + checked_cache(host, capture)?; + } + capture.cache.retain_callback_directory(directory); + capture.cache.retain_callback_stdout(rows["stdout"].as_str().unwrap_or_default()); + let values = array(rows.get("value"), "callback components")?; + if snapshot { + capture.environment_facts["snapshotLoadOutput"] = values + .first() + .map_or(Value::Null, |first| first["snapshotLoadOutput"].clone()); + } + for row in values { + apply(capture, row)?; + } + Ok(info["issues"] == true) +} + +/// Queries `statusCallbackInfo` on `host` for non-core workspace aspects and buffered startup stdout. +fn callback_info(host: &Host, context: &Value, capture: &Capture) -> io::Result { + capture.cache.retain_callback_startup(&host.take_stdout()); + let core = core_ids()?; + let aspects = context["workspace"] + .as_object() + .into_iter() + .flat_map(|config| config.iter()) + .filter(|(id, val)| { + let bare = bit_install::policy::split_version(id).0; + id.contains('/') + && **val != Value::Bool(false) + && val.as_str() != Some("-") + && !(core.contains(id) || core.iter().any(|item| item == bare)) + }) + .map(|(id, _)| id) + .collect::>(); + let info = host.call("statusCallbackInfo", &json!({"coreIds":core,"aspectIds":aspects}))?; + capture.cache.retain_callback_startup(&host.take_stdout()); + Ok(info) +} + +#[inline] +fn checked_cache(host: &Host, capture: &mut Capture) -> io::Result<()> { + let checked = host.call("statusDependencyCacheChecked", &json!({}))?; + if !checked.is_boolean() { + return Err(decline("checkout dependency loader cache receipt")); + } + capture.environment_facts["checkoutLoaderCacheChecked"] = checked; + Ok(()) +} + +pub(in crate::commands::status) struct SwitchStage { + directory: tempfile::TempDir, + pub(super) issues: bool, +} + +pub(super) fn switch_begin( + host: &Host, + context: &Value, + capture: &Capture, + order: &[String], +) -> io::Result> { + let info = callback_info(host, context, capture)?; + if info["onLoad"] != true && info["classLoad"] != true { + return Ok(None); + } + let directory = tempfile::Builder::new().prefix("bit-native-callback-").tempdir()?; + let result = + host.call("switchStatusBegin", &json!({"loadOrder":order,"directory":directory.path()}))?; + capture.cache.retain_callback_startup(&host.take_stdout()); + if result != true { + return Err(decline("switch callback stage begin receipt")); + } + Ok(Some(SwitchStage { directory, issues: info["issues"] == true })) +} + +#[inline] +fn switch_component(context: &Value, capture: &Capture, identity: &str) -> io::Result { + let identity = identity.trim(); + component_rows(capture, context)? + .into_iter() + .find(|row| row["identity"].as_str().map(str::trim) == Some(identity)) + .ok_or_else(|| decline("switch callback target missing")) +} + +#[inline] +pub(super) fn switch_one( + host: &Host, + context: &Value, + capture: &Capture, + identity: &str, +) -> io::Result { + let component = switch_component(context, capture, identity)?; + let result = host.call("switchStatusLoadOne", &json!({"component":component}))?; + capture.cache.retain_callback_stdout(&host.take_stdout()); + Ok(result) +} + +#[inline] +pub(super) fn switch_stage_row( + host: &Host, + context: &Value, + capture: &Capture, + identity: &str, +) -> io::Result<()> { + let component = switch_component(context, capture, identity)?; + let result = host.call("switchStatusStageRow", &json!({"component":component}))?; + capture.cache.retain_callback_stdout(&host.take_stdout()); + if result != true { + return Err(decline("switch callback staged row receipt")); + } + Ok(()) +} + +#[inline] +pub(super) fn switch_resume(host: &Host, capture: &Capture) -> io::Result { + let result = host.call("switchStatusResume", &json!({}))?; + capture.cache.retain_callback_stdout(&host.take_stdout()); + Ok(result) +} + +#[inline] +pub(super) fn switch_accept( + capture: &mut Capture, + result: &Value, + identity: &str, +) -> io::Result<()> { + let identity = identity.trim(); + let row = result.get("row").ok_or_else(|| decline("switch callback row receipt"))?; + if row["identity"].as_str().map(str::trim) != Some(identity) { + return Err(decline("switch callback target changed")); + } + apply(capture, row) +} + +#[inline] +pub(super) fn switch_finish( + host: &Host, + capture: &mut Capture, + stage: SwitchStage, +) -> io::Result { + let result = host.call("switchStatusFinish", &json!({}))?; + capture.cache.retain_callback_stdout(&host.take_stdout()); + if result != true { + return Err(decline("switch callback stage finish receipt")); + } + checked_cache(host, capture)?; + capture.cache.retain_callback_directory(stage.directory); + Ok(stage.issues) +} + +pub(in crate::commands) fn component_rows( + capture: &Capture, + context: &Value, +) -> io::Result> { + capture.captured.iter().map(|row| { + let identity = text(row.get("identity"))?; + let issues = capture.components.iter().find(|component|component.identity.split('@').next() == Some(identity)).map_or_else(||json!([]), |component|component.issues.clone()); + let mut result=json!({"identity":identity,"version":row["version"],"directory":row["directory"],"files":row["files"],"current":row["current"],"issues":issues,"scoped":array(context.get("targets"),"callback targets")?.iter().find(|target|target["identity"]==identity).map(|target|target["scoped"].clone()),"scope":scope_metadata(context,identity)?,"dependencyCacheMiss":capture.environment_facts["checkoutCacheMisses"][identity]}); + if let Some(version)=row.get("trackingVersion"){result["trackingVersion"]=version.clone();} + let state = capture.dependency_states.get(identity).ok_or_else(|| decline("callback dependency state"))?; + result["peerDependencies"] = super::super::policy::peer_metadata(state)?; + Ok(result) + }).collect() +} + +fn scope_metadata(context: &Value, identity: &str) -> io::Result { + array(context.get("models"), "callback models")?; + let Some(model) = crate::commands::status::policy::model_of(context, identity) else { + return Ok(Value::Null); + }; + let head = context["laneHeads"] + .get(identity) + .unwrap_or_else(|| &model["head"]); + let version = array(context.get("versions"), "callback versions")? + .iter() + .find(|row| &row[0] == head); + let current = current_version(context, model, identity)?; + Ok(json!({"model":model,"head":version,"currentVersion":current})) +} + +fn current_version(context: &Value, model: &Value, identity: &str) -> io::Result> { + let selected = context["targets"] + .as_array() + .and_then(|rows| { + rows.iter() + .find(|row| row["identity"] == identity) + }) + .and_then(|row| row["version"].as_str()) + .map(str::trim); + let reference = selected.and_then(|selected| { + if selected.is_empty() || matches!(selected, "latest" | "head") { + model["head"].as_str() + } else { + model["versions"] + .get(selected) + .or_else(|| model["orphanedVersions"].get(selected)) + .and_then(Value::as_str) + .or(Some(selected)) + } + }); + let current = array(context.get("versions"), "callback versions")? + .iter() + .find(|row| row[0].as_str() == reference) + .map(|row| row[1].clone()); + Ok(current) +} + +pub(in crate::commands::status) fn apply(capture: &mut Capture, row: &Value) -> io::Result<()> { + let identity = text(row.get("identity"))?.trim(); + for target in capture.captured.iter_mut().chain(&mut capture.comparisons) { + if target["identity"].as_str().map(str::trim) == Some(identity) { + target["current"] = row["current"].clone(); + target["files"] = row["files"].clone(); + target["sourceOverlayRoot"] = row["sourceOverlayRoot"].clone(); + target["sourceOverlays"] = row["sourceOverlays"].clone(); + } + } + if let Some(data) = row.get("snapshotData") { + capture.cache.retain_snapshot_data(identity, data.clone()); + } + let component = capture.components + .iter_mut() + .find(|component| { + component.identity + .split('@') + .next() + .map(str::trim) + == Some(identity) + }) + .ok_or_else(|| decline("callback component identity"))?; + dependencies(component, row)?; + let issues = array(row.get("issues"), "callback issues")?; + component.issues = json!( + issues + .iter() + .map(|issue| json!({"callbackObject":issue})) + .collect::>() + ); + capture.cache.retain_callback_proofs(array(row.get("readSet"), "callback source proofs")?); + Ok(()) +} + +fn dependencies( + component: &mut crate::commands::status::issues::Component, + row: &Value, +) -> io::Result<()> { + let original = + array(row.get("originalDependencyIdentities"), "callback original dependencies")?; + component.dependencies.retain(|id| { + !original + .iter() + .any(|value| value.as_str() == Some(id)) + }); + for id in array(row.get("dependencyIdentities"), "callback dependency identities")? { + let id = text(Some(id))?.trim(); + if !id.is_empty() + && !component.dependencies + .iter() + .any(|existing| existing == id) + { + component.dependencies.push(id.to_owned()); + } + } + Ok(()) +} + +pub(in crate::commands::status) fn issue_callbacks( + host: &Host, + rows: &[Value], + context: &Value, +) -> io::Result<(Vec, String)> { + let mut ignored = crate::commands::update_cmd::aspect_entry( + &context["workspace"], + "teambit.component/issues", + ) + .and_then(|(_, issues)| issues.get("ignoreIssues")) + .cloned() + .unwrap_or_else(|| json!([])); + if let Some(extra) = context["snapshotIgnoreIssues"].as_array() { + let mut combined = extra.clone(); + for issue in ignored.as_array().into_iter().flatten() { + if !combined.contains(issue) { + combined.push(issue.clone()); + } + } + ignored = json!(combined); + } + let output = host.call( + "statusIssueCallbacks", + &json!({"rows":rows,"coreIds":core_ids()?,"ignored":ignored}), + )?; + Ok(( + array(output.get("value"), "callback issue results")?.to_vec(), + output["stdout"] + .as_str() + .unwrap_or_default() + .to_owned(), + )) +} + +#[inline] +fn core_ids() -> io::Result> { + serde_json::from_str(include_str!( + "../../../../../../scopes/harmony/testing/load-aspect/core-aspects-ids.json" + )) + .map_err(io::Error::other) +} diff --git a/native/cli/src/commands/status/environments/context.rs b/native/cli/src/commands/status/environments/context.rs new file mode 100644 index 000000000000..38dd1de7255f --- /dev/null +++ b/native/cli/src/commands/status/environments/context.rs @@ -0,0 +1,245 @@ +//! Initialize provider callbacks before native command controls are captured. +//! +//! [`read_context`] and [`read_snapshot_context`] initialize [`Environments`] over [`WorkspaceComponent`] sets. +use super::{ + Environments, WorkspaceComponent, array, component, core_ids, needs_facts, root_aspects, +}; +use serde_json::{Value, json}; +use std::{io, path::Path}; + +/// Initializes configured environment providers and captures workspace status controls into `(context, Environments)`. +#[inline] +pub(in crate::commands) fn read_context( + engine: &mut crate::commands::engine::Engine, + root: &Path, + home: &Path, + history: bool, + pattern: Option<&str>, + startup: &mut crate::commands::status::autosync::Startup, +) -> io::Result<(Value, Environments)> { + read_context_with(engine, root, home, history, pattern, startup, ContextSetup::default()) +} + +/// Snapshot-specific provider startup precedes native command control receipts. +#[inline] +pub(in crate::commands) fn read_snapshot_context( + engine: &mut crate::commands::engine::Engine, + root: &Path, + home: &Path, + build: bool, + startup: &mut crate::commands::status::autosync::Startup, +) -> io::Result<(Value, Environments)> { + read_context_with( + engine, + root, + home, + false, + None, + startup, + ContextSetup { snapshot: Some(build), inherited: None }, + ) +} + +/// Installation process facts share a Host, while ordinary provider startup still runs. +#[inline] +pub(in crate::commands) fn read_inherited_context( + engine: &mut crate::commands::engine::Engine, + root: &Path, + home: &Path, + pattern: Option<&str>, + startup: &mut crate::commands::status::autosync::Startup, + inherited: Environments, +) -> io::Result<(Value, Environments)> { + read_context_with( + engine, + root, + home, + false, + pattern, + startup, + ContextSetup { snapshot: None, inherited: Some(inherited) }, + ) +} +/// Internal configuration options for [`read_context_with`]. +#[derive(Default)] +struct ContextSetup { + snapshot: Option, + inherited: Option, +} + +fn read_context_with( + engine: &mut crate::commands::engine::Engine, + root: &Path, + home: &Path, + history: bool, + pattern: Option<&str>, + startup: &mut crate::commands::status::autosync::Startup, + setup: ContextSetup, +) -> io::Result<(Value, Environments)> { + validate_context_paths(root, home)?; + crate::commands::status::autosync::run(engine, root, startup)?; + let request = json!({"kind":"statusContext","root":root,"home":home,"history":history}); + let mut context: Value = engine.request(&request)?; + let selected = crate::commands::diff::selection::select( + array(context.get("targets"), "environment targets")?, + pattern, + )?; + let mut environments = setup.inherited.unwrap_or_else(Environments::new); + if let Some(build) = setup.snapshot { + environments = environments.for_snapshot_build(build); + } + bootstrap_owned(&mut environments, root, &context, &selected, startup)?; + if let Some(build) = setup.snapshot + && (build || super::snapshot::configured(engine, root, &context)?) + { + startup.committed = true; + environments + .host(root)? + .call("dependencyPolicies", &json!({"extensionIds":[]}))?; + } + if environments.host.is_some() { + context = engine + .request(&request) + .inspect_err(|_error| { + if let Some(host) = &environments.host { + startup.stdout.push_str(&host.take_stdout()); + } + })?; + } + Ok((context, environments)) +} + +fn bootstrap_owned( + environments: &mut Environments, + root: &Path, + context: &Value, + targets: &[Value], + startup: &mut crate::commands::status::autosync::Startup, +) -> io::Result<()> { + let components = targets + .iter() + .map(|target| component(root, context, target)) + .collect::>>()?; + let core = core_ids()?; + let aspects = root_aspects(context, &core); + if components.iter().any(|component| needs_facts(component, &core, &aspects)) { + startup.committed = true; + } + environments + .prime(root, &components, &core, &aspects) + .inspect_err(|_error| { + if let Some(host) = &environments.host { + startup.stdout.push_str(&host.take_stdout()); + } + }) +} + +impl Environments { + pub(super) fn prime( + &mut self, + root: &Path, + components: &[WorkspaceComponent], + core: &[String], + aspects: &[String], + ) -> io::Result<()> { + if !components + .iter() + .any(|component| { + needs_facts(component, core, aspects) + && !(self.builtin_services != super::builtin::Services::Disabled + && super::builtin::supported(component, core, aspects)) + }) + { + return Ok(()); + } + let ids = collect_prime_ids(components, core, aspects); + let record = should_record_snapshot(self.snapshot, components, aspects); + let host = self.host(root)?; + if record { + host.call("snapshotLoadFacts", &json!({}))?; + } + let used_extensions = used_extensions(components); + host.call( + "dependencyPolicies", + &json!({"extensionIds":ids,"bootstrap":ids.iter().any(|id|aspects.contains(id)),"startupAspectIds":aspects,"componentAspectIds":components.iter().flat_map(|component|component.config.keys()).collect::>(),"componentIdentities":components.iter().map(|component|component.id.to_string_without_version()).collect::>(),"componentUsedExtensions":used_extensions}), + )?; + if record { + let facts = host.call("snapshotLoadFacts", &json!({}))?; + self.snapshot_loads.extend( + array(Some(&facts), "snapshot environment loads")?.iter().cloned(), + ); + } + Ok(()) + } +} + +fn collect_prime_ids<'a>( + components: &'a [WorkspaceComponent], + core: &[String], + aspects: &'a [String], +) -> Vec<&'a String> { + let mut seen = std::collections::HashSet::new(); + aspects + .iter() + .chain(components.iter().flat_map(|component| component.config.keys())) + .filter(|id| { + if !seen.insert(*id) || bit_install::policy::is_core_env(id) { + return false; + } + let bare = bit_install::policy::split_version(id).0; + !(core.contains(id) || core.iter().any(|item| item == bare)) + }) + .collect() +} + +#[inline] +fn should_record_snapshot( + snapshot: bool, + components: &[WorkspaceComponent], + aspects: &[String], +) -> bool { + snapshot + && components + .iter() + .any(|component| { + let bare_env = bit_install::policy::split_version(&component.env_id).0; + !bit_install::policy::is_core_env(&component.env_id) + && !aspects + .iter() + .any(|aspect| bit_install::policy::split_version(aspect).0 == bare_env) + }) +} + +#[inline] +fn validate_context_paths(root: &Path, home: &Path) -> io::Result<()> { + let is_blank = |path: &Path| { + path.as_os_str().is_empty() + || path + .to_str() + .is_some_and(|text| text.trim().is_empty()) + }; + if is_blank(root) || is_blank(home) { + return Err(io::Error::other("environment context root or home path is empty")); + } + Ok(()) +} + +fn used_extensions(components: &[WorkspaceComponent]) -> Vec { + components + .iter() + .map(|component| { + let mut used = component.config + .iter() + .filter(|(_, val)| val.as_str() != Some("-") && **val != Value::Bool(false)) + .map(|(key, _)| key.trim()) + .filter(|key| !key.is_empty()) + .map(str::to_owned) + .collect::>(); + let env_id = component.env_id.trim(); + if !env_id.is_empty() && !used.iter().any(|item| item == env_id) { + used.push(env_id.to_owned()); + } + json!({"id":component.id.to_string_without_version(),"usedIds":used}) + }) + .collect() +} diff --git a/native/cli/src/commands/status/environments/dependencies.rs b/native/cli/src/commands/status/environments/dependencies.rs new file mode 100644 index 000000000000..b5e71de1ec4b --- /dev/null +++ b/native/cli/src/commands/status/environments/dependencies.rs @@ -0,0 +1,85 @@ +//! Dependency parser and detector dispatch in the retained environment session. +//! +//! Extends [`Environments`] with [`Environments::parser`] for custom aspect dependency detectors over [`Value`]. +use super::{Environments, Value, decline, io, json, text}; +use std::path::Path; + +impl Environments { + /// Parses custom-detector dependencies for `files` in `target` via built-in syntax or `host`. + pub(in crate::commands::status) fn parser( + &mut self, + root: &Path, + target: &Value, + files: &[String], + custom: &Value, + ) -> io::Result { + if self.builtin_services == super::builtin::Services::Compiler { + return crate::commands::compile_cmd::syntax::parse(root, target, files, custom); + } + if self.builtin_services != super::builtin::Services::Disabled { + return Err(decline("component service dependency parser")); + } + let directory = target_directory(root, target)?; + let file_paths = target_file_paths(&directory, files); + let mut parsed = self.host(root)?.call("parseDependencies",&json!({"files":file_paths,"exclude":custom.as_object().map(|custom|custom.keys().collect::>()).unwrap_or_default()}))?; + if let Some(custom) = custom.as_object() { + parsed + .as_object_mut() + .ok_or_else(|| decline("parser receipt"))? + .extend(custom.clone()); + } + Ok(parsed) + } + + /// Invokes `detectDependencies` on `self.host` when `target`'s environment requires custom detectors. + pub(in crate::commands::status) fn detectors( + &mut self, + root: &Path, + context: &Value, + target: &Value, + files: &[String], + ) -> io::Result { + let identity = text(target.get("identity"))?.trim(); + let facts = &context["environmentFacts"][identity]; + let env = facts["envId"] + .as_str() + .map(str::trim) + .filter(|env| !env.is_empty()) + .unwrap_or("teambit.harmony/node"); + let bare_env = bit_install::policy::split_version(env).0; + if self.builtin_services != super::builtin::Services::Disabled + && bare_env == "teambit.react/react" + { + return Ok(Value::Null); + } + if (bare_env == "teambit.harmony/node" && facts.is_null()) || facts["nonLoaded"] == true { + return Ok(Value::Null); + } + let directory = target_directory(root, target)?; + let file_paths = target_file_paths(&directory, files); + self.host(root)?.call("detectDependencies", &json!({"envId":env,"files":file_paths,"componentDir":directory,"aspectIds":facts["aspectIds"]})) + } +} + +#[inline] +fn target_directory(root: &Path, target: &Value) -> io::Result { + let dir = text( + target + .get("directory") + .filter(|val| !val.is_null()) + .or_else(|| target.get("rootDir")), + )? + .trim(); + Ok(root.join(dir)) +} + +#[inline] +fn target_file_paths(directory: &Path, files: &[String]) -> Vec { + files + .iter() + .map(String::as_str) + .map(|file| file.trim().trim_start_matches("./")) + .filter(|file| !file.is_empty()) + .map(|file| directory.join(file)) + .collect() +} diff --git a/native/cli/src/commands/status/environments/diagnostics.rs b/native/cli/src/commands/status/environments/diagnostics.rs new file mode 100644 index 000000000000..dab4505d403f --- /dev/null +++ b/native/cli/src/commands/status/environments/diagnostics.rs @@ -0,0 +1,125 @@ +//! Environment issue facts after native dependency detection. +//! +//! [`issues`] merges environment diagnostic [`Value`] records with [`full_identity`] resolution. + +use crate::commands::status::js::{array, decline, full_identity, text}; +use serde_json::{Value, json}; +use std::io; + +/// Appends environment diagnostic issues (`MultipleEnvs`, `RemovedEnv`, `NonLoadedEnv`, `ExternalEnvWithoutVersion`) onto `dependencies`. +pub(in crate::commands::status) fn issues( + context: &Value, + target: &Value, + dependencies: &mut Value, +) -> io::Result<()> { + let identity = validated_identity(target)?; + let facts = &context["environmentFacts"][identity]; + load_failures(context, facts, identity, dependencies)?; + if facts["multiple"] + .as_array() + .is_some_and(|ids| ids.len() > 1) + { + append_issue(dependencies, "MultipleEnvs", &facts["multiple"])?; + } + removed(context, facts, dependencies)?; + if facts["nonLoaded"] != true { + return Ok(()); + } + let env = text(facts.get("envId"))?.trim(); + let bare_env = bit_install::policy::split_version(env).0; + let workspace_env = array(context.get("targets"), "env diagnostic targets")? + .iter() + .any(|target| target["identity"].as_str().map(str::trim) == Some(bare_env)); + let (kind, data) = if !env.contains('@') && !workspace_env { + ("ExternalEnvWithoutVersion", json!({"envId":env,"componentId":full_identity(target)?})) + } else { + ("NonLoadedEnv", json!(env)) + }; + append_issue(dependencies, kind, &data) +} + +/// Appends a `LoadFailures` issue onto `dependencies` when `identity` has matching aspect load failures. +fn load_failures( + context: &Value, + facts: &Value, + identity: &str, + dependencies: &mut Value, +) -> io::Result<()> { + let Some(failures) = context["environmentFacts"]["loadFailures"] + .as_array() + .filter(|arr| !arr.is_empty()) + .or_else(|| facts["loadFailures"].as_array()) + else { + return Ok(()); + }; + let already_has = dependencies["issues"] + .as_array() + .is_some_and(|issues| { + issues + .iter() + .any(|issue| issue["type"] == "LoadFailures") + }); + if already_has { + return Ok(()); + } + let matching = failures + .iter() + .filter(|entry| { + entry["failedId"] + .as_str() + .is_some_and(|failed| { + bit_install::policy::split_version(failed.trim()).0 == identity + }) + }) + .cloned() + .collect::>(); + if !matching.is_empty() { + append_issue(dependencies, "LoadFailures", &Value::Array(matching))?; + } + Ok(()) +} + +fn removed(context: &Value, facts: &Value, dependencies: &mut Value) -> io::Result<()> { + if let Some(env) = facts["envId"] + .as_str() + .map(str::trim) + .filter(|env| !env.is_empty()) + { + let bare_env = bit_install::policy::split_version(env).0; + for target in array(context.get("targets"), "removed environment targets")? { + if target["locallyRemoved"] == true + && target["identity"].as_str().map(str::trim) == Some(bare_env) + { + append_issue(dependencies, "RemovedEnv", &json!(full_identity(target)?))?; + } + } + } + Ok(()) +} + +#[inline] +fn append_issue(dependencies: &mut Value, kind: &str, data: &Value) -> io::Result<()> { + if dependencies["issues"].is_null() { + dependencies["issues"] = json!([]); + } + dependencies["issues"] + .as_array_mut() + .ok_or_else(|| decline("environment issues"))? + .push(json!({"type":kind,"data":data.to_string()})); + Ok(()) +} + +#[inline] +fn validated_identity(target: &Value) -> io::Result<&str> { + let identity = text( + target + .get("identity") + .filter(|val| !val.is_null()) + .or_else(|| target.get("id")), + )? + .trim(); + if identity.is_empty() { + return Err(decline("environment target identity is empty")); + } + Ok(identity) +} diff --git a/native/cli/src/commands/status/environments/facts.rs b/native/cli/src/commands/status/environments/facts.rs new file mode 100644 index 000000000000..118f1bb44314 --- /dev/null +++ b/native/cli/src/commands/status/environments/facts.rs @@ -0,0 +1,236 @@ +//! Raw registered environment policies and callback facts. +//! +//! [`read`] queries [`Host`] and [`EnvJsoncReader`] for [`WorkspaceComponent`] environment [`Value`] facts. + +use super::{EnvJsoncReader, Host, Value, WorkspaceComponent, io, json}; + +/// Queries `host` and `reader` for raw environment policy, compiler, dev-pattern, and registration facts for `component`. +pub(super) fn read( + host: &Host, + reader: &EnvJsoncReader<'_>, + component: &WorkspaceComponent, + core: &[String], + aspects: &[String], + needs_issues: bool, +) -> io::Result { + let configured = component.config + .keys() + .filter(|id| { + let bare = bit_install::policy::split_version(id).0; + !(core.contains(id) + || core.iter().any(|item| item == bare) + || bit_install::policy::is_core_env(id)) + }) + .collect::>(); + let registrations = if configured.is_empty() { + json!([]) + } else { + host.call("dependencyPolicies", &json!({"extensionIds":configured}))? + }; + let (env_id, multiple) = selected_env(host, reader, component)?; + validate_selected_env(&env_id)?; + let manifest = reader.resolved(&env_id)?; + let provider = provider_facts(host, &env_id, manifest.as_ref(), needs_issues)?; + Ok(json!({"envId":env_id,"envPolicy":provider.env_policy, + "compiler":provider.compiler,"nonLoaded":provider.dev_patterns.is_null(),"devPatterns":provider.dev_patterns,"multiple":multiple, + "manifestPatterns":manifest.as_ref().and_then(|manifest|manifest.get("patterns")), + "registeredEnvPolicy":registered(component,®istrations),"aspectIds":aspects, + "envOwnPolicy":own_policy(reader,component,host,aspects)?, + "loadFailures":provider.load_failures, + })) +} + +/// Resolved environment policy, dev-file patterns, compiler settings, and load failures from `providerFacts`. +struct ProviderFacts { + env_policy: Value, + dev_patterns: Value, + compiler: Value, + load_failures: Value, +} + +fn provider_facts( + host: &Host, + env_id: &str, + manifest: Option<&Value>, + needs_issues: bool, +) -> io::Result { + let provider = host.call( + "providerFacts", + &json!({"envId":env_id,"needsEnvPolicy":manifest.is_none(),"needsIssues":needs_issues}), + )?; + let env_policy = match manifest { + Some(manifest) => manifest + .get("policy") + .cloned() + .unwrap_or_else(|| json!({})), + None => provider_fact(&provider, "envPolicy")?, + }; + let dev_patterns = provider_fact(&provider, "devPatterns")?; + let compiler = if needs_issues { provider_fact(&provider, "compiler")? } else { Value::Null }; + let load_failures = provider + .get("loadFailures") + .cloned() + .unwrap_or_else(|| json!([])); + Ok(ProviderFacts { + env_policy: if env_policy.is_null() { json!({}) } else { env_policy }, + dev_patterns, + compiler, + load_failures, + }) +} + +#[inline] +fn provider_fact(provider: &Value, name: &str) -> io::Result { + provider + .get(name) + .cloned() + .ok_or_else(|| io::Error::other(format!("provider facts omitted {name}"))) +} + +#[inline] +fn validate_selected_env(env_id: &str) -> io::Result<()> { + if env_id.trim().is_empty() { + return Err(io::Error::other("selected environment id is empty")); + } + Ok(()) +} + +#[inline] +fn explicit_env(component: &WorkspaceComponent) -> Option<&str> { + component.config + .get("teambit.envs/envs") + .or_else(|| { + component.config + .iter() + .find_map(|(key, val)| { + (bit_install::policy::split_version(key).0 == "teambit.envs/envs").then_some( + val, + ) + }) + }) + .and_then(|config| config["env"].as_str()) + .map(str::trim) + .filter(|env| !env.is_empty()) +} + +fn selected_env( + host: &Host, + reader: &EnvJsoncReader<'_>, + component: &WorkspaceComponent, +) -> io::Result<(String, Value)> { + let envs = + host.call("environmentIds", &json!({"ids":component.config.keys().collect::>()}))?; + let explicit = explicit_env(component); + let selected = explicit + .map_or_else( + || { + envs.get(0) + .and_then(Value::as_str) + .map(str::trim) + .filter(|env| !env.is_empty()) + .or_else(|| non_loaded_workspace_env(reader, component)) + .unwrap_or(&component.env_id) + }, + |env| { + component.config + .keys() + .map(String::as_str) + .map(str::trim) + .find(|id| { + bit_install::policy::split_version(id).0 + == bit_install::policy::split_version(env).0 + }) + .unwrap_or(env) + }, + ) + .trim() + .to_owned(); + Ok((selected, if explicit.is_some() { json!([]) } else { envs })) +} + +fn non_loaded_workspace_env<'a>( + reader: &EnvJsoncReader<'_>, + component: &'a WorkspaceComponent, +) -> Option<&'a str> { + if bit_install::policy::split_version(&component.env_id).0 != "teambit.harmony/node" { + return None; + } + component.config + .keys() + .find(|id| { + let base = bit_install::policy::split_version(id).0; + reader.components + .iter() + .any(|other| { + other.id.to_string_without_version() == base + && (other.is_env + || bit_install::policy::split_version(&other.env_id).0 + == "teambit.envs/env" + || other.config + .keys() + .any(|key| { + bit_install::policy::split_version(key).0 == "teambit.envs/env" + })) + && !other.dir.join("dist").exists() + }) + }) + .map(String::as_str) +} + +fn registered(component: &WorkspaceComponent, registrations: &Value) -> Vec { + let Some(entries) = registrations + .as_array() + .filter(|arr| !arr.is_empty()) + else { + return Vec::new(); + }; + component.config + .iter() + .filter(|(_, val)| val.as_str() != Some("-") && **val != Value::Bool(false)) + .filter_map(|(id, _)| { + let id = id.trim(); + if id.is_empty() { + return None; + } + let bare_id = bit_install::policy::split_version(id).0; + entries + .iter() + .find(|entry| { + entry[0] + .as_str() + .map(str::trim) + .is_some_and(|registrar| { + registrar == id + || registrar.contains(id) + || bit_install::policy::split_version(registrar).0 == bare_id + }) + }) + .map(|entry| entry[1].clone()) + }) + .collect() +} + +fn own_policy( + reader: &EnvJsoncReader<'_>, + component: &WorkspaceComponent, + host: &Host, + aspects: &[String], +) -> io::Result { + if !component.is_env + && bit_install::policy::split_version(&component.env_id).0 != "teambit.envs/env" + { + return Ok(json!({})); + } + let env_id = component.id.to_string_without_version(); + let policy = if let Some(manifest) = reader.resolved(&env_id)? { + bit_install::policy::env::from_config(&manifest["policy"], false)? + } else { + let raw = host.call("registeredEnvPolicy", &json!({"envId":env_id,"aspectIds":aspects}))?; + bit_install::policy::env::from_config(&raw, true)? + }; + let dependencies = policy.self_policy + .into_iter() + .map(|entry| (entry.name, json!(entry.version))) + .collect::>(); + Ok(json!({"dependencies":dependencies})) +} diff --git a/native/cli/src/commands/status/environments/modes.rs b/native/cli/src/commands/status/environments/modes.rs new file mode 100644 index 000000000000..8432026db44d --- /dev/null +++ b/native/cli/src/commands/status/environments/modes.rs @@ -0,0 +1,158 @@ +//! Retained callback sessions change operation without replaying providers. +//! +//! Extends [`Environments`] with [`Environments::stash_added`], checkout, and install callback dispatch over [`Host`]. +use super::{Environments, Host, Path, Value, component, io, json, text}; + +impl Environments { + /// Creates an empty [`Environments`] session configured for post-stash callback execution. + #[inline] + pub(in crate::commands) fn for_stash_callbacks() -> Self { + Self::new() + } + + /// Invoke registered add/compile callbacks after Rust restores stashed sources and bitmap. + pub(in crate::commands) fn stash_added( + &mut self, + root: &Path, + ids: &[String], + ) -> io::Result { + let ids = ids + .iter() + .map(String::as_str) + .map(str::trim) + .filter(|id| !id.is_empty()) + .collect::>(); + if ids.is_empty() { + return Ok(String::new()); + } + let host = self.host(root)?; + host.call("stashAdded", &json!({"ids":ids}))?; + Ok(host.take_stdout()) + } + + /// Wraps an already-running [`Host`] into an [`Environments`] session. + #[inline] + pub(in crate::commands) fn from_host(host: Host) -> Self { + Self { host: Some(host), ..Self::new() } + } + + #[inline] + pub(in crate::commands) fn continue_snapshot(self, build: bool) -> io::Result { + if let Some(host) = &self.host { + host.call("snapshotContinuation", &json!({}))?; + } + Ok(self.for_snapshot_build(build)) + } + + #[inline] + pub(in crate::commands) fn for_snapshot_build(mut self, build: bool) -> Self { + self.snapshot = true; + self.snapshot_build = build; + self + } + + #[inline] + pub(in crate::commands::status) fn is_snapshot(&self) -> bool { + self.snapshot + } + + #[inline] + pub(in crate::commands) fn for_checkout(mut self) -> Self { + self.checkout = true; + self + } + + /// Import publishes eligible dependency data before actual onLoad subscribers run. + #[inline] + pub(in crate::commands) fn for_import_callbacks(mut self, cache_initialized: bool) -> Self { + self.import = Some(cache_initialized); + self + } + + #[inline] + pub(in crate::commands::status) fn is_import(&self) -> bool { + self.import.is_some() + } + + #[inline] + pub(in crate::commands::status) fn import_cache_initialized(&self) -> bool { + self.import == Some(true) + } + + #[inline] + pub(in crate::commands::status) fn is_checkout(&self) -> bool { + self.checkout + } + + #[inline] + pub(in crate::commands) fn with_issue_facts(mut self, enabled: bool) -> Self { + self.issue_facts = enabled; + self + } +} + +pub(in crate::commands::status) fn dev_files( + root: &Path, + context: &Value, + target: &Value, + files: &[String], +) -> io::Result> { + use bit_install::dependencies::{DevPatternConfig, EnvDevPatterns, dev_files, dev_patterns}; + let mut comp = component(root, context, target)?; + comp.files = files.to_vec(); + let identity = text( + target + .get("identity") + .filter(|val| !val.is_null()) + .or_else(|| target.get("id")), + )? + .trim(); + let facts = &context["environmentFacts"][identity]; + if let Some(env_id) = facts["envId"] + .as_str() + .map(str::trim) + .filter(|env_id| !env_id.is_empty()) + { + env_id.clone_into(&mut comp.env_id); + } + let hooks = &facts["devPatterns"]; + let list = |key: &str| { + hooks + .get(key) + .filter(|val| !val.is_null()) + .map(strings) + }; + let env = EnvDevPatterns { + patterns: facts + .get("manifestPatterns") + .or_else(|| hooks.get("devPatterns")) + .filter(|val| !val.is_null()) + .cloned(), + tests: list("tests"), + docs: list("docs"), + compositions: list("compositions"), + }; + let config = DevPatternConfig::from_aspects(&|id| { + crate::commands::update_cmd::aspect_entry(&context["workspace"], id) + .map(|(_, val)| val.clone()) + }); + Ok(dev_files(&comp, &dev_patterns(&comp, &config, &env))) +} + +#[inline] +fn strings(value: &Value) -> Vec { + match value { + Value::Array(values) => values + .iter() + .filter_map(Value::as_str) + .map(str::trim) + .filter(|item| !item.is_empty()) + .map(str::to_owned) + .collect(), + Value::String(value) => { + let trimmed = value.trim(); + if trimmed.is_empty() { Vec::new() } else { vec![trimmed.to_owned()] } + } + _ => Vec::new(), + } +} diff --git a/native/cli/src/commands/status/environments/quick.rs b/native/cli/src/commands/status/environments/quick.rs new file mode 100644 index 000000000000..2f6bd3044572 --- /dev/null +++ b/native/cli/src/commands/status/environments/quick.rs @@ -0,0 +1,51 @@ +//! Root provider startup for public quick status, without component hooks. +//! +//! [`run`] initializes [`Environments`] with [`root_aspects`] over [`Path`] roots for `bit status --quick`. + +use super::{Environments, root_aspects}; +use serde_json::json; +use std::{io, path::Path}; + +/// Loads root workspace aspect providers for `bit status --quick` without hydrating component hooks. +pub(in crate::commands) fn run( + root: &Path, + home: &Path, + startup: &mut crate::commands::status::autosync::Startup, +) -> io::Result<()> { + let is_blank = |path: &Path| { + path.as_os_str().is_empty() + || path + .to_str() + .is_some_and(|text| text.trim().is_empty()) + }; + if is_blank(root) || is_blank(home) { + return Err(io::Error::other("quick status root or home path is empty")); + } + let workspace = bit_object_import::effective_workspace_configuration(root, home)?; + if workspace.as_object().is_none_or(serde_json::Map::is_empty) { + return Ok(()); + } + let core = core_aspect_ids()?; + let aspects = root_aspects(&json!({"workspace":workspace}), &core); + if aspects.is_empty() { + return Ok(()); + } + let mut environments = Environments::new(); + let host = environments.host(root)?; + startup.committed = true; + let result = host.call("dependencyPolicies", &json!({"extensionIds":aspects})); + let stdout = host.take_stdout(); + if !stdout.is_empty() { + startup.stdout.push_str(&stdout); + } + result.map(|_| ()) +} + +/// Deserializes the embedded core aspect IDs list. +#[inline] +fn core_aspect_ids() -> io::Result> { + serde_json::from_str(include_str!( + "../../../../../../scopes/harmony/testing/load-aspect/core-aspects-ids.json" + )) + .map_err(io::Error::other) +} diff --git a/native/cli/src/commands/status/environments/snapshot.rs b/native/cli/src/commands/status/environments/snapshot.rs new file mode 100644 index 000000000000..5279578e5461 --- /dev/null +++ b/native/cli/src/commands/status/environments/snapshot.rs @@ -0,0 +1,128 @@ +//! Select the callback boundary before snapshot control receipts are retained. +//! +//! [`configured`] inspects [`Engine`], [`Inventory`], and [`Options`] state to decide whether snapshot callbacks apply. +use super::{array, component, text}; +use crate::commands::{ + engine::Engine, + inventory::{Inventory, Options}, +}; +use serde_json::{Value, json}; +use std::{io, path::Path}; + +/// Returns `true` when `context` has pre-snap formatter or non-core aspect callbacks requiring host hydration. +pub(super) fn configured(engine: &mut Engine, root: &Path, context: &Value) -> io::Result { + if crate::commands::update_cmd::aspect_entry( + &context["workspace"], + "teambit.defender/formatter", + ) + .is_some_and(|(_, fmt)| fmt["formatOnPreSnap"] == true) + { + return Ok(true); + } + let targets = array(context.get("targets"), "snapshot callback targets")?; + if targets.is_empty() { + return Ok(false); + } + let options: Options = + serde_json::from_value(context["options"].clone()).map_err(io::Error::other)?; + let inventory = Inventory::new(&options)?; + for target in targets { + let component = component(root, context, target)?; + if component.config + .iter() + .any(|(id, config)| { + !matches!( + bit_install::policy::split_version(id).0, + "teambit.envs/envs" + | "teambit.harmony/node" + | "teambit.dependencies/dependency-resolver" + | "teambit.component/remove" + | "teambit.component/deprecation" + | "teambit.component/issues", + ) && config + .as_object() + .is_some_and(|config| !config.is_empty()) + }) + { + return Ok(true); + } + if source_callbacks(engine, root, context, target, &inventory)? { + return Ok(true); + } + } + Ok(false) +} + +/// Checks whether `target`'s source directory triggers snapshot callbacks under `inventory`. +fn source_callbacks( + engine: &mut Engine, + root: &Path, + context: &Value, + target: &Value, + inventory: &Inventory, +) -> io::Result { + let raw_dir = text( + target + .get("directory") + .filter(|val| !val.is_null()) + .or_else(|| target.get("rootDir")), + )? + .trim(); + if raw_dir.is_empty() { + return Ok(false); + } + let trimmed = raw_dir.trim_start_matches("./").trim_end_matches('/'); + let directory = if trimmed.is_empty() { "." } else { trimmed }; + let location = root.join(directory); + if !location.try_exists()? { + return Ok(false); + } + let excludes = root_excludes(root, context, directory)?; + let manifest = inventory.scan( + engine, + json!({"kind":"inventoryBegin","root":location,"workspace":root,"excludeDirs":excludes}), + "inventoryFinish", + )?; + Ok(array(manifest.get("files"), "snapshot source names")?.iter().any(is_callback_source_file)) +} + +#[inline] +fn root_excludes( + root: &Path, + context: &Value, + directory: &str, +) -> io::Result> { + if directory != "." { + return Ok(Vec::new()); + } + Ok(array(context.get("targets"), "snapshot inventory targets")? + .iter() + .filter_map(|row| { + row["directory"] + .as_str() + .or_else(|| row["rootDir"].as_str()) + }) + .map(|dir| { + dir.trim() + .trim_start_matches("./") + .trim_end_matches('/') + }) + .filter(|dir| !dir.is_empty() && *dir != ".") + .map(|dir| root.join(dir)) + .collect()) +} + +#[inline] +fn is_callback_source_file(file: &Value) -> bool { + file["relativePath"] + .as_str() + .map(str::trim) + .is_some_and(|name| { + name.contains(".docs.") + || name.contains(".composition.") + || name.contains(".compositions.") + || name.contains(".bit-app.") + || name.contains(".node-app.") + || name.contains(".react-app.") + }) +} diff --git a/native/cli/src/commands/status/environments/switch.rs b/native/cli/src/commands/status/environments/switch.rs new file mode 100644 index 000000000000..35a3380bf80d --- /dev/null +++ b/native/cli/src/commands/status/environments/switch.rs @@ -0,0 +1,130 @@ +//! Retain one published switch loader while Rust captures each requested source. +//! +//! Extends [`Environments`] with [`Environments::switch_begin`] over [`Capture`], [`Host`], and [`callbacks`]. + +use super::{Environments, Host, Value, callbacks, decline, io, json}; +use crate::commands::status::capture::{Capture, callback_load_stdout, callback_startup_stdout}; + +impl Environments { + /// Begins an ordered checkout/switch callback stage on `self.host` when checkout callbacks are active. + pub(in crate::commands::status) fn switch_begin( + &self, + context: &Value, + capture: &Capture, + order: &[String], + ) -> io::Result> { + let Some(host) = self.host + .as_ref() + .filter(|_| self.checkout && !order.is_empty()) + else { + return Ok(None); + }; + callbacks::switch_begin(host, context, capture, order) + .map_err(|error| self.callback_failure(capture, error)) + } + + /// Returns the active checkout callback [`Host`] or declines if absent. + #[inline] + fn switch_host(&self) -> io::Result<&Host> { + self.host.as_ref().ok_or_else(|| decline("switch callback host")) + } + + #[inline] + pub(in crate::commands::status) fn switch_one( + &self, + context: &Value, + capture: &Capture, + identity: &str, + ) -> io::Result { + let identity = validated_switch_id(identity)?; + let host = self.switch_host()?; + callbacks::switch_one(host, context, capture, identity) + .map_err(|error| self.callback_failure(capture, error)) + } + + #[inline] + pub(in crate::commands::status) fn switch_stage_row( + &self, + context: &Value, + capture: &Capture, + identity: &str, + ) -> io::Result<()> { + let identity = validated_switch_id(identity)?; + let host = self.switch_host()?; + callbacks::switch_stage_row(host, context, capture, identity) + .map_err(|error| self.callback_failure(capture, error)) + } + + #[inline] + pub(in crate::commands::status) fn switch_resume( + &self, + capture: &Capture, + ) -> io::Result { + let host = self.switch_host()?; + callbacks::switch_resume(host, capture) + .map_err(|error| self.callback_failure(capture, error)) + } + + #[inline] + pub(in crate::commands::status) fn switch_accept( + &self, + capture: &mut Capture, + result: &Value, + identity: &str, + ) -> io::Result<()> { + let identity = validated_switch_id(identity)?; + callbacks::switch_accept(capture, result, identity) + .map_err(|error| self.callback_failure(capture, error)) + } + + #[inline] + pub(in crate::commands::status) fn switch_finish( + &mut self, + capture: &mut Capture, + stage: callbacks::SwitchStage, + ) -> io::Result<()> { + let host = self.switch_host()?; + callbacks::switch_finish(host, capture, stage) + .map_err(|error| self.callback_failure(capture, error))?; + capture.cache.retain_callback_host(self.host.take()); + Ok(()) + } + + pub(in crate::commands::status) fn switch_abort( + &self, + capture: &Capture, + error: io::Error, + ) -> io::Error { + let host_failed = error + .get_ref() + .is_some_and( + ::is::< + crate::host::callback_error::CallbackError, + >, + ); + if !host_failed && let Some(host) = &self.host { + let _ = host.call("switchStatusFinish", &json!({})); + capture.cache.retain_callback_stdout(&host.take_stdout()); + } + if host_failed { error } else { self.callback_failure(capture, error) } + } + + #[inline] + fn callback_failure(&self, capture: &Capture, error: io::Error) -> io::Error { + let output = self.host.as_ref().map_or_else(String::new, Host::take_stdout); + crate::host::callback_error::CallbackError::wrap( + error, + callback_startup_stdout(capture), + format!("{}{}", callback_load_stdout(capture), output), + ) + } +} + +#[inline] +fn validated_switch_id(identity: &str) -> io::Result<&str> { + let identity = identity.trim(); + if identity.is_empty() { + return Err(decline("switch callback identity")); + } + Ok(identity) +} diff --git a/native/cli/src/commands/status/environments/tracking.rs b/native/cli/src/commands/status/environments/tracking.rs new file mode 100644 index 000000000000..d7fc9dd63372 --- /dev/null +++ b/native/cli/src/commands/status/environments/tracking.rs @@ -0,0 +1,137 @@ +//! Project native tracking metadata into published classes before linking callbacks. +//! +//! Extends [`Environments`] with [`Environments::take_tracking_stdout`] and [`Environments::identity_installation_info`] over [`Host`]. +use super::Environments; +use crate::host::Host; +use serde_json::{Value, json}; +use std::{ffi::OsString, io, path::Path}; + +impl Environments { + /// Drains and returns already-buffered [`Host`] stdout before another metadata adapter reads it (see [`Environments::identity_installation_info`]). + #[inline] + pub(in crate::commands) fn take_tracking_stdout(&self) -> String { + self.host.as_ref().map_or_else(String::new, Host::take_stdout) + } + + /// Resolve installer process facts before native workers can read the environment. + pub(in crate::commands) fn identity_installation_info( + &mut self, + root: &Path, + args: &[OsString], + ) -> io::Result<(bit_install::HostInfo, String)> { + let argv = args + .iter() + .map(|arg| { + arg.to_str() + .map(str::to_owned) + .ok_or_else(|| io::Error::other("installation argv")) + }) + .collect::>>()?; + let terminal = crate::commands::install::Terminal::detect(&argv); + let host = self.host(root)?; + match crate::commands::install::host_info(host, &terminal) { + Ok(info) => Ok((info, host.take_stdout())), + Err(error) => Err(self.failure(error)), + } + } + + /// Start a tracking callback session before installing its projected bitmap. + #[inline] + pub(in crate::commands) fn for_tracking() -> Self { + Self::new() + } + + /// Retain one provider session across native identity and path mutations. + pub(in crate::commands) fn identity_call( + &mut self, + root: &Path, + method: &str, + params: &Value, + ) -> io::Result<(Value, String)> { + let method = method.trim(); + if method.is_empty() { + return Err(io::Error::other("identity callback method is empty")); + } + let host = self.host(root)?; + let result = host.call(method, params); + match result { + Ok(value) => Ok((value, host.take_stdout())), + Err(error) => Err(self.failure(error)), + } + } + + /// Ask only the published semantic parser; Rust publishes the returned cache metadata. + pub(in crate::commands) fn tracking_docs( + &mut self, + root: &Path, + components: &[Value], + native_cache_only: bool, + ) -> io::Result { + if components.is_empty() { + return Ok(json!([])); + } + self.host(root)? + .call( + "trackingDocs", + &json!({"components":components,"nativeCacheOnly":native_cache_only}), + ) + } + + /// Resolve only published environment configuration metadata before native tracking. + pub(in crate::commands) fn tracking_env_config( + &mut self, + root: &Path, + env: &str, + ) -> io::Result { + let env = env.trim(); + if env.is_empty() { + return Err(io::Error::other("tracking environment id is empty")); + } + self.host(root)? + .call("trackingEnvConfig", &json!({"env":env})) + } + + /// Replace only in-memory tracking records; the native plan retains disk publication. + #[inline] + pub(in crate::commands) fn project_tracking( + &mut self, + root: &Path, + bitmap: &Value, + components: &[Value], + ) -> io::Result { + self.project_tracking_with(root, &json!({"bitmap":bitmap,"components":components})) + } + + /// Keep loaded identity classes while projecting later writes from the same command. + #[inline] + pub(in crate::commands) fn project_tracking_retained( + &mut self, + root: &Path, + bitmap: &Value, + components: &[Value], + ) -> io::Result { + self.project_tracking_with( + root, + &json!({"bitmap":bitmap,"components":components,"retainLoaded":true}), + ) + } + + fn project_tracking_with(&mut self, root: &Path, payload: &Value) -> io::Result { + if root.as_os_str().is_empty() + || root + .to_str() + .is_some_and(|path| path.trim().is_empty()) + { + return Err(io::Error::other("tracking workspace root is empty")); + } + let host = self.host(root)?; + host.call("trackingProjectBitmap", payload)?; + Ok(host.take_stdout()) + } + + /// Transfer the same provider and component caches to native package/link adapters. + #[inline] + pub(in crate::commands) fn into_tracking_host(mut self) -> Option { + self.host.take() + } +} diff --git a/native/cli/src/commands/status/fresh.rs b/native/cli/src/commands/status/fresh.rs new file mode 100644 index 000000000000..dfeafcbc3063 --- /dev/null +++ b/native/cli/src/commands/status/fresh.rs @@ -0,0 +1,304 @@ +//! Fresh dependency detection, for components whose dependency cache entry +//! is missing or stale, and the commit of their cache entries. The [`Engine`] scans and +//! resolves; this module supplies what Node knew: the [`dev_files`] patterns, the +//! running Node's built-in modules, the bundled `TypeScript` version and the +//! core aspect packages. + +pub mod dev_files; +#[cfg(test)] +mod fresh_tests; +mod node; + +use super::{ + grammar, + js::{array, decline, full_identity, text}, + policy::Policy, +}; +use crate::commands::engine::Engine; +use serde_json::{Value, json}; +use std::{io, path::Path}; + +/// `rustGraphDetectorExtensions` plus `.jsonc`. +const DETECTOR_EXTENSIONS: [&str; 16] = [ + ".js", ".jsx", ".cjs", ".mjs", ".css", ".sass", ".less", ".scss", ".styl", ".mts", ".cts", + ".ts", ".tsx", ".md", ".mdx", ".jsonc", +]; +/// The `TypeScript` version the engine's import resolution reproduces. +const TYPESCRIPT: &str = "5.9.2"; +/// `getAllCoreAspectsIds()`: the list `npm run generate-core-aspects-ids` +/// writes for `load-aspect` (`bit aspect list-core --json`). +const CORE_ASPECT_IDS: &str = + include_str!("../../../../../scopes/harmony/testing/load-aspect/core-aspects-ids.json"); + +/// A detection whose cache entry is written once the whole command succeeded. +#[derive(Clone)] +pub struct Pending { + identity: String, + raw_data: String, + cacheable: bool, + read_set: Value, +} + +impl Pending { + /// A `dependencyCaches` entry of the engine. + pub fn from_cache(cache: &Value) -> io::Result { + let raw_data = cache.get("rawData").and_then(Value::as_str); + let cacheable = cache.get("cacheable").and_then(Value::as_bool); + let read_set = cache.get("readSet").filter(|read_set| read_set.is_array()); + let (Some(raw_data), Some(cacheable), Some(read_set)) = (raw_data, cacheable, read_set) + else { + return Err(decline("command dependency cache receipt")); + }; + let identity = text(cache.get("identity"))?.trim().to_string(); + if identity.is_empty() { + return Err(decline("command dependency cache identity")); + } + Ok(Self { identity, raw_data: raw_data.to_string(), cacheable, read_set: read_set.clone() }) + } + + /// Constructs a [`Pending`] cache entry from freshly detected `dependencies` and `read_set` for `target`. + fn detected(target: &Value, dependencies: &Value, read_set: Value) -> io::Result { + let raw_data = dependencies.get("rawData").and_then(Value::as_str); + let cacheable = dependencies.get("cacheable").and_then(Value::as_bool); + let (Some(raw_data), Some(cacheable), true) = (raw_data, cacheable, read_set.is_array()) + else { + return Err(decline("status fresh dependency cache receipt")); + }; + Ok(Self { + identity: full_identity(target)?, + raw_data: raw_data.to_string(), + cacheable, + read_set, + }) + } + + /// Keep source receipts validated while suppressing a cache-blocked detection write. + #[inline] + pub(in crate::commands) fn block_cache(&mut self) { + self.cacheable = false; + } + + /// The same entry under another identity. + #[must_use] + #[inline] + pub fn renamed(&self, identity: String) -> Self { + Self { + cacheable: self.cacheable, + identity, + raw_data: self.raw_data.clone(), + read_set: self.read_set.clone(), + } + } + + /// `@`. + #[must_use] + #[inline] + pub fn identity(&self) -> &str { + &self.identity + } + + #[inline] + pub(in crate::commands::status) fn replace_source_proof( + &mut self, + before: &Value, + after: &Value, + ) { + if let Some(proofs) = self.read_set.as_array_mut() { + for proof in proofs { + if proof == before { + *proof = after.clone(); + } + } + } + } + + /// The files the detection read, which the engine checks again before writing. + #[must_use] + #[inline] + pub fn read_set(&self) -> &[Value] { + self.read_set + .as_array() + .map_or(&[], Vec::as_slice) + } +} + +/// Facts about the Node.js CLI that are the same for every component. +pub struct Facts { + graph: Value, + core_packages: Vec, + core_aspect_ids: Vec, +} + +impl Facts { + pub fn new(home: &Path) -> io::Result { + let mut graph = dependency_grammar()?; + graph["home"] = json!(home); + let ids: Vec = serde_json::from_str(CORE_ASPECT_IDS).map_err(io::Error::other)?; + let core_packages = ids + .iter() + .map(|id| core_package_name(id)) + .collect(); + Ok(Self { graph, core_packages, core_aspect_ids: ids }) + } +} + +/// `nativeDependencyGrammar()`: the running Node's built-in modules, the +/// detector extensions and whether the bundled TypeScript is the one the +/// engine reproduces. +pub fn dependency_grammar() -> io::Result { + let node = node::Node::find()?; + Ok(json!({ + "builtins": node.builtins()?, + "detectorExtensions": DETECTOR_EXTENSIONS, + "typescript": node.typescript_version()?.as_deref() == Some(TYPESCRIPT), + })) +} + +/// `getCoreAspectPackageName`: `teambit.x/a/b` is `@teambit/a.b`. +#[inline] +fn core_package_name(id: &str) -> String { + let mut pkg = String::with_capacity(9 + id.len()); + pkg.push_str("@teambit/"); + for (idx, part) in id + .trim() + .split('/') + .skip(1) + .map(str::trim) + .filter(|part| !part.is_empty()) + .enumerate() + { + if idx > 0 { + pkg.push('.'); + } + pkg.push_str(part); + } + pkg +} + +/// What one fresh detection needs beyond the target and its files. +pub struct Detection<'d> { + pub root: &'d Path, + pub context: &'d Value, + pub facts: &'d Facts, +} + +impl Detection<'_> { + /// `readFreshDependencies`: the dependency state and the pending cache entry. + pub fn read_with_detectors( + &self, + engine: &mut Engine, + target: &Value, + files: &[String], + policy: &Policy, + custom: Value, + ) -> io::Result<(Value, Pending)> { + let dir = text( + target + .get("directory") + .filter(|val| !val.is_null()) + .or_else(|| target.get("rootDir")), + )? + .trim(); + let component_root = self.root.join(dir); + let mut detection = self.detection(files, policy)?; + detection["graph"]["custom"] = custom; + detection["devFiles"] = + json!(super::environments::dev_files(self.root, self.context, target, files)?); + let capture: Value = engine.request(&json!({ + "kind": "dependencyDetectFresh", + "root": self.root, + "componentRoot": component_root, + "identity": full_identity(target)?, + "files": files.iter().map(|file| component_root.join(file)).collect::>(), + "detection": detection, + }))?; + let (receipt, grammar_inputs, read_set) = self.receipt(&capture)?; + let mut request = json!({ "kind": "dependencyPolicyDetected", "root": self.root, + "componentRoot": component_root, "capture": receipt }); + super::capture::spread(&mut request, &policy.input)?; + request["resolution"]["grammar"] = + grammar::dependency(&json!([policy.input, grammar_inputs])); + let dependencies: Value = engine.request(&request)?; + let pending = Pending::detected(target, &dependencies, read_set)?; + Ok((dependencies, pending)) + } + + fn receipt(&self, capture: &Value) -> io::Result<(Value, Value, Value)> { + let mut receipt = capture + .as_object() + .cloned() + .ok_or_else(|| decline("status capture"))?; + let grammar_inputs = receipt.remove("grammarInputs").unwrap_or(Value::Null); + if let Some(proofs) = self.context["environmentReadSet"].as_array() { + receipt + .get_mut("readSet") + .and_then(Value::as_array_mut) + .ok_or_else(|| decline("environment proof receipt"))? + .extend(proofs.iter().cloned()); + } + let read_set = receipt + .get("readSet") + .cloned() + .unwrap_or(Value::Null); + Ok((Value::Object(receipt), grammar_inputs, read_set)) + } + + /// `freshDependencyDetection(context, files, policy)`. + pub fn detection(&self, files: &[String], policy: &Policy) -> io::Result { + let workspace = &policy.input["policies"]["workspacePolicy"]; + let field = |name: &str| { + workspace + .get(name) + .cloned() + .unwrap_or_else(|| json!({})) + }; + let components = array(self.context.get("targets"), "targets")? + .iter() + .map(full_identity) + .collect::>>()?; + Ok(json!({ + "graph": self.facts.graph, + "devFiles": files.iter().filter(|file| dev_files::is_dev_file(file)).collect::>(), + "modelPackages": policy.model_packages, + "workspacePolicy": [field("dependencies"), field("devDependencies"), field("peerDependencies")], + "mergePolicy": policy.input["resolution"]["merge"], + "corePackages": self.facts.core_packages, + "coreAspectIds": self.facts.core_aspect_ids, + "workspaceComponents": components, + })) + } +} + +/// `commitFreshDependencies`: every read set still fresh, then the entries saved. +pub fn commit( + engine: &mut Engine, + root: &Path, + pending: &[Pending], + reset: bool, + write_cache: bool, +) -> io::Result<()> { + let proofs = pending + .iter() + .flat_map(Pending::read_set) + .cloned() + .collect::>(); + engine.validate_read_set(&proofs, "status fresh dependencies changed")?; + if !write_cache { + return Ok(()); + } + let cache = bit_object_import::scope_path(root) + .join("cache") + .join("components") + .join("deps"); + // `deleteAllDependenciesDataCache`: the deps cache holds nothing else. + if reset { + match std::fs::remove_dir_all(&cache) { + Err(error) if error.kind() != io::ErrorKind::NotFound => return Err(error), + _ => {} + } + } + for entry in pending.iter().filter(|entry| entry.cacheable) { + bit_install::dependencies::cache::put(&cache, &entry.identity, &entry.raw_data)?; + } + Ok(()) +} diff --git a/native/cli/src/commands/status/fresh/dev_files.rs b/native/cli/src/commands/status/fresh/dev_files.rs new file mode 100644 index 000000000000..c9b7d7df5c01 --- /dev/null +++ b/native/cli/src/commands/status/fresh/dev_files.rs @@ -0,0 +1,100 @@ +//! Whether a component file is a test, docs or composition file under the +//! default dev patterns, as `matchPatterns` decides with `minimatch` 3.0.5. +//! +//! `minimatch(file, '**/')` lets `**` match any number of directories +//! that do not start with a dot, and matches the file name with the [`NAMES`] segment +//! [`Regex`] below (`dot: false`, case-sensitive). + +use regress::Regex; +use std::sync::OnceLock; + +/// The segment regexes `minimatch` compiles for `defaultTestPatterns()`, +/// `defaultDocsPatterns()` and `defaultCompositionsPatterns()`. +const NAMES: [&str; 4] = [ + r"^(?!\.)(?=.)[^/]*?\.spec\.(?:js|ts|jsx|tsx)+$", + r"^(?!\.)(?=.)[^/]*?\.test\.(?:js|ts|jsx|tsx)+$", + r"^(?!\.)(?=.)[^/]*?\.docs\.[^/]*?$", + r"^(?!\.)(?=.)[^/]*?\.composition(?:s)?\.[^/]*?$", +]; + +/// Returns the compiled [`Regex`] slice corresponding to [`NAMES`]. +#[inline] +fn names() -> &'static [Regex] { + static NAMES_RE: OnceLock> = OnceLock::new(); + NAMES_RE.get_or_init(|| { + NAMES + .iter() + .map(|source| Regex::new(source).expect("dev pattern regexes compile")) + .collect() + }) +} + +/// The default dev pattern groups, in `NAMES` order. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Group { + /// `defaultTestPatterns()`: `*.spec.*` and `*.test.*`. + Tests, + /// `defaultDocsPatterns()`. + Docs, + /// `defaultCompositionsPatterns()`. + Compositions, +} + +impl Group { + /// Returns the compiled segment regex slice for this dev-pattern [`Group`]. + #[inline] + fn names(self) -> &'static [Regex] { + let all = names(); + match self { + Self::Tests => &all[0..2], + Self::Docs => &all[2..3], + Self::Compositions => &all[3..4], + } + } +} + +/// `files.filter(file => matchPatterns(file, group))`. +#[must_use] +#[inline] +pub fn group_files(files: &[String], group: Group) -> Vec { + files + .iter() + .map(String::as_str) + .map(|file| file.trim().trim_start_matches("./")) + .filter(|file| matches(file, group.names())) + .map(str::to_owned) + .collect() +} + +/// `file` is a relative path with `/` separators. +#[must_use] +#[inline] +pub fn is_dev_file(file: &str) -> bool { + matches(file, names()) +} + +fn matches(file: &str, regexes: &[Regex]) -> bool { + let file = file.trim().trim_start_matches("./"); + if file.is_empty() { + return false; + } + let normalized = if cfg!(windows) && file.contains('\\') { + std::borrow::Cow::Owned(file.replace('\\', "/")) + } else { + std::borrow::Cow::Borrowed(file) + }; + let path = normalized.strip_suffix('/').unwrap_or(&normalized); + let (dirs, name) = match path.rsplit_once('/') { + Some((dirs, name)) => (Some(dirs), name), + None => (None, path), + }; + if dirs.is_some_and(|dirs| { + dirs.split('/') + .any(|dir| matches!(dir.trim(), "." | "..") || dir.trim().starts_with('.')) + }) { + return false; + } + regexes + .iter() + .any(|regex| regex.find(name).is_some()) +} diff --git a/native/cli/src/commands/status/fresh/fresh_tests.rs b/native/cli/src/commands/status/fresh/fresh_tests.rs new file mode 100644 index 000000000000..2ac83281b33b --- /dev/null +++ b/native/cli/src/commands/status/fresh/fresh_tests.rs @@ -0,0 +1,30 @@ +//! Unit tests for default dev-file pattern matching ([`is_dev_file`]) and core aspect package naming ([`core_package_name`]) against [`MINIMATCH`]. +//! +//! Verifies `minimatch` 3.0.5 fixture parity and `@teambit/` package name derivation. + +use super::{core_package_name, dev_files::is_dev_file}; + +/// `[path, matchPatterns(path, defaults)]` from `minimatch` 3.0.5. +const MINIMATCH: &str = include_str!("minimatch_3_0_5.json"); + +/// Verifies [`is_dev_file`] matches every entry in [`MINIMATCH`]. +#[test] +fn dev_files_match_minimatch() { + let rows: Vec<(String, bool)> = serde_json::from_str(MINIMATCH).unwrap(); + for (path, expected) in rows { + assert_eq!(is_dev_file(&path), expected, "{path:?}"); + } +} + +/// Verifies that [`core_package_name`] converts `teambit.*` aspect IDs into `@teambit/*` package names. +#[test] +fn core_package_names() { + assert_eq!(core_package_name("teambit.workspace/workspace"), "@teambit/workspace"); + assert_eq!(core_package_name("teambit.dependencies/pnpm"), "@teambit/pnpm"); + assert_eq!(core_package_name("teambit.a/b/c"), "@teambit/b.c"); +} + +#[test] +fn ordinary_source_file_is_not_classified_as_dev_file() { + assert!(!is_dev_file("src/index.ts")); +} diff --git a/native/cli/src/commands/status/fresh/minimatch_3_0_5.json b/native/cli/src/commands/status/fresh/minimatch_3_0_5.json new file mode 100644 index 000000000000..4e3f145ac690 --- /dev/null +++ b/native/cli/src/commands/status/fresh/minimatch_3_0_5.json @@ -0,0 +1 @@ +[["a.spec.ts",true],["a.spec.tsx",true],["a.spec.jsx",true],["a.spec.mjs",false],["a.spec.tsts",true],["a.test.js",true],["a.tests.js",false],[".a.spec.ts",false],["spec.ts",false],[".spec.ts",false],["a.spec.",false],["a.docs.mdx",true],["a.docs.",true],["docs.md",false],["x.docs.md.bak",true],["a.composition.tsx",true],["a.compositions.tsx",true],["a.compositionss.tsx",false],["a.composition.",true],["A.SPEC.TS",false],["a.b.spec.ts",true],["a.spec.ts.map",false],["index.ts",false],["a.spec.jsts",true],["a.spec.ts/",true],["é.spec.ts",true],["a spec.ts",false],["a.spec .ts",false],["x/a.spec.ts",true],["x/a.spec.tsx",true],["x/a.spec.jsx",true],["x/a.spec.mjs",false],["x/a.spec.tsts",true],["x/a.test.js",true],["x/a.tests.js",false],["x/.a.spec.ts",false],["x/spec.ts",false],["x/.spec.ts",false],["x/a.spec.",false],["x/a.docs.mdx",true],["x/a.docs.",true],["x/docs.md",false],["x/x.docs.md.bak",true],["x/a.composition.tsx",true],["x/a.compositions.tsx",true],["x/a.compositionss.tsx",false],["x/a.composition.",true],["x/A.SPEC.TS",false],["x/a.b.spec.ts",true],["x/a.spec.ts.map",false],["x/index.ts",false],["x/a.spec.jsts",true],["x/a.spec.ts/",true],["x/é.spec.ts",true],["x/a spec.ts",false],["x/a.spec .ts",false],["x/y/a.spec.ts",true],["x/y/a.spec.tsx",true],["x/y/a.spec.jsx",true],["x/y/a.spec.mjs",false],["x/y/a.spec.tsts",true],["x/y/a.test.js",true],["x/y/a.tests.js",false],["x/y/.a.spec.ts",false],["x/y/spec.ts",false],["x/y/.spec.ts",false],["x/y/a.spec.",false],["x/y/a.docs.mdx",true],["x/y/a.docs.",true],["x/y/docs.md",false],["x/y/x.docs.md.bak",true],["x/y/a.composition.tsx",true],["x/y/a.compositions.tsx",true],["x/y/a.compositionss.tsx",false],["x/y/a.composition.",true],["x/y/A.SPEC.TS",false],["x/y/a.b.spec.ts",true],["x/y/a.spec.ts.map",false],["x/y/index.ts",false],["x/y/a.spec.jsts",true],["x/y/a.spec.ts/",true],["x/y/é.spec.ts",true],["x/y/a spec.ts",false],["x/y/a.spec .ts",false],[".x/a.spec.ts",false],[".x/a.spec.tsx",false],[".x/a.spec.jsx",false],[".x/a.spec.mjs",false],[".x/a.spec.tsts",false],[".x/a.test.js",false],[".x/a.tests.js",false],[".x/.a.spec.ts",false],[".x/spec.ts",false],[".x/.spec.ts",false],[".x/a.spec.",false],[".x/a.docs.mdx",false],[".x/a.docs.",false],[".x/docs.md",false],[".x/x.docs.md.bak",false],[".x/a.composition.tsx",false],[".x/a.compositions.tsx",false],[".x/a.compositionss.tsx",false],[".x/a.composition.",false],[".x/A.SPEC.TS",false],[".x/a.b.spec.ts",false],[".x/a.spec.ts.map",false],[".x/index.ts",false],[".x/a.spec.jsts",false],[".x/a.spec.ts/",false],[".x/é.spec.ts",false],[".x/a spec.ts",false],[".x/a.spec .ts",false],["x/.y/a.spec.ts",false],["x/.y/a.spec.tsx",false],["x/.y/a.spec.jsx",false],["x/.y/a.spec.mjs",false],["x/.y/a.spec.tsts",false],["x/.y/a.test.js",false],["x/.y/a.tests.js",false],["x/.y/.a.spec.ts",false],["x/.y/spec.ts",false],["x/.y/.spec.ts",false],["x/.y/a.spec.",false],["x/.y/a.docs.mdx",false],["x/.y/a.docs.",false],["x/.y/docs.md",false],["x/.y/x.docs.md.bak",false],["x/.y/a.composition.tsx",false],["x/.y/a.compositions.tsx",false],["x/.y/a.compositionss.tsx",false],["x/.y/a.composition.",false],["x/.y/A.SPEC.TS",false],["x/.y/a.b.spec.ts",false],["x/.y/a.spec.ts.map",false],["x/.y/index.ts",false],["x/.y/a.spec.jsts",false],["x/.y/a.spec.ts/",false],["x/.y/é.spec.ts",false],["x/.y/a spec.ts",false],["x/.y/a.spec .ts",false],["x//a.spec.ts",true],["x//a.spec.tsx",true],["x//a.spec.jsx",true],["x//a.spec.mjs",false],["x//a.spec.tsts",true],["x//a.test.js",true],["x//a.tests.js",false],["x//.a.spec.ts",false],["x//spec.ts",false],["x//.spec.ts",false],["x//a.spec.",false],["x//a.docs.mdx",true],["x//a.docs.",true],["x//docs.md",false],["x//x.docs.md.bak",true],["x//a.composition.tsx",true],["x//a.compositions.tsx",true],["x//a.compositionss.tsx",false],["x//a.composition.",true],["x//A.SPEC.TS",false],["x//a.b.spec.ts",true],["x//a.spec.ts.map",false],["x//index.ts",false],["x//a.spec.jsts",true],["x//a.spec.ts/",true],["x//é.spec.ts",true],["x//a spec.ts",false],["x//a.spec .ts",false],["_a/a.spec.ts",true],["_a/a.spec.tsx",true],["_a/a.spec.jsx",true],["_a/a.spec.mjs",false],["_a/a.spec.tsts",true],["_a/a.test.js",true],["_a/a.tests.js",false],["_a/.a.spec.ts",false],["_a/spec.ts",false],["_a/.spec.ts",false],["_a/a.spec.",false],["_a/a.docs.mdx",true],["_a/a.docs.",true],["_a/docs.md",false],["_a/x.docs.md.bak",true],["_a/a.composition.tsx",true],["_a/a.compositions.tsx",true],["_a/a.compositionss.tsx",false],["_a/a.composition.",true],["_a/A.SPEC.TS",false],["_a/a.b.spec.ts",true],["_a/a.spec.ts.map",false],["_a/index.ts",false],["_a/a.spec.jsts",true],["_a/a.spec.ts/",true],["_a/é.spec.ts",true],["_a/a spec.ts",false],["_a/a.spec .ts",false],["node_modules/a.spec.ts",true],["node_modules/a.spec.tsx",true],["node_modules/a.spec.jsx",true],["node_modules/a.spec.mjs",false],["node_modules/a.spec.tsts",true],["node_modules/a.test.js",true],["node_modules/a.tests.js",false],["node_modules/.a.spec.ts",false],["node_modules/spec.ts",false],["node_modules/.spec.ts",false],["node_modules/a.spec.",false],["node_modules/a.docs.mdx",true],["node_modules/a.docs.",true],["node_modules/docs.md",false],["node_modules/x.docs.md.bak",true],["node_modules/a.composition.tsx",true],["node_modules/a.compositions.tsx",true],["node_modules/a.compositionss.tsx",false],["node_modules/a.composition.",true],["node_modules/A.SPEC.TS",false],["node_modules/a.b.spec.ts",true],["node_modules/a.spec.ts.map",false],["node_modules/index.ts",false],["node_modules/a.spec.jsts",true],["node_modules/a.spec.ts/",true],["node_modules/é.spec.ts",true],["node_modules/a spec.ts",false],["node_modules/a.spec .ts",false]] \ No newline at end of file diff --git a/native/cli/src/commands/status/fresh/node.rs b/native/cli/src/commands/status/fresh/node.rs new file mode 100644 index 000000000000..cc77f261b0ef --- /dev/null +++ b/native/cli/src/commands/status/fresh/node.rs @@ -0,0 +1,167 @@ +//! Facts about the Node.js CLI ([`Node`] / [`NodeCli`]) that dependency detection depends on: the +//! built-in modules of the Node it runs on, and the `TypeScript` version its +//! import lookups bundle. + +use super::super::{ + cache::{cache_root, hex}, + js::decline, +}; +use crate::discovery::{self, NodeCli, Target}; +use sha1::{Digest, Sha1}; +use std::{ + env, fs, io, + path::{Path, PathBuf}, + process::Command, + time::UNIX_EPOCH, +}; + +/// Inline Node.js one-liner that writes `require('module').builtinModules` as JSON to stdout. +const BUILTINS_SCRIPT: &str = + "process.stdout.write(JSON.stringify(require('module').builtinModules))"; + +/// Handle to the discovered Node.js CLI runtime used for built-in module and `TypeScript` version queries. +pub struct Node { + cli: NodeCli, +} + +impl Node { + /// Discovers the Node.js CLI runtime when backed by an installed Bit distribution directory. + pub fn find() -> io::Result { + match discovery::find()? { + Target::Entry(cli) if cli.bit_dir.is_some() => Ok(Self { cli }), + _ => Err(decline("status needs the Node.js CLI's own Node")), + } + } + + /// `builtinNames`: `builtinModules`, each also with the `node:` prefix. + /// Node is asked once per binary; the answer is cached by path, size and time. + pub fn builtins(&self) -> io::Result> { + let binary = self.binary()?; + let metadata = fs::metadata(&binary)?; + let modified = metadata + .modified()? + .duration_since(UNIX_EPOCH) + .map_err(io::Error::other)?; + let key = format!("{}\n{}\n{}", binary.display(), metadata.len(), modified.as_nanos()); + let digest = hex(&Sha1::digest(key.as_bytes())); + let cached = cache_root() + .join("native-cli") + .join(format!("node-builtins-{digest}.json")); + if let Some(names) = fs::read(&cached) + .ok() + .and_then(|bytes| serde_json::from_slice(&bytes).ok()) + { + return Ok(names); + } + let output = Command::new(&binary) + .arg("-e") + .arg(BUILTINS_SCRIPT) + .output()?; + if !output.status.success() { + return Err(decline("node builtinModules")); + } + let modules: Vec = + serde_json::from_slice(&output.stdout).map_err(io::Error::other)?; + let names = builtin_names(&modules); + let _ = write_atomic(&cached, &serde_json::to_vec(&names)?); + Ok(names) + } + + /// The Node binary, resolved through `PATH` when discovery left it bare. + fn binary(&self) -> io::Result { + let node = &self.cli.node; + if node.is_absolute() || (node.components().count() > 1 && node.is_file()) { + return Ok(node.clone()); + } + let names: &[&str] = if cfg!(windows) { &["node.exe", "node"] } else { &["node"] }; + env::var_os("PATH") + .and_then(|path| { + env::split_paths(&path) + .filter(|dir| !dir.as_os_str().is_empty()) + .find_map(|dir| { + names + .iter() + .map(|name| dir.join(name)) + .find(|candidate| candidate.is_file()) + }) + }) + .ok_or_else(|| decline("node is not on PATH")) + } + + /// `typescript/package.json` as `@teambit/typescript.deps-lookups.lookup-typescript` + /// resolves it from the `@teambit/bit` package. + pub fn typescript_version(&self) -> io::Result> { + let Some(bit_dir) = &self.cli.bit_dir else { return Ok(None) }; + let from = fs::canonicalize(bit_dir)?.join("dist"); + let Some(lookup) = + resolve_package(&from, "@teambit/typescript.deps-lookups.lookup-typescript") + else { + return Ok(None); + }; + let lookup = fs::canonicalize(lookup)?; + let Some(typescript) = resolve_package(&lookup, "typescript") else { return Ok(None) }; + let manifest: serde_json::Value = + bit_object_import::parse_jsonc(&fs::read(typescript.join("package.json"))?) + .map_err(io::Error::other)?; + Ok(manifest + .get("version") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|version| !version.is_empty()) + .map(str::to_string)) + } +} + +/// Node's `node_modules` lookup: the closest `/node_modules/`. +#[inline] +fn resolve_package(from: &Path, name: &str) -> Option { + let name = name.trim(); + if name.is_empty() { + return None; + } + from.ancestors() + .map(|dir| dir.join("node_modules").join(name)) + .find(|dir| dir.join("package.json").is_file()) +} + +#[inline] +fn builtin_names(modules: &[String]) -> Vec { + let mut names: Vec = Vec::with_capacity(modules.len() * 2); + for raw_module in modules { + let module = raw_module.trim(); + if !module.is_empty() { + push_module_forms(&mut names, module); + } + } + names +} + +#[inline] +fn push_module_forms(names: &mut Vec, module: &str) { + let bare = module.strip_prefix("node:").map_or(module, str::trim); + if bare.is_empty() { + return; + } + if !module.starts_with("node:") + && !names + .iter() + .any(|existing| existing == bare) + { + names.push(bare.to_owned()); + } + let prefixed = format!("node:{bare}"); + if !names.contains(&prefixed) { + names.push(prefixed); + } +} + +fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> { + use std::sync::atomic::{AtomicU64, Ordering}; + static COUNTER: AtomicU64 = AtomicU64::new(0); + let parent = path.parent().ok_or_else(|| decline("cache path"))?; + fs::create_dir_all(parent)?; + let serial = COUNTER.fetch_add(1, Ordering::Relaxed); + let temporary = parent.join(format!(".{}-{serial}.tmp", std::process::id())); + fs::write(&temporary, bytes)?; + fs::rename(&temporary, path) +} diff --git a/native/cli/src/commands/status/grammar.rs b/native/cli/src/commands/status/grammar.rs new file mode 100644 index 000000000000..5f931a4d518c --- /dev/null +++ b/native/cli/src/commands/status/grammar.rs @@ -0,0 +1,151 @@ +//! The npm `semver` facts the status engine asks for, computed with +//! [`npm`], the port of npm `semver` 7.7.1's strict functions. +//! +//! [`snapshot`] serializes canonical version text and [`Identifier`] components into a [`Map`]-backed [`Value`]. + +use node_semver::npm::{self, Identifier}; +use serde_json::{Map, Value, json}; + +/// Computes `snapshotSemverGrammar(values)`: canonical version text and parsed parts for each valid version. +pub fn snapshot<'v>(values: impl IntoIterator) -> Option { + let mut result = Map::new(); + for text in values { + if result.contains_key(text) { + continue; + } + if result.len() >= 4096 || text.encode_utf16().count() > 4096 { + return None; + } + result.insert(text.to_string(), snapshot_entry(text)); + } + Some(Value::Object(result)) +} + +/// Formats the canonical semver grammar entry for a single version string `text`. +#[inline] +fn snapshot_entry(text: &str) -> Value { + let Some(version) = npm::parse(text.trim()) else { return json!({}) }; + json!({ + "canonicalText": version.version, + "version": version_parts(&version), + }) +} + +/// `dependencyGrammar(values)`: `valid`, `validRange` and `coerce` of every +/// string anywhere in `values`. +#[must_use] +pub fn dependency(values: &Value) -> Value { + let mut strings = Vec::new(); + visit(values, &mut strings); + let mut result = Map::new(); + for text in strings { + if result.contains_key(text) || text.encode_utf16().count() > 4096 { + continue; + } + if result.len() >= 16384 { + break; + } + let trimmed = text.trim(); + let coerced = npm::coerce(trimmed).map(|version| version.version); + let entry = json!({ + "valid": npm::valid(trimmed).is_some(), + "range": npm::valid_range(trimmed).is_some(), + "coerced": coerced, + }); + result.insert(text.to_string(), entry); + } + Value::Object(result) +} + +#[inline] +fn visit<'v>(value: &'v Value, strings: &mut Vec<&'v str>) { + match value { + Value::String(text) => strings.push(text), + Value::Array(items) => items.iter().for_each(|item| visit(item, strings)), + Value::Object(map) => map.values().for_each(|item| visit(item, strings)), + _ => {} + } +} + +/// `normalizeSemverInputs` of `@teambit/config-merger` over every string in +/// `values`: the parsed version, the comparator sets and the minimum of +/// each. `None` for values outside the ported grammar. +#[must_use] +pub fn config_inputs(values: &Value) -> Option { + let mut strings = Vec::new(); + visit(values, &mut strings); + let mut seen = std::collections::HashSet::with_capacity(strings.len()); + let mut unique: Vec<&str> = Vec::with_capacity(strings.len()); + for text in strings { + if seen.insert(text) { + unique.push(text); + } + } + let bytes: usize = unique + .iter() + .map(|text| text.len()) + .sum(); + if unique.len() > 4096 + || bytes > 4 * 1024 * 1024 + || unique + .iter() + .any(|text| text.encode_utf16().count() > 4096) + { + return None; + } + let mut result = Map::new(); + for text in unique { + result.insert(text.to_string(), config_input(text)?); + } + Some(Value::Object(result)) +} + +/// One comparator set: each comparator's operator and version parts. +#[inline] +fn comparator_set(set: &[npm::Comparator]) -> Value { + let comparators = set + .iter() + .map(|comparator| { + let mut row = json!({ "operator": comparator.operator }); + if let Some(version) = &comparator.version { + row["version"] = version_parts(version); + } + row + }); + Value::Array(comparators.collect()) +} + +#[inline] +fn config_input(text: &str) -> Option { + let text = text.trim(); + let mut input = Map::new(); + if let Some(version) = npm::parse(text) { + input.insert("version".into(), version_parts(&version)); + } + if npm::valid_range(text).is_some() { + let sets = npm::range_set(text)?; + let range: Vec = sets + .iter() + .map(|set| comparator_set(set)) + .collect(); + input.insert("range".into(), Value::Array(range)); + if let Some(minimum) = npm::min_version(text).ok()? { + input.insert("minimum".into(), version_parts(&minimum)); + input.insert("minimumText".into(), json!(minimum.version)); + } + } + Some(Value::Object(input)) +} + +/// `{ major, minor, patch, prerelease }` of a parsed version. +#[inline] +fn version_parts(version: &npm::Version) -> Value { + let prerelease: Vec = version.prerelease + .iter() + .map(|id| match id { + Identifier::Numeric(number) => json!(number), + Identifier::Alphanumeric(text) => json!(text), + }) + .collect(); + json!({ "major": version.major, "minor": version.minor, "patch": version.patch, "prerelease": prerelease }) +} diff --git a/native/cli/src/commands/status/issues.rs b/native/cli/src/commands/status/issues.rs new file mode 100644 index 000000000000..766e2637f8cf --- /dev/null +++ b/native/cli/src/commands/status/issues.rs @@ -0,0 +1,364 @@ +//! The issues status adds to what dependency detection found, formatted as +//! `IssuesList.toObjectIncludeDataAsString()` formats them. +//! +//! Coordinates [`callbacks`], [`cycles`], [`deprecated`], [`detected`], [`format`], [`kinds`], [`removed`], and [`resolve`] into [`Issue`] sets. + +mod callbacks; +mod cycles; +mod deprecated; +mod detected; +mod format; +mod kinds; +mod removed; +mod resolve; +#[cfg(test)] +mod tests; + +use super::js::{array, decline, nullish, text, truthy, without_version}; +use serde_json::{Value, json}; +use std::{ + collections::HashSet, + io, + path::{Path, PathBuf}, +}; + +use cycles::cyclic; +use kinds::Issue; + +/// Aspect ID for the dependency resolver extension (`teambit.dependencies/dependency-resolver`). +const RESOLVER: &str = "teambit.dependencies/dependency-resolver"; +/// Aspect ID for the component issues extension (`teambit.component/issues`). +const ISSUES: &str = "teambit.component/issues"; +/// `ISSUE_FORMAT_SPACE`. +const SPACE: &str = " "; + +/// One captured component: its full identity and its detection results. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct Component { + /// Per-component `IssuesMain` settings from the fully merged aspect config. + pub ignored: Vec, + pub identity: String, + pub package_name: String, + pub dependencies: Vec, + pub issues: Value, +} + +/// Validate a public component-issue class name against the released native catalog ([`collect`], [`collect_resolved`]). +#[inline] +pub(in crate::commands) fn validate_name(name: &str) -> io::Result<()> { + Issue::parse(name).map(|_| ()) +} + +/// `None` where the native status declines: a dependency outside +/// the workspace, or a model whose head is deprecated. +pub fn collect( + root: &Path, + context: &Value, + components: &[Component], +) -> io::Result>> { + if !ordinary_model_heads(context)? { + return Ok(None); + } + collect_graph(root, context, components, components) +} + +/// The status issues with the resolved issue heads: the dependencies +/// outside the workspace are the engine's external vertices (`issueGraph`), +/// and the models' heads are the engine's proofs. +pub fn collect_resolved( + root: &Path, + context: &Value, + components: &[Component], + heads: &Value, + issue_graph: Option<&Value>, +) -> io::Result>> { + let Some(external) = resolve::external_vertices(components, issue_graph) else { + return Ok(None); + }; + let mut graph = Vec::with_capacity(components.len() + external.len()); + graph.extend_from_slice(components); + graph.extend(external); + if !resolve::ordinary_heads_for(context, heads, Some(&graph)) { + return Ok(None); + } + collect_graph(root, context, components, &graph) +} + +/// The issues of `components`, whose dependencies are all in `graph`. +pub(super) fn collect_graph( + root: &Path, + context: &Value, + components: &[Component], + graph: &[Component], +) -> io::Result>> { + let identities: HashSet<&str> = graph + .iter() + .map(|component| component.identity.as_str()) + .collect(); + let outside = components + .iter() + .any(|component| { + component.dependencies + .iter() + .any(|id| !identities.contains(id.as_str())) + }); + if outside { + return Ok(None); + } + let resolver = nullish( + context + .get("workspace") + .and_then(|workspace| workspace.get(RESOLVER)), + ); + let filtered_graph = cycles::filter_graph_for_cycles(root, components, graph); + let circular = if context["ignoreCircularDependencies"] == true { + HashSet::new() + } else { + cyclic(&filtered_graph) + }; + let checks = Checks { + root, + context, + targets: array(context.get("targets"), "targets")?, + packages: workspace_packages(context, resolver), + root_components: truthy(resolver.and_then(|resolver| resolver.get("rootComponents"))), + circular, + ignored: ignored(context)?, + }; + components + .iter() + .map(|component| checks.issues(component)) + .collect::>>() + .map(Some) +} + +/// What the per-component checks share. +struct Checks<'c> { + root: &'c Path, + context: &'c Value, + targets: &'c [Value], + packages: HashSet<&'c str>, + root_components: bool, + circular: HashSet<&'c str>, + /// The issue types `removeIgnoredIssuesFromComponents` removes. + ignored: HashSet<&'c str>, +} + +impl Checks<'_> { + /// `{ id, issues }` of one component. + fn missing_dists(&self, component: &Component, target: &Value) -> io::Result { + let projected = super::policy::environment_target(self.context, target)?; + let env = crate::commands::update_cmd::aspect_entry( + &projected["config"]["extensions"], + "teambit.envs/envs", + ) + .and_then(|(_, val)| val["env"].as_str()) + .map(str::trim) + .filter(|env| !env.is_empty()) + .unwrap_or("teambit.harmony/node"); + let module = runtime_module_path(self.root, component, self.root_components, env); + let compiler = + &self.context["environmentFacts"][without_version(&component.identity)]["compiler"]; + let dist = compiler["distDir"] + .as_str() + .map(str::trim) + .filter(|dir| !dir.is_empty()) + .unwrap_or("dist"); + Ok(compiler["hasCompiler"] != false && !exists(&module.join(dist))?) + } + + fn issues(&self, component: &Component) -> io::Result { + let identity = without_version(&component.identity); + let target = self.targets + .iter() + .find(|row| { + row.get("identity") + .and_then(Value::as_str) + .map(str::trim) + == Some(identity) + }) + .ok_or_else(|| decline("status issue target missing"))?; + let dir = text( + target + .get("directory") + .filter(|val| !val.is_null()) + .or_else(|| target.get("rootDir")), + )? + .trim() + .trim_start_matches("./") + .trim_end_matches('/'); + let mut ignored = self.ignored.clone(); + for name in &component.ignored { + Issue::parse(name)?; + ignored.insert(name.as_str()); + } + let mut issues = deserialize(&component.issues, &ignored)?; + self.append_computed_issues(component, target, !dir.is_empty() && dir != ".", &mut issues)?; + issue_object(identity, &issues, &ignored, component) + } + + fn append_computed_issues( + &self, + component: &Component, + target: &Value, + nested: bool, + issues: &mut Vec<(Issue, Value)>, + ) -> io::Result<()> { + if nested + && !self.root + .join("node_modules") + .join(&component.package_name) + .exists() + { + set(issues, Issue::MissingLinks, json!(true)); + } + if self.missing_dists(component, target)? { + set(issues, Issue::MissingDists, json!(true)); + } + if nested && self.packages.contains(component.package_name.as_str()) { + set(issues, Issue::Duplicate, json!(component.package_name)); + } + if self.circular.contains(component.identity.as_str()) { + set(issues, Issue::Circular, json!(true)); + } + for (kind, entries) in [ + (Issue::RemovedDependencies, self.removed_dependencies(component, issues)?), + (Issue::DeprecatedDependencies, self.deprecated_dependencies(component)?), + ] { + if !entries.is_empty() { + set(issues, kind, json!(entries)); + } + } + Ok(()) + } +} + +fn issue_object( + identity: &str, + issues: &[(Issue, Value)], + ignored: &HashSet<&str>, + component: &Component, +) -> io::Result { + let mut objects = issues + .iter() + .filter(|(kind, _)| !ignored.contains(kind.class().0)) + .map(|(kind, data)| kind.to_object(data)) + .collect::>>()?; + callbacks::merge(&mut objects, &component.issues, ignored)?; + Ok(json!({ "id": identity, "issues": objects })) +} + +/// `getIssuesToIgnoreGlobally`: `ignoreIssues` of `teambit.component/issues` in the +/// `workspace.jsonc` the context carries, which the engine validated. +fn ignored(context: &Value) -> io::Result> { + let workspace = context + .get("workspace") + .ok_or_else(|| decline("status issues without the workspace configuration"))?; + let Some(names) = crate::commands::update_cmd::aspect_entry(workspace, ISSUES) + .and_then(|(_, issues)| issues.get("ignoreIssues")) + else { + return Ok(HashSet::new()); + }; + array(Some(names), "ignoreIssues")? + .iter() + .map(|name| text(Some(name)).map(str::trim)) + .collect() +} + +/// Model head metadata was captured and verified by the Rust repository reader. +#[inline] +pub(super) fn ordinary_model_heads(context: &Value) -> io::Result { + array(context.get("models"), "models")?; + array(context.get("versions"), "versions")?; + Ok(true) +} + +/// Package names the workspace policy and root `package.json` declare. +fn workspace_packages<'c>(context: &'c Value, resolver: Option<&'c Value>) -> HashSet<&'c str> { + let keys = |object: Option<&'c Value>, fields: &[&str]| -> Vec<&'c str> { + fields + .iter() + .filter_map(|field| { + object + .and_then(|o| o.get(*field)) + .and_then(Value::as_object) + }) + .flat_map(|map| map.keys().map(String::as_str)) + .collect() + }; + let policy = nullish(resolver.and_then(|r| r.get("policy"))); + let package_json = nullish(context.get("packageJson")); + let mut packages: HashSet<&str> = + keys(policy, &["dependencies", "peerDependencies"]).into_iter().collect(); + packages.extend(keys(package_json, &["dependencies", "devDependencies", "peerDependencies"])); + packages +} + +/// `runtimeModulePath`: with root components, the component's own root (or +/// the Node environment's root) when it holds the package. +fn runtime_module_path( + root: &Path, + component: &Component, + root_components: bool, + env: &str, +) -> PathBuf { + let ordinary = root.join("node_modules").join(&component.package_name); + if !root_components { + return ordinary; + } + let roots = root.join("node_modules").join(".bit_roots"); + let own = roots.join(without_version(&component.identity).replace('/', "_")); + let selected = + if own.exists() { own } else { roots.join(without_version(env).replace('/', "_")) }; + let inner = selected.join("node_modules").join(&component.package_name); + if inner.exists() { inner } else { ordinary } +} + +/// `fs.promises.access`: missing paths are `false`, other errors decline. +#[inline] +fn exists(path: &Path) -> io::Result { + match path.metadata() { + Ok(_) => Ok(true), + Err(error) + if matches!(error.kind(), io::ErrorKind::NotFound | io::ErrorKind::NotADirectory) => + { + Ok(false) + } + Err(error) => Err(error), + } +} + +/// `IssuesList.deserialize`: `[{ type, data: JSON string }]`, without the +/// `ignored` types. +fn deserialize(value: &Value, ignored: &HashSet<&str>) -> io::Result> { + let Some(rows) = value.as_array() else { return Ok(Vec::new()) }; + rows.iter() + .filter(|row| row.get("callbackObject").is_none()) + .filter(|row| { + !row.get("type") + .and_then(Value::as_str) + .is_some_and(|kind| ignored.contains(kind)) + }) + .map(|row| { + let kind = Issue::parse(text(row.get("type"))?)?; + let data = serde_json::from_str(text(row.get("data"))?).map_err(io::Error::other)?; + let data = match kind { + Issue::Detected(kind) => kind.deserialize(data)?, + _ => data, + }; + Ok((kind, data)) + }) + .collect() +} + +/// `issues.getOrCreate(Class).data = data`. +#[inline] +fn set(issues: &mut Vec<(Issue, Value)>, kind: Issue, data: Value) { + match issues + .iter_mut() + .find(|(known, _)| *known == kind) + { + Some((_, existing)) => *existing = data, + None => issues.push((kind, data)), + } +} diff --git a/native/cli/src/commands/status/issues/callbacks.rs b/native/cli/src/commands/status/issues/callbacks.rs new file mode 100644 index 000000000000..d74eff3825b8 --- /dev/null +++ b/native/cli/src/commands/status/issues/callbacks.rs @@ -0,0 +1,76 @@ +//! Fully formatted issue objects supplied by installed Harmony callbacks. +//! +//! [`merge`] deduplicates and appends aspect callback issue [`Value`] records against [`HashSet`] ignore filters. + +#[cfg(test)] +mod tests; + +use super::{decline, text}; +use serde_json::Value; +use std::{collections::HashSet, io}; + +/// Merges Harmony callback issue objects from `raw` into `issues`, skipping types listed in `ignored`. +pub(super) fn merge( + issues: &mut Vec, + raw: &Value, + ignored: &HashSet<&str>, +) -> io::Result<()> { + let Some(raw) = raw + .as_array() + .filter(|arr| !arr.is_empty()) + else { + return Ok(()); + }; + let mut insertion = raw + .iter() + .filter_map(|row| { + row.get("type") + .and_then(Value::as_str) + .map(str::trim) + }) + .filter(|kind| { + issues + .iter() + .any(|issue| issue["type"].as_str().map(str::trim) == Some(*kind)) + }) + .count(); + for object in raw + .iter() + .filter_map(|row| row.get("callbackObject")) + { + let kind = validate_callback_object(object)?; + if ignored.contains(kind) { + continue; + } + if let Some(known) = issues + .iter_mut() + .find(|issue| issue["type"].as_str().map(str::trim) == Some(kind)) + { + *known = object.clone(); + } else { + issues.insert(insertion, object.clone()); + insertion += 1; + } + } + Ok(()) +} + +/// Validates that `object` is a well-formed Harmony callback issue object and returns its `type` string. +#[inline] +fn validate_callback_object(object: &Value) -> io::Result<&str> { + if !object.is_object() { + return Err(decline("Harmony callback issue object")); + } + let kind = text(object.get("type"))?.trim(); + if kind.is_empty() { + return Err(decline("Harmony callback issue type")); + } + let valid_data = object + .get("dataAsString") + .and_then(Value::as_str) + .is_some_and(|data| !data.contains('\u{1b}')); + if !valid_data { + return Err(decline("Harmony callback issue object")); + } + Ok(kind) +} diff --git a/native/cli/src/commands/status/issues/callbacks/tests.rs b/native/cli/src/commands/status/issues/callbacks/tests.rs new file mode 100644 index 000000000000..40a7f9543c48 --- /dev/null +++ b/native/cli/src/commands/status/issues/callbacks/tests.rs @@ -0,0 +1,35 @@ +//! Unit tests for merging ([`merge`]) plugin callback issue objects into native component status issues. +//! +//! Verifies replacement of existing issue types, custom plugin insertion order, and ignored issue filtering. + +use super::merge; +use serde_json::json; +use std::collections::HashSet; + +/// Verifies [`merge`] replaces existing issue types, orders custom plugin issues before later checks, and filters ignored issues. +#[test] +fn callbacks_replace_existing_types_and_precede_later_native_checks() { + let replacement = json!({"type":"ParseErrors","dataAsString":"callback parser details","data":{"a.js":"error"}}); + let custom = + json!({"type":"CustomPluginIssue","dataAsString":"plugin details","isTagBlocker":false}); + let ignored = json!({"type":"IgnoredPluginIssue","dataAsString":"ignored"}); + let raw = json!([ + {"type":"ParseErrors","data":"{}"}, + {"callbackObject":custom}, + {"callbackObject":replacement}, + {"callbackObject":ignored} + ]); + let later = json!({"type":"MissingDists","data":true}); + let mut issues = vec![json!({"type":"ParseErrors","data":{}}), later.clone()]; + merge(&mut issues, &raw, &HashSet::from(["IgnoredPluginIssue"])).unwrap(); + assert_eq!(issues, vec![replacement, custom, later]); +} + +/// Verifies that [`merge`] leaves existing component issues untouched when the callback array is empty. +#[test] +fn empty_callback_array_leaves_existing_issues_unchanged() { + let mut issues = vec![json!({"type":"MissingDists","data":true})]; + let before = issues.clone(); + merge(&mut issues, &json!([]), &HashSet::new()).unwrap(); + assert_eq!(issues, before); +} diff --git a/native/cli/src/commands/status/issues/cycles.rs b/native/cli/src/commands/status/issues/cycles.rs new file mode 100644 index 000000000000..56d4940f6693 --- /dev/null +++ b/native/cli/src/commands/status/issues/cycles.rs @@ -0,0 +1,217 @@ +//! Dependency cycles among the captured components (`Graph.findCycles()`), +//! found with Tarjan's algorithm. +//! +//! [`filter_graph_for_cycles`] and cycle detection helpers inspect [`Component`] dependency edges across [`Path`] roots. + +use super::Component; +use std::{ + collections::{HashMap, HashSet}, + path::Path, +}; + +/// Prunes non-workspace dependency edges from `graph` before running Tarjan's cycle detection for `components`. +pub(super) fn filter_graph_for_cycles( + root: &Path, + components: &[Component], + graph: &[Component], +) -> Vec { + let workspace_ids: HashSet<&str> = components + .iter() + .map(|component| component.identity.trim()) + .collect(); + let unresolved: HashSet<&str> = components + .iter() + .filter(|component| has_unresolved_deps(root, component)) + .map(|component| component.identity.trim()) + .collect(); + graph + .iter() + .map(|component| { + if workspace_ids.contains(component.identity.trim()) { + return component.clone(); + } + let mut ext = component.clone(); + ext.dependencies.retain(|dep| { + let dep = dep.trim(); + !workspace_ids.contains(dep) || unresolved.contains(dep) + }); + ext + }) + .collect() +} + +/// Returns `true` when `component`'s `node_modules` link is missing or its detected issues include unresolved dependencies. +#[inline] +fn has_unresolved_deps(root: &Path, component: &Component) -> bool { + let package_name = component.package_name.trim(); + if !package_name.is_empty() + && !root + .join("node_modules") + .join(package_name) + .exists() + { + return true; + } + component.issues + .as_array() + .is_some_and(|rows| { + rows.iter() + .any(|row| { + matches!( + row["type"].as_str(), + Some( + "MissingPackagesDependenciesOnFs" + | "MissingDependenciesOnFs" + | "UntrackedDependencies" + | "ParseErrors" + | "ResolveErrors", + ), + ) + }) + }) +} + +/// Components in a dependency cycle, self-dependencies included: the +/// members of strongly connected components larger than one node. +#[must_use] +pub fn cyclic(components: &[Component]) -> HashSet<&str> { + let mut index: HashMap<&str, usize> = HashMap::with_capacity(components.len() * 2); + for (position, component) in components.iter().enumerate() { + let raw = component.identity.as_str().trim(); + if raw.is_empty() { + continue; + } + index.insert(raw, position); + index + .entry(strip_version(raw)) + .or_insert(position); + } + let edges: Vec> = components + .iter() + .map(|component| { + component.dependencies + .iter() + .filter_map(|id| { + let trimmed = id.as_str().trim(); + if trimmed.is_empty() { + return None; + } + index + .get(trimmed) + .or_else(|| index.get(strip_version(trimmed))) + .copied() + }) + .collect() + }) + .collect(); + let mut tarjan = Tarjan::new(&edges); + (0..components.len()).for_each(|node| tarjan.visit(node)); + tarjan.components + .iter() + .filter(|scc| scc.len() > 1 || edges[scc[0]].contains(&scc[0])) + .flatten() + .flat_map(|&node| { + let raw = components[node].identity.as_str(); + [raw, raw.trim()] + }) + .collect() +} + +#[inline] +fn strip_version(id: &str) -> &str { + match id.rfind('@') { + Some(index) if index > 0 && !id[index + 1..].contains('/') => id[..index].trim(), + _ => id.trim(), + } +} + +struct Tarjan<'e> { + edges: &'e [Vec], + index: Vec>, + low: Vec, + on_stack: Vec, + stack: Vec, + next: usize, + components: Vec>, +} + +impl<'e> Tarjan<'e> { + #[inline] + fn new(edges: &'e [Vec]) -> Self { + let size = edges.len(); + Self { + edges, + index: vec![None; size], + low: vec![0; size], + on_stack: vec![false; size], + stack: Vec::with_capacity(size), + next: 0, + components: Vec::with_capacity(size), + } + } + + /// Iterative, so deep dependency chains cannot overflow the stack. + fn visit(&mut self, start: usize) { + if self.index[start].is_some() { + return; + } + let mut work = vec![(start, 0)]; + self.open(start); + while let Some((node, edge)) = work.pop() { + match self.edges[node].get(edge) { + Some(&next) => { + work.push((node, edge + 1)); + self.follow(node, next, &mut work); + } + None => self.finish(node, work.last().map(|&(parent, _)| parent)), + } + } + } + + /// The edge `node -> next`: descend into an unvisited node, or lower + /// `node`'s link through one still on the stack. + #[inline] + fn follow(&mut self, node: usize, next: usize, work: &mut Vec<(usize, usize)>) { + match self.index[next] { + None => { + self.open(next); + work.push((next, 0)); + } + Some(index) if self.on_stack[next] => self.low[node] = self.low[node].min(index), + Some(_) => {} + } + } + + /// All of `node`'s edges are done: pass its link up, close its component. + #[inline] + fn finish(&mut self, node: usize, parent: Option) { + if let Some(parent) = parent { + self.low[parent] = self.low[parent].min(self.low[node]); + } + if Some(self.low[node]) == self.index[node] { + self.close(node); + } + } + + #[inline] + fn open(&mut self, node: usize) { + self.index[node] = Some(self.next); + self.low[node] = self.next; + self.next += 1; + self.stack.push(node); + self.on_stack[node] = true; + } + + #[inline] + fn close(&mut self, root: usize) { + let mut component = Vec::new(); + while let Some(node) = self.stack.pop() { + self.on_stack[node] = false; + component.push(node); + if node == root { + break; + } + } + self.components.push(component); + } +} diff --git a/native/cli/src/commands/status/issues/deprecated.rs b/native/cli/src/commands/status/issues/deprecated.rs new file mode 100644 index 000000000000..a8285eeaccbc --- /dev/null +++ b/native/cli/src/commands/status/issues/deprecated.rs @@ -0,0 +1,126 @@ +//! `DeprecationMain.isDeprecatedByIdWithoutLoadingComponent` over verified model heads. +//! +//! Extends [`Checks`] with [`Checks::deprecated_dependencies`] across [`Component`], [`Range`], and [`Version`]. + +use super::{Checks, Component, array, truthy, without_version}; +use node_semver::{Range, Version}; +use serde_json::Value; +use std::io; + +/// Aspect ID for the component deprecation extension (`teambit.component/deprecation`). +const DEPRECATION: &str = "teambit.component/deprecation"; + +impl Checks<'_> { + /// Returns the list of deprecated dependency IDs referenced by `component`. + #[inline] + pub(super) fn deprecated_dependencies(&self, component: &Component) -> io::Result> { + if deprecated(self.context, &component.identity)? { + return Ok(Vec::new()); + } + component.dependencies + .iter() + .filter_map(|id| match deprecated(self.context, id) { + Ok(true) => Some(Ok(id.clone())), + Ok(false) => None, + Err(error) => Some(Err(error)), + }) + .collect() + } +} + +/// Evaluates whether `full_id` is marked deprecated in workspace overrides or its scope model head. +fn deprecated(context: &Value, full_id: &str) -> io::Result { + let identity = without_version(full_id.trim()).trim(); + if identity.is_empty() { + return Ok(false); + } + let target = array(context.get("targets"), "deprecation targets")? + .iter() + .find(|target| target["identity"].as_str().map(str::trim) == Some(identity)); + if let Some((_, cfg)) = target.and_then(|target| { + crate::commands::update_cmd::aspect_entry(&target["componentConfig"], DEPRECATION) + }) { + if cfg.as_str() == Some("-") { + return Ok(false); + } + if let Some(value) = cfg["deprecate"].as_bool() { + return Ok(value); + } + } + array(context.get("models"), "deprecation models")?; + let Some(model) = crate::commands::status::policy::model_of(context, identity) else { + return Ok(false); + }; + if truthy(model.get("deprecated")) { + return Ok(true); + } + let Some(extension) = head_deprecation(context, model, identity)? else { + return Ok(false); + }; + if truthy(extension["config"].get("deprecate")) { + return Ok(true); + } + range_matches(model, full_id, &extension["config"]) +} + +#[inline] +fn head_deprecation<'c>( + context: &'c Value, + model: &'c Value, + identity: &str, +) -> io::Result> { + let head = context["laneHeads"] + .get(identity) + .unwrap_or_else(|| &model["head"]); + let head_str = head.as_str().map(str::trim); + let version = array(context.get("versions"), "deprecation versions")? + .iter() + .find(|row| { + &row[0] == head || (head_str.is_some() && row[0].as_str().map(str::trim) == head_str) + }); + let Some(version) = version else { + return Ok(None); + }; + Ok(version[1]["extensions"] + .as_array() + .into_iter() + .flatten() + .find(|extension| crate::commands::update_cmd::matches_aspect(extension, DEPRECATION))) +} + +fn range_matches(model: &Value, full_id: &str, config: &Value) -> io::Result { + let Some(range) = config["range"] + .as_str() + .map(str::trim) + .filter(|val| !val.is_empty()) + .and_then(|val| Range::parse(val).ok()) + else { + return Ok(false); + }; + let Some((_, version)) = full_id.trim().rsplit_once('@') else { + return Ok(false); + }; + let version = version.trim(); + if version.is_empty() { + return Ok(false); + } + let empty = serde_json::Map::new(); + let tags = match model.get("versions") { + None | Some(Value::Null) => &empty, + Some(value) => value.as_object().ok_or_else(|| super::decline("deprecation model tags"))?, + }; + let orphaned = model["orphanedVersions"].as_object(); + let tag = if tags.contains_key(version) || orphaned.is_some_and(|map| map.contains_key(version)) + { + Some(version) + } else { + tags.iter() + .chain(orphaned.into_iter().flat_map(serde_json::Map::iter)) + .find(|(_, reference)| reference.as_str().map(str::trim) == Some(version)) + .map(|(tag, _)| tag.as_str().trim()) + }; + let Some(tag) = tag else { + return Ok(false); + }; + Ok(Version::parse(tag).is_ok_and(|version| version.satisfies(&range))) +} diff --git a/native/cli/src/commands/status/issues/detected.rs b/native/cli/src/commands/status/issues/detected.rs new file mode 100644 index 000000000000..7b6ef21421c6 --- /dev/null +++ b/native/cli/src/commands/status/issues/detected.rs @@ -0,0 +1,266 @@ +//! Detection issue classes from `@teambit/component-issues` 2.2.94. +//! +//! Defines [`Issue`] and formats detected component issues using [`list_or_text`] into JSON [`Value`] payloads. + +#[cfg(test)] +mod tests; + +use super::{SPACE, array, decline, format::list_or_text, text}; +use serde_json::{Value, json}; +use std::io; + +/// Component issue kind produced directly during dependency detection. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum Issue { + Untracked, + Relative, + RelativeAuthored, + Parse, + Resolve, + SelfReference, + ExternalEnv, + ImportNonMain, + Legacy, + MergeConfig, + LoadFailures, +} + +impl Issue { + /// Parses a component-issue class name into a [`Issue`] variant or declines if unknown. + #[inline] + pub(super) fn parse(name: &str) -> io::Result { + Ok(match name.trim() { + "UntrackedDependencies" => Self::Untracked, + "RelativeComponents" => Self::Relative, + "RelativeComponentsAuthored" => Self::RelativeAuthored, + "ParseErrors" => Self::Parse, + "ResolveErrors" => Self::Resolve, + "SelfReference" => Self::SelfReference, + "ExternalEnvWithoutVersion" => Self::ExternalEnv, + "ImportNonMainFiles" => Self::ImportNonMain, + "LegacyInsideHarmony" => Self::Legacy, + "MergeConfigHasConflict" => Self::MergeConfig, + "LoadFailures" => Self::LoadFailures, + _ => return Err(decline(format!("status issue type {name}"))), + }) + } + + #[inline] + pub(super) const fn class(self) -> (&'static str, &'static str, &'static str, bool) { + match self { + Self::Untracked + | Self::Relative + | Self::RelativeAuthored + | Self::Parse + | Self::Resolve => self.source_class(), + _ => self.runtime_class(), + } + } + + #[inline] + const fn source_class(self) -> (&'static str, &'static str, &'static str, bool) { + match self { + Self::Untracked => ( + "UntrackedDependencies", + "untracked file dependencies", + r#"use "bit add " to track untracked files as components"#, + true, + ), + Self::Relative => ( + "RelativeComponents", + "components with relative import statements found", + "use module paths for imported components", + true, + ), + Self::RelativeAuthored => ( + "RelativeComponentsAuthored", + "components with relative import statements found", + r#"replace to module paths or use "bit link --rewire" to replace"#, + true, + ), + Self::Parse => ( + "ParseErrors", + "error found while parsing the file", + "edit the file and fix the parsing error", + true, + ), + Self::Resolve => ( + "ResolveErrors", + "error found while resolving the file dependencies", + "see the log for the full error", + true, + ), + _ => unreachable!(), + } + } + + #[inline] + const fn runtime_class(self) -> (&'static str, &'static str, &'static str, bool) { + match self { + Self::SelfReference => ( + "SelfReference", + "a component has an import statement that points to itself", + "edit the file and change the import statement to a relative path", + true, + ), + Self::ExternalEnv => ( + "ExternalEnvWithoutVersion", + "failed loading env - external env without a version", + "", + true, + ), + Self::ImportNonMain => ( + "ImportNonMainFiles", + "importing non-main files", + "the dependency should expose its API from the main file", + true, + ), + Self::Legacy => ( + "LegacyInsideHarmony", + "legacy component inside Harmony workspace", + "remove the component and re-create it via Harmony", + true, + ), + Self::MergeConfig => ( + "MergeConfigHasConflict", + "merge-conflict file has unresolved conflicts", + "edit the merge-conflict file in the workspace root and resolve the conflicts", + true, + ), + Self::LoadFailures => ( + "LoadFailures", + "failed loading some aspects/envs of this component (the load continued without them)", + r#"see the error details below, often resolved by running "bit install" or "bit compile""#, + false, + ), + _ => unreachable!(), + } + } + + pub(super) fn deserialize(self, mut data: Value) -> io::Result { + if !matches!(self, Self::Relative | Self::RelativeAuthored) { + return Ok(data); + } + let files = data.as_object_mut().ok_or_else(|| decline("relative component issue data"))?; + for entries in files.values_mut() { + let entries = entries + .as_array_mut() + .ok_or_else(|| decline("relative component issue entries"))?; + for entry in entries { + match self { + Self::Relative => *entry = json!({"_legacy":entry.clone()}), + Self::RelativeAuthored => { + entry["componentId"] = component_id(&entry["componentId"])?; + } + _ => unreachable!(), + } + } + } + Ok(data) + } + + #[inline] + pub(super) fn data_to_string(self, data: &Value) -> io::Result { + match self { + Self::Untracked => untracked(data), + Self::ExternalEnv => { + let env_id = text(data.get("envId"))?.trim(); + Ok(format!("{SPACE}{env_id}")) + } + Self::LoadFailures => load_failures(data), + _ => file_data(data, |value| self.file_value(value)), + } + } + + fn file_value(self, value: &Value) -> io::Result { + match self { + Self::Relative => { + let count = array(Some(value), "relative issues")?.len(); + Ok(vec!["[object Object]"; count].join(", ")) + } + Self::RelativeAuthored => array(Some(value), "relative authored issues")? + .iter() + .map(|entry| { + let mut value = entry["componentId"]["_legacy"].clone(); + if let Some(scope) = entry["componentId"].get("_scope") { + value["scope"] = scope.clone(); + } + let id = crate::commands::ids::Id::from_object(&value)?; + Ok(format!(r#""{}" ({})"#, text(entry.get("importSource"))?, id.display())) + }) + .collect::>>() + .map(|values| values.join(", ")), + _ => list_or_text(value), + } + } +} + +fn legacy(id: &Value) -> io::Result { + let id = id.get("_legacy").unwrap_or(id); + let mut legacy = serde_json::Map::new(); + for key in ["scope", "name", "version"] { + if let Some(value) = id.get(key) { + legacy.insert(key.to_string(), value.clone()); + } + } + if legacy + .get("name") + .and_then(Value::as_str) + .is_none_or(|name| name.trim().is_empty()) + { + return Err(decline("relative component id without name")); + } + Ok(Value::Object(legacy)) +} + +fn file_data(data: &Value, format: impl Fn(&Value) -> io::Result) -> io::Result { + if data.is_boolean() { + return Ok(String::new()); + } + let files = data.as_object().ok_or_else(|| decline("detection issue file data"))?; + files + .iter() + .map(|(file, value)| Ok(format!("{SPACE}{file} -> {}", format(value)?))) + .collect::>>() + .map(|rows| rows.join("\n")) +} + +fn untracked(data: &Value) -> io::Result { + let files = data.as_object().ok_or_else(|| decline("untracked dependency issue data"))?; + files + .iter() + .map(|(file, value)| { + let space = if value["nested"] == true { " " } else { SPACE }; + let files = array(value.get("untrackedFiles"), "untracked files")? + .iter() + .map(|file| text(file.get("relativePath")).map(str::trim)) + .collect::>>()?; + Ok(format!("{space}{file} -> {}", files.join(", "))) + }) + .collect::>>() + .map(|rows| rows.join("\n")) +} + +fn load_failures(data: &Value) -> io::Result { + array(Some(data), "aspect load failures")? + .iter() + .map(|entry| { + Ok(format!( + "{SPACE}{} (during {}) -> {}", + text(entry.get("failedId"))?.trim(), + text(entry.get("phase"))?.trim(), + text(entry.get("error"))?.trim(), + )) + }) + .collect::>>() + .map(|rows| rows.join("\n")) +} + +#[inline] +fn component_id(original: &Value) -> io::Result { + let mut id = json!({"_legacy":legacy(original)?}); + if let Some(scope) = original.get("_scope") { + id["_scope"] = scope.clone(); + } + Ok(id) +} diff --git a/native/cli/src/commands/status/issues/detected/oracle.json b/native/cli/src/commands/status/issues/detected/oracle.json new file mode 100644 index 000000000000..37ac5e4d4115 --- /dev/null +++ b/native/cli/src/commands/status/issues/detected/oracle.json @@ -0,0 +1,274 @@ +[ + { + "type": "UntrackedDependencies", + "data": { + "src/a.js": { + "nested": false, + "untrackedFiles": [ + { + "relativePath": "other.js", + "existing": true + }, + { + "relativePath": "new.js" + } + ] + }, + "src/nested.js": { + "nested": true, + "untrackedFiles": [ + { + "relativePath": "deep.js" + } + ] + } + }, + "expected": { + "type": "UntrackedDependencies", + "description": "untracked file dependencies", + "solution": "use \"bit add \" to track untracked files as components", + "isTagBlocker": true, + "data": { + "src/a.js": { + "nested": false, + "untrackedFiles": [ + { + "relativePath": "other.js", + "existing": true + }, + { + "relativePath": "new.js" + } + ] + }, + "src/nested.js": { + "nested": true, + "untrackedFiles": [ + { + "relativePath": "deep.js" + } + ] + } + }, + "dataAsString": " src/a.js -> other.js, new.js\n src/nested.js -> deep.js" + } + }, + { + "type": "RelativeComponents", + "data": { + "a.js": [ + { + "scope": "s", + "name": "foo", + "version": "1.0.0" + } + ] + }, + "expected": { + "type": "RelativeComponents", + "description": "components with relative import statements found", + "solution": "use module paths for imported components", + "isTagBlocker": true, + "data": { + "a.js": [ + { + "_legacy": { + "scope": "s", + "name": "foo", + "version": "1.0.0" + } + } + ] + }, + "dataAsString": " a.js -> [object Object]" + } + }, + { + "type": "RelativeComponentsAuthored", + "data": { + "a.js": [ + { + "importSource": "../foo", + "componentId": { + "name": "foo", + "scope": "s", + "version": "1.0.0" + }, + "relativePath": "foo.js" + } + ] + }, + "expected": { + "type": "RelativeComponentsAuthored", + "description": "components with relative import statements found", + "solution": "replace to module paths or use \"bit link --rewire\" to replace", + "isTagBlocker": true, + "data": { + "a.js": [ + { + "importSource": "../foo", + "componentId": { + "_legacy": { + "scope": "s", + "name": "foo", + "version": "1.0.0" + } + }, + "relativePath": "foo.js" + } + ] + }, + "dataAsString": " a.js -> \"../foo\" (s/foo@1.0.0)" + } + }, + { + "type": "ParseErrors", + "data": { + "src/a.js": "Unexpected token (2:4)" + }, + "expected": { + "type": "ParseErrors", + "description": "error found while parsing the file", + "solution": "edit the file and fix the parsing error", + "isTagBlocker": true, + "data": { + "src/a.js": "Unexpected token (2:4)" + }, + "dataAsString": " src/a.js -> Unexpected token (2:4)" + } + }, + { + "type": "ResolveErrors", + "data": { + "src/a.js": "unable to resolve file" + }, + "expected": { + "type": "ResolveErrors", + "description": "error found while resolving the file dependencies", + "solution": "see the log for the full error", + "isTagBlocker": true, + "data": { + "src/a.js": "unable to resolve file" + }, + "dataAsString": " src/a.js -> unable to resolve file" + } + }, + { + "type": "SelfReference", + "data": { + "a.js": [ + "@s/foo", + "@s/foo/internal" + ] + }, + "expected": { + "type": "SelfReference", + "description": "a component has an import statement that points to itself", + "solution": "edit the file and change the import statement to a relative path", + "isTagBlocker": true, + "data": { + "a.js": [ + "@s/foo", + "@s/foo/internal" + ] + }, + "dataAsString": " a.js -> @s/foo, @s/foo/internal" + } + }, + { + "type": "ExternalEnvWithoutVersion", + "data": { + "envId": "org/env@1.0.0", + "componentId": "s/foo" + }, + "expected": { + "type": "ExternalEnvWithoutVersion", + "description": "failed loading env - external env without a version", + "solution": "", + "isTagBlocker": true, + "data": { + "envId": "org/env@1.0.0", + "componentId": "s/foo" + }, + "dataAsString": " org/env@1.0.0" + } + }, + { + "type": "ImportNonMainFiles", + "data": { + "a.js": [ + "@s/foo/internal.js" + ] + }, + "expected": { + "type": "ImportNonMainFiles", + "description": "importing non-main files", + "solution": "the dependency should expose its API from the main file", + "isTagBlocker": true, + "data": { + "a.js": [ + "@s/foo/internal.js" + ] + }, + "dataAsString": " a.js -> @s/foo/internal.js" + } + }, + { + "type": "LegacyInsideHarmony", + "data": true, + "expected": { + "type": "LegacyInsideHarmony", + "description": "legacy component inside Harmony workspace", + "solution": "remove the component and re-create it via Harmony", + "isTagBlocker": true, + "data": true, + "dataAsString": "" + } + }, + { + "type": "MergeConfigHasConflict", + "data": true, + "expected": { + "type": "MergeConfigHasConflict", + "description": "merge-conflict file has unresolved conflicts", + "solution": "edit the merge-conflict file in the workspace root and resolve the conflicts", + "isTagBlocker": true, + "data": true, + "dataAsString": "" + } + }, + { + "type": "LoadFailures", + "data": [ + { + "failedId": "org/env@1.0.0", + "phase": "load", + "error": "missing aspect" + }, + { + "failedId": "org/plugin", + "phase": "onLoad", + "error": "broken callback" + } + ], + "expected": { + "type": "LoadFailures", + "description": "failed loading some aspects/envs of this component (the load continued without them)", + "solution": "see the error details below, often resolved by running \"bit install\" or \"bit compile\"", + "isTagBlocker": false, + "data": [ + { + "failedId": "org/env@1.0.0", + "phase": "load", + "error": "missing aspect" + }, + { + "failedId": "org/plugin", + "phase": "onLoad", + "error": "broken callback" + } + ], + "dataAsString": " org/env@1.0.0 (during load) -> missing aspect\n org/plugin (during onLoad) -> broken callback" + } + } +] diff --git a/native/cli/src/commands/status/issues/detected/tests.rs b/native/cli/src/commands/status/issues/detected/tests.rs new file mode 100644 index 000000000000..7d611c9bd545 --- /dev/null +++ b/native/cli/src/commands/status/issues/detected/tests.rs @@ -0,0 +1,28 @@ +//! Oracle tests comparing detected component [`Issue`] serialization against released `@teambit/component-issues` classes. +//! +//! Verifies `deserialize`, `toObject`, and `dataToString` parity against `oracle.json` fixtures. + +use super::super::Issue; +use serde_json::Value; + +/// Captured from the released `@teambit/component-issues` 2.2.94 classes, +/// including their `deserialize`, `toObject` and `dataToString` methods. +#[test] +fn detection_issue_objects_match_released_node_classes() { + let fixtures: Vec = serde_json::from_str(include_str!("oracle.json")).unwrap(); + for fixture in fixtures { + let kind = Issue::parse(fixture["type"].as_str().unwrap()).unwrap(); + let Issue::Detected(detected) = kind else { panic!("detection issue expected") }; + let data = detected + .deserialize(fixture["data"].clone()) + .unwrap(); + let actual = kind.to_object(&data).unwrap(); + assert_eq!(actual, fixture["expected"], "{}", fixture["type"]); + } +} + +/// Verifies that [`Issue::parse`] returns an error when given an unrecognized issue type name. +#[test] +fn unknown_issue_type_name_returns_error() { + assert!(Issue::parse("NonexistentIssueType").is_err()); +} diff --git a/native/cli/src/commands/status/issues/format.rs b/native/cli/src/commands/status/issues/format.rs new file mode 100644 index 000000000000..cb2626e14a75 --- /dev/null +++ b/native/cli/src/commands/status/issues/format.rs @@ -0,0 +1,50 @@ +//! String forms of issue data in status JSON. +//! +//! [`join`] and [`list_or_text`] serialize issue [`Value`] payloads with [`array`] and [`text`] into comma-separated strings. + +use super::{array, text}; +use serde_json::Value; +use std::io; + +/// Joins the string elements of JSON array `data` with `", "` (`array.join(', ')`). +#[inline] +pub(super) fn join(data: &Value) -> io::Result { + let items = array(Some(data), "issue data")?; + if items.is_empty() { + return Ok(String::new()); + } + let items = items + .iter() + .map(item_text) + .collect::>>()?; + Ok(items.join(", ")) +} + +/// `componentIssueToString`: arrays joined, strings as they are. +#[inline] +pub(super) fn list_or_text(value: &Value) -> io::Result { + match value { + Value::Array(_) => join(value), + _ => item_text(value).map(str::to_owned), + } +} + +/// Extracts a display string from a JSON issue element (object field or string value). +#[inline] +fn item_text(value: &Value) -> io::Result<&str> { + if let Some(object) = value.as_object() + && let Some(found) = + ["message", "id", "name", "componentId", "relativePath", "filePath", "path"] + .into_iter() + .find_map(|key| { + object + .get(key) + .and_then(Value::as_str) + .map(str::trim) + .filter(|val| !val.is_empty()) + }) + { + return Ok(found); + } + text(Some(value)).map(str::trim) +} diff --git a/native/cli/src/commands/status/issues/kinds.rs b/native/cli/src/commands/status/issues/kinds.rs new file mode 100644 index 000000000000..b141171e1143 --- /dev/null +++ b/native/cli/src/commands/status/issues/kinds.rs @@ -0,0 +1,247 @@ +//! Native issue kinds and their released class metadata. +//! +//! Maps [`Issue`] and [`detected`] categories into formatted [`Value`] descriptors via [`join`] and [`list_or_text`]. + +use super::{ + SPACE, array, decline, detected, + format::{join, list_or_text}, + text, +}; +use serde_json::{Value, json}; +use std::io; + +/// Top-level status issue kind combining post-detection workspace checks and [`detected::Issue`] variants. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum Issue { + MissingManual, + MissingPackages, + MissingDependencies, + ImportFromDirectory, + MissingLinks, + MissingDists, + Duplicate, + Circular, + RemovedDependencies, + NonLoadedEnv, + MultipleEnvs, + RemovedEnv, + DeprecatedDependencies, + PinnedDependencies, + Detected(detected::Issue), +} + +impl Issue { + /// Parses a status issue class name into a top-level [`Issue`] or delegates to [`detected::Issue::parse`]. + #[inline] + pub(super) fn parse(name: &str) -> io::Result { + let name = name.trim(); + Ok(match name { + "MissingManuallyConfiguredPackages" => Self::MissingManual, + "MissingPackagesDependenciesOnFs" => Self::MissingPackages, + "MissingDependenciesOnFs" => Self::MissingDependencies, + "ImportFromDirectory" => Self::ImportFromDirectory, + "MissingLinksFromNodeModulesToSrc" => Self::MissingLinks, + "MissingDists" => Self::MissingDists, + "DuplicateComponentAndPackage" => Self::Duplicate, + "CircularDependencies" => Self::Circular, + "RemovedDependencies" => Self::RemovedDependencies, + "NonLoadedEnv" => Self::NonLoadedEnv, + "MultipleEnvs" => Self::MultipleEnvs, + "RemovedEnv" => Self::RemovedEnv, + "DeprecatedDependencies" => Self::DeprecatedDependencies, + "PinnedDependencies" => Self::PinnedDependencies, + _ => Self::Detected(detected::Issue::parse(name)?), + }) + } + + /// `type`, `description`, `solution` and `isTagBlocker` of the issue class. + #[inline] + pub(super) const fn class(self) -> (&'static str, &'static str, &'static str, bool) { + match self { + Self::MissingPackages => ( + "MissingPackagesDependenciesOnFs", + "missing packages or links from node_modules to the source", + r#"run "bit install --add-missing-deps" to fix both issues"#, + true, + ), + Self::MissingDependencies => ( + "MissingDependenciesOnFs", + "non-existing dependency files", + "make sure all files exists on your workspace", + true, + ), + Self::ImportFromDirectory => ( + "ImportFromDirectory", + "relative import from a directory", + "change the import statement to a specific file", + false, + ), + Self::Detected(kind) => kind.class(), + _ => self.legacy_class(), + } + } + + #[inline] + const fn legacy_class(self) -> (&'static str, &'static str, &'static str, bool) { + match self { + Self::MissingManual => ( + "MissingManuallyConfiguredPackages", + "missing packages that were manually set", + r#"run "bit install""#, + true, + ), + Self::MissingLinks => ( + "MissingLinksFromNodeModulesToSrc", + "missing links from node_modules to source", + r#"run "bit link""#, + false, + ), + Self::MissingDists => ("MissingDists", "missing dists", r#"run "bit compile""#, false), + Self::Duplicate => ( + "DuplicateComponentAndPackage", + "tracked component added as a package", + "either remove the package from the workspace.jsonc (bit uninstall) or remove the component (bit remove)", + true, + ), + Self::Circular => ( + "CircularDependencies", + "circular dependencies", + "run `bit deps circular` to get the component-ids participating in the circular", + true, + ), + Self::RemovedDependencies => ( + "RemovedDependencies", + "dependencies used in the code were deleted", + r#"if the dependencies are no longer needed, remove them from the code. otherwise, to get it installed from main run "bit install ". to undo the delete, run "bit recover""#, + true, + ), + Self::NonLoadedEnv | Self::MultipleEnvs | Self::RemovedEnv => self.environment_class(), + Self::DeprecatedDependencies => Self::deprecated_class(), + Self::PinnedDependencies => Self::pinned_class(), + _ => unreachable!(), + } + } + + #[inline] + const fn deprecated_class() -> (&'static str, &'static str, &'static str, bool) { + ( + "DeprecatedDependencies", + "dependencies used in the code were deprecated", + "replace them with alternative dependencies. or remove them if not needed anymore", + false, + ) + } + + #[inline] + const fn pinned_class() -> (&'static str, &'static str, &'static str, bool) { + ( + "PinnedDependencies", + r#"dependencies on workspace components with a version set by config ("bit deps set", variants or env), not the workspace version"#, + r#"to use the workspace version, remove the version from the config, e.g. "bit deps unset ""#, + false, + ) + } + + #[inline] + const fn environment_class(self) -> (&'static str, &'static str, &'static str, bool) { + match self { + Self::NonLoadedEnv => { + ("NonLoadedEnv", "failed loading env", r#"run "bit install""#, true) + } + Self::MultipleEnvs => ( + "MultipleEnvs", + "multiple envs", + r#"set the desired env by running "bit env set ", if it doesn't work, run "bit aspect unset ". to keep troubleshooting run "bit aspect list ""#, + true, + ), + Self::RemovedEnv => ( + "RemovedEnv", + "the env of this component is deleted", + r#"use "bit env set/replace" to set a new env or a different version of this env. to undo the delete, run "bit recover""#, + true, + ), + _ => unreachable!(), + } + } + + /// `issue.toObject()` plus `dataAsString`. + #[inline] + pub(super) fn to_object(self, data: &Value) -> io::Result { + let (kind, description, solution, blocker) = self.class(); + let text = self.data_to_string(data)?; + if text.contains('\u{1b}') { + return Err(decline("status issue data with escape sequences")); + } + Ok(json!({ + "type": kind, + "description": description, + "solution": solution, + "isTagBlocker": blocker, + "data": data, + "dataAsString": text, + })) + } + + /// `issue.dataToString()`. + fn data_to_string(self, data: &Value) -> io::Result { + match self { + Self::Detected(kind) => kind.data_to_string(data), + Self::MissingManual + | Self::RemovedDependencies + | Self::MultipleEnvs + | Self::DeprecatedDependencies + | Self::PinnedDependencies => Ok(format!("{SPACE}{}", join(data)?)), + Self::Duplicate | Self::NonLoadedEnv | Self::RemovedEnv => { + let item = text(Some(data))?.trim(); + Ok(format!("{SPACE}{item}")) + } + Self::MissingPackages => missing_packages_to_string(data), + Self::MissingDependencies | Self::ImportFromDirectory => { + let files = data.as_object().ok_or_else(|| decline("status issue file data"))?; + format_file_map(files) + } + // `Object.keys(true)` is empty. + Self::MissingLinks | Self::MissingDists | Self::Circular => match data { + Value::Bool(_) => Ok(String::new()), + Value::Array(items) if items.is_empty() => Ok(String::new()), + Value::Object(map) if map.is_empty() => Ok(String::new()), + _ => Err(decline("status issue data")), + }, + } + } +} + +#[inline] +fn format_file_map(files: &serde_json::Map) -> io::Result { + files + .iter() + .map(|(file, items)| Ok(format!("{SPACE}{file} -> {}", list_or_text(items)?))) + .collect::>>() + .map(|rows| rows.join("\n")) +} + +fn missing_packages_to_string(data: &Value) -> io::Result { + if let Some(files) = data.as_object() { + return format_file_map(files); + } + let rows = array(Some(data), "missing packages")? + .iter() + .map(|row| { + let file = ["filePath", "file", "path"] + .into_iter() + .find_map(|key| { + row.get(key) + .and_then(Value::as_str) + .map(str::trim) + .filter(|val| !val.is_empty()) + }) + .ok_or_else(|| decline("missing packages file path"))?; + let packages = row + .get("missingPackages") + .filter(|val| !val.is_null()) + .or_else(|| row.get("packages")) + .ok_or_else(|| decline("missing packages"))?; + Ok(format!("{SPACE}{file} -> {}", list_or_text(packages)?)) + }); + Ok(rows.collect::>>()?.join("\n")) +} diff --git a/native/cli/src/commands/status/issues/removed.rs b/native/cli/src/commands/status/issues/removed.rs new file mode 100644 index 000000000000..ea61120aa5d3 --- /dev/null +++ b/native/cli/src/commands/status/issues/removed.rs @@ -0,0 +1,76 @@ +//! Dependencies on components removed in this workspace. +//! +//! Extends [`Checks`] with [`Checks::removed_dependencies`] to emit [`Issue`] entries for [`Component`] and [`Value`] targets. + +use super::{Checks, Component, Issue, array, without_version}; +use serde_json::Value; +use std::{collections::HashSet, io}; + +impl Checks<'_> { + /// Returns dependency IDs of `component` that point to locally removed workspace components. + #[inline] + pub(super) fn removed_dependencies( + &self, + component: &Component, + issues: &[(Issue, Value)], + ) -> io::Result> { + let missing = issues + .iter() + .find(|(kind, _)| *kind == Issue::MissingPackages) + .map(|(_, data)| array(Some(data), "missing packages")) + .transpose()? + .unwrap_or_default(); + let packages = missing_packages(missing); + Ok(self.targets + .iter() + .filter(|target| target["locallyRemoved"] == true) + .filter_map(|target| removed_id(target, component, &packages)) + .collect()) + } +} + +/// Collects the set of missing package names reported across `MissingPackagesDependenciesOnFs` issue rows. +#[inline] +fn missing_packages(rows: &[Value]) -> HashSet<&str> { + rows.iter() + .flat_map(|row| { + row.get("missingPackages") + .or_else(|| row.get("packages")) + .and_then(Value::as_array) + .into_iter() + .flatten() + }) + .filter_map(Value::as_str) + .map(str::trim) + .filter(|pkg| !pkg.is_empty()) + .collect() +} + +fn removed_id(target: &Value, component: &Component, packages: &HashSet<&str>) -> Option { + let raw = target["identity"] + .as_str() + .map(str::trim) + .filter(|id| !id.is_empty()) + .or_else(|| { + target["id"] + .as_str() + .map(str::trim) + .filter(|id| !id.is_empty()) + })?; + let identity = without_version(raw); + let version = target["version"] + .as_str() + .or_else(|| target["head"].as_str()) + .map(str::trim) + .filter(|ver| !ver.is_empty())?; + let (scope, name) = identity + .split_once('/') + .map(|(scope, name)| (scope.trim(), name.trim())) + .filter(|(scope, name)| !scope.is_empty() && !name.is_empty())?; + let referenced = component.dependencies + .iter() + .any(|dependency| without_version(dependency) == identity) + || (!packages.is_empty() + && packages.contains(format!("@{scope}/{}", name.replace('/', ".")).as_str())); + referenced.then(|| format!("{scope}/{name}@{version}")) +} diff --git a/native/cli/src/commands/status/issues/resolve.rs b/native/cli/src/commands/status/issues/resolve.rs new file mode 100644 index 000000000000..72e80e8b1a62 --- /dev/null +++ b/native/cli/src/commands/status/issues/resolve.rs @@ -0,0 +1,256 @@ +//! For snap and tag, the engine proves +//! the models' heads and the dependencies outside the workspace. +//! +//! [`ordinary_heads_for`] resolves [`Component`] dependency versions and missing-dependency issues across [`HashMap`] stores. + +#[cfg(test)] +mod resolve_tests; + +use super::{super::js::truthy, Component}; +use serde_json::Value; +use std::collections::{HashMap, HashSet}; + +/// Evaluates `ordinaryResolveIssueHeads(context, receipt)`: one row per model with its head and lane head, none deprecated or removed. +#[must_use] +pub fn ordinary_heads_for(context: &Value, receipt: &Value, graph: Option<&[Component]>) -> bool { + let referenced = graph.map(|components| { + components + .iter() + .map(|component| super::without_version(&component.identity)) + .collect::>() + }); + let (Some(models), Some(rows)) = (context["models"].as_array(), receipt.as_array()) else { + return false; + }; + rows.len() == models.len() + && models + .iter() + .zip(rows) + .all(|(model, row)| ordinary_model(context, model, row, referenced.as_ref())) +} + +/// Checks whether a single `model` and its `row` in `receipt` satisfy ordinary issue-head invariants. +fn ordinary_model( + context: &Value, + model: &Value, + row: &Value, + referenced: Option<&HashSet<&str>>, +) -> bool { + let Some(identity) = model_identity(context, model) else { + return false; + }; + let check_issues = referenced.is_none_or(|set| set.contains(identity.as_str())); + if check_issues && truthy(model.get("deprecated")) { + return false; + } + let lane = context["lane"]["targets"] + .as_array() + .and_then(|targets| { + targets + .iter() + .find(|target| { + target["identity"].as_str().map(str::trim) == Some(identity.as_str()) + }) + }); + let mut expected: Vec<&Value> = Vec::with_capacity(2); + for hash in [&model["head"], lane.map_or(&Value::Null, |lane| &lane["currentHead"])] { + if !hash.is_null() && !expected.contains(&hash) { + expected.push(hash); + } + } + let heads = row["heads"].as_array(); + if row["identity"].as_str().map(str::trim) != Some(identity.as_str()) + || heads.is_none_or(|heads| heads.len() != expected.len()) + { + return false; + } + heads + .into_iter() + .flatten() + .zip(expected) + .all(|(head, expected)| ordinary_head(head, expected, check_issues)) +} + +fn model_identity(context: &Value, model: &Value) -> Option { + let name = model["name"] + .as_str() + .map(str::trim) + .filter(|name| !name.is_empty())?; + if let Some(scope) = model["scope"] + .as_str() + .map(str::trim) + .filter(|scope| !scope.is_empty()) + { + return Some(format!("{scope}/{name}")); + } + context["targets"] + .as_array() + .and_then(|targets| { + targets + .iter() + .filter_map(|target| target["identity"].as_str().map(str::trim)) + .find(|identity| { + identity.split_once('/').map_or(*identity, |(_, bare)| bare.trim()) == name + }) + .map(str::to_owned) + }) + .or_else(|| { + let (_, ws) = crate::commands::update_cmd::aspect_entry( + &context["workspace"], + "teambit.workspace/workspace", + )?; + let scope = ws["defaultScope"] + .as_str() + .map(str::trim) + .filter(|scope| !scope.is_empty())?; + Some(format!("{scope}/{name}")) + }) +} + +fn ordinary_head(head: &Value, expected: &Value, check_issues: bool) -> bool { + let hash = head["hash"].as_str(); + let valid = hash.is_some_and(|hash| { + hash.len() == 40 + && hash + .bytes() + .all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f')) + }); + if !valid || &head["hash"] != expected { + return false; + } + let Some(version) = head.get("version") else { return false }; + if version.is_null() { + return true; + } + let extensions = match version.get("extensions") { + None | Some(Value::Null) => Some(&[][..]), + Some(extensions) => extensions.as_array().map(Vec::as_slice), + }; + version.is_object() + && extensions.is_some_and(|extensions| { + !check_issues + || !extensions + .iter() + .any(|extension| { + crate::commands::update_cmd::matches_aspect( + extension, + "teambit.component/deprecation", + ) || crate::commands::update_cmd::matches_aspect( + extension, + "teambit.component/remove", + ) + }) + }) +} + +/// `resolveExternalIssueVertices(components, receipt)`: every dependency +/// outside the workspace reachable through the engine's external rows, +/// with no row left over; `None` when the receipt does not prove that. +#[must_use] +pub fn external_vertices( + components: &[Component], + receipt: Option<&Value>, +) -> Option> { + let workspace: HashSet<&str> = components + .iter() + .map(|component| component.identity.as_str()) + .collect(); + let requested: Vec<&str> = components + .iter() + .flat_map(|component| component.dependencies.iter().map(String::as_str)) + .filter(|id| !workspace.contains(id)) + .collect(); + let Some(receipt) = receipt else { return requested.is_empty().then(Vec::new) }; + let external = external_rows(receipt, &workspace)?; + let mut seen = HashSet::new(); + let mut pending = requested; + let mut edges = 0; + while let Some(id) = pending.pop() { + if workspace.contains(id) || !seen.insert(id) { + continue; + } + let row = external.get(id)?; + edges += row.dependencies.len(); + if edges > 16384 { + return None; + } + pending.extend(row.dependencies.iter().map(String::as_str)); + } + (seen.len() == external.len()).then(|| external.values().cloned().collect()) +} + +/// The receipt's `external` rows, validated, by identity. +fn external_rows(receipt: &Value, workspace: &HashSet<&str>) -> Option> { + let object = receipt.as_object()?; + let rows = object.get("external")?.as_array()?; + if object + .keys() + .any(|key| key != "external") + || rows.len() > 4096 + { + return None; + } + let mut external = HashMap::with_capacity(rows.len()); + for row in rows { + let component = external_row(row, workspace)?; + if external.insert(component.identity.clone(), component).is_some() { + return None; + } + } + Some(external) +} + +/// One external vertex: a component id outside the workspace, its distinct +/// dependencies, and a proven absent model. +fn external_row(row: &Value, workspace: &HashSet<&str>) -> Option { + let identity = row["identity"].as_str().filter(|identity| component_identity(identity))?; + let dependencies: Vec = row["dependencies"] + .as_array()? + .iter() + .map(|id| { + id.as_str() + .filter(|id| workspace.contains(id) || component_identity(id)) + .map(str::to_string) + }) + .collect::>()?; + let unique: HashSet<&String> = dependencies.iter().collect(); + let keys = row + .as_object()? + .keys() + .all(|key| matches!(key.as_str(), "identity" | "dependencies" | "model")); + let absent_model = row.get("model").is_some_and(Value::is_null); + if !keys || !absent_model || workspace.contains(identity) || unique.len() != dependencies.len() + { + return None; + } + Some(Component { + ignored: Vec::new(), + identity: identity.to_string(), + package_name: String::new(), + dependencies, + issues: Value::Null, + }) +} + +#[inline] +fn component_identity(value: &str) -> bool { + versioned(value) || (!value.contains('@') && scoped_name(value)) +} + +/// `/^[^@\s]+\/[^@\s]+@[^@\s]+$/`. +#[inline] +fn versioned(value: &str) -> bool { + let Some((id, version)) = value.split_once('@') else { return false }; + scoped_name(id) && plain_part(version) +} + +#[inline] +fn scoped_name(id: &str) -> bool { + let Some((scope, name)) = id.split_once('/') else { return false }; + plain_part(scope) && plain_part(name) +} + +#[inline] +fn plain_part(part: &str) -> bool { + !part.is_empty() && !part.contains('@') && !part.chars().any(char::is_whitespace) +} diff --git a/native/cli/src/commands/status/issues/resolve/resolve_tests.rs b/native/cli/src/commands/status/issues/resolve/resolve_tests.rs new file mode 100644 index 000000000000..3b36ed5fe874 --- /dev/null +++ b/native/cli/src/commands/status/issues/resolve/resolve_tests.rs @@ -0,0 +1,73 @@ +//! Unit tests for [`Component`] head proof validation ([`ordinary_heads_for`]), [`versioned`] lookups, and [`external_vertices`] dependency checks. +//! +//! Verifies [`HEAD`] proof matching and [`ordinary_heads`] validation across workspace status contexts. + +use super::{Component, external_vertices, ordinary_heads_for, versioned}; +use serde_json::{Value, json}; + +/// Canonical 40-hex snap hash fixture used in [`ordinary_heads`] tests. +const HEAD: &str = "0123456789abcdef0123456789abcdef01234567"; + +/// Evaluates [`ordinary_heads_for`] without a custom scope filter. +fn ordinary_heads(context: &Value, receipt: &Value) -> bool { + ordinary_heads_for(context, receipt, None) +} + +fn component(identity: &str, dependencies: &[&str]) -> Component { + Component { + ignored: Vec::new(), + identity: identity.to_string(), + package_name: String::new(), + dependencies: dependencies + .iter() + .map(ToString::to_string) + .collect(), + issues: serde_json::Value::Null, + } +} + +#[test] +fn heads_are_proven_per_model() { + let context = json!({ "models": [{ "scope": "s", "name": "a", "head": HEAD }, { "scope": "s", "name": "b" }] }); + let ordinary = json!([{ "identity": "s/a", "heads": [{ "hash": HEAD, "version": { "extensions": [] } }] }, { "identity": "s/b", "heads": [] }]); + assert!(ordinary_heads(&context, &ordinary)); + let absent = json!([{ "identity": "s/a", "heads": [{ "hash": HEAD, "version": null }] }, { "identity": "s/b", "heads": [] }]); + assert!(ordinary_heads(&context, &absent)); + let removed = json!([ + { "identity": "s/a", "heads": [{ "hash": HEAD, "version": { "extensions": [{ "name": "teambit.component/remove" }] } }] }, + { "identity": "s/b", "heads": [] }, + ]); + assert!(!ordinary_heads(&context, &removed)); + let unproven = json!([{ "identity": "s/a", "heads": [{ "hash": HEAD }] }, { "identity": "s/b", "heads": [] }]); + assert!(!ordinary_heads(&context, &unproven)); +} + +#[test] +fn external_vertices_must_cover_exactly_the_reachable_dependencies() { + let components = [component("s/a@1.0.0", &["s/x@1.0.0"])]; + assert!(external_vertices(&components, None).is_none()); + let graph = json!({ "external": [{ "identity": "s/x@1.0.0", "dependencies": ["s/a@1.0.0"], "model": null }] }); + let external = external_vertices(&components, Some(&graph)).unwrap(); + assert_eq!(external[0].identity, "s/x@1.0.0"); + let extra = json!({ "external": [ + { "identity": "s/x@1.0.0", "dependencies": [], "model": null }, + { "identity": "s/y@1.0.0", "dependencies": [], "model": null }, + ] }); + assert!(external_vertices(&components, Some(&extra)).is_none()); + let alone = [component("s/a@1.0.0", &[])]; + assert_eq!(external_vertices(&alone, None).map(|rows| rows.len()), Some(0)); +} + +#[test] +fn versioned_identities() { + assert!(versioned("s/a@1.0.0")); + assert!(versioned("s.org/ui/button@abc")); + assert!(!versioned("s/a")); + assert!(!versioned("a@1.0.0")); + assert!(!versioned("s/a@1@2")); +} + +#[test] +fn empty_version_suffix_is_not_a_versioned_identity() { + assert!(!versioned("s/a@")); +} diff --git a/native/cli/src/commands/status/issues/tests.rs b/native/cli/src/commands/status/issues/tests.rs new file mode 100644 index 000000000000..781b5fd176c6 --- /dev/null +++ b/native/cli/src/commands/status/issues/tests.rs @@ -0,0 +1,49 @@ +//! Unit tests for workspace `ignoreIssues` filtering during [`Component`] status issue [`collect`]ion. +//! +//! Verifies root-level and component-level `ignoreIssues` suppression of `MissingManuallyConfiguredPackages`. + +use super::{Component, collect}; +use serde_json::{Value, json}; + +/// Verifies root `ignoreIssues` settings suppress `MissingManuallyConfiguredPackages` during [`collect`]. +#[test] +fn root_issue_setting_ignores_only_missing_manually_configured_packages() { + let component = Component { + ignored: Vec::new(), + identity: "scope/alpha@1.0.0".into(), + package_name: "@scope/alpha".into(), + dependencies: vec![], + issues: json!([{"type":"MissingManuallyConfiguredPackages","data":r#"["missing-pkg"]"#}]), + }; + let mut context = json!({ + "versions":[],"models":[],"targets":[{"identity":"scope/alpha","directory":"."}], + "workspace":{"teambit.dependencies/dependency-resolver":{"rootComponents":false}}, + "packageJson":{}, + }); + let root = std::env::temp_dir().join("bit-ignored-manual-issue-test"); + let plain = collect(&root, &context, std::slice::from_ref(&component)).unwrap().unwrap(); + let plain_issues = plain[0]["issues"].as_array().unwrap(); + assert!( + plain_issues + .iter() + .any(|issue| issue["type"] == "MissingManuallyConfiguredPackages"), + ); + context["workspace"]["teambit.component/issues"] = + json!({"ignoreIssues":["MissingManuallyConfiguredPackages"]}); + let ignored = collect(&root, &context, &[component]).unwrap().unwrap(); + let ignored_issues = ignored[0]["issues"].as_array().unwrap(); + assert!( + ignored_issues + .iter() + .all(|issue| issue["type"] != "MissingManuallyConfiguredPackages"), + ); + assert!(ignored_issues.iter().all(Value::is_object)); +} + +/// Verifies that [`collect`] returns an empty issue list when given an empty [`Component`] slice. +#[test] +fn collect_with_empty_components_slice_returns_empty_vec() { + let context = json!({"versions":[],"models":[],"targets":[],"workspace":{},"packageJson":{}}); + let root = std::env::temp_dir().join("bit-empty-issue-test"); + assert_eq!(collect(&root, &context, &[]).unwrap(), Some(Vec::new())); +} diff --git a/native/cli/src/commands/status/js.rs b/native/cli/src/commands/status/js.rs new file mode 100644 index 000000000000..6d612b768a08 --- /dev/null +++ b/native/cli/src/commands/status/js.rs @@ -0,0 +1,145 @@ +//! `JavaScript` value semantics the Node.js CLI relies on, over `serde_json` [`Value`] items. +//! +//! Exposes [`decline`], [`nullish`], [`truthy`], [`text`], [`array`], and [`object`] coercion helpers. + +use serde_json::{Map, Value}; +use std::io; + +/// Constructs an [`io::Error`] indicating that native status execution should decline (`reason`). +#[must_use] +#[inline] +pub fn decline(reason: impl Into) -> io::Error { + io::Error::other(reason.into()) +} + +/// Nullish coalescing helper (`value ?? fallback`) that treats `Some(Value::Null)` and `None` identically. +#[must_use] +#[inline] +pub fn nullish(value: Option<&Value>) -> Option<&Value> { + value.filter(|value| !value.is_null()) +} + +/// Evaluates `JavaScript` truthiness of an optional JSON [`Value`]. +#[must_use] +#[inline] +pub fn truthy(value: Option<&Value>) -> bool { + match value { + None | Some(Value::Null) => false, + Some(Value::Bool(value)) => *value, + Some(Value::Number(number)) => number + .as_f64() + .is_some_and(|number| number != 0.0), + Some(Value::String(text)) => !text.is_empty(), + Some(_) => true, + } +} + +/// An array index key, which JavaScript objects order before every other +/// key. Values keyed that way would serialize in a different order than +/// they were inserted, so they decline. +#[inline] +fn index_like(key: &str) -> bool { + key == "0" + || (!key.starts_with('0') && !key.is_empty() && key.bytes().all(|b| b.is_ascii_digit())) +} + +/// An object whose keys keep their insertion order in `JavaScript` too ([`string_map`], [`assign`]). +#[inline] +pub fn object(value: &Value) -> io::Result<&Map> { + let map = value.as_object().ok_or_else(|| decline("status expected an object"))?; + if map.keys().any(|key| index_like(key)) { + return Err(decline("status index-like object key")); + } + Ok(map) +} + +/// `map(value)` of the status policy: `undefined` is `{}`, anything but an +/// object of strings declines. +pub fn string_map(value: Option<&Value>) -> io::Result> { + let Some(value) = value else { return Ok(Map::new()) }; + let map = object(value).map_err(|_| decline("status complex dependency policy"))?; + if map + .values() + .any(|value| !value.is_string()) + { + return Err(decline("status complex dependency policy")); + } + Ok(map.clone()) +} + +/// `Object.assign(target, source)`: existing keys keep their position. +#[inline] +pub fn assign(target: &mut Map, source: &Map) { + for (key, value) in source { + target.insert(key.clone(), value.clone()); + } +} + +/// `value ?? []` for an array field; anything else declines. +#[inline] +pub fn array<'v>(value: Option<&'v Value>, what: &str) -> io::Result<&'v [Value]> { + match nullish(value) { + None => Ok(&[]), + Some(Value::Array(items)) => Ok(items), + Some(_) => Err(decline(format!("status {what} is not an array"))), + } +} + +/// `String(value)` for the strings and absent values identities hold. +#[inline] +pub fn text(value: Option<&Value>) -> io::Result<&str> { + match value { + Some(Value::String(text)) => Ok(text), + _ => Err(decline("status expected a string")), + } +} + +/// `target.identity + (target.version ? '@' + target.version : '')`. +pub fn full_identity(target: &Value) -> io::Result { + let identity = + match non_empty_str(target.get("identity")).or_else(|| non_empty_str(target.get("id"))) { + Some(identity) => std::borrow::Cow::Borrowed(identity), + None => std::borrow::Cow::Owned(base_target_identity(target)?), + }; + if identity.contains('@') { + return Ok(identity.into_owned()); + } + match non_empty_str(target.get("version")) + .or_else(|| non_empty_str(target.get("head"))) + .or_else(|| non_empty_str(target["id"].get("version"))) + { + Some(version) => Ok(format!("{identity}@{version}")), + None => Ok(identity.into_owned()), + } +} + +fn base_target_identity(target: &Value) -> io::Result { + let object = target["id"].as_object().ok_or_else(|| decline("status expected a string"))?; + let name = text(object.get("name"))?.trim(); + if name.is_empty() { + return Err(decline("status expected a string")); + } + match non_empty_str(object.get("scope")) { + Some(scope) => Ok(format!("{scope}/{name}")), + None => Ok(name.to_owned()), + } +} + +#[inline] +fn non_empty_str(value: Option<&Value>) -> Option<&str> { + value + .and_then(Value::as_str) + .map(str::trim) + .filter(|text| !text.is_empty()) +} + +/// `identity.split('@')[0]`. +#[must_use] +#[inline] +pub fn without_version(identity: &str) -> &str { + let identity = identity.trim(); + identity + .split_once('@') + .filter(|(base, _)| !base.trim().is_empty()) + .map_or(identity, |(base, _)| base.trim()) +} diff --git a/native/cli/src/commands/status/lane_refresh.rs b/native/cli/src/commands/status/lane_refresh.rs new file mode 100644 index 000000000000..14ead26bb10b --- /dev/null +++ b/native/cli/src/commands/status/lane_refresh.rs @@ -0,0 +1,196 @@ +//! Retained main-history refresh for `status --lanes`. +//! +//! [`Refreshes`] queries [`Engine`] to refresh main-lane component heads when inspecting lane updates. + +use super::{ + Engine, + js::{array, decline, text}, +}; +use serde_json::{Value, json}; +use std::{collections::HashSet, io, path::Path}; + +/// Token, update payload, component count, and object count for a main-lane status refresh. +type Main = (Value, Value, usize, usize); +/// Engine, token, update payload, and component count for a forked-lane status refresh. +type Forked = (Engine, Value, Value, usize); + +/// Prepared main-lane and forked-lane head refreshes for `bit status --lanes`. +pub(super) struct Refreshes { + main: Option
, + forked: Option, +} + +impl Refreshes { + pub(super) fn prepare( + engine: &mut Engine, + root: &Path, + context: &Value, + result: &Value, + lanes: bool, + ) -> io::Result { + let current = text(result.get("currentLaneId"))?.trim(); + let is_main = current.is_empty() || current == "main" || current.ends_with("/main"); + let main = + if lanes && !is_main { prepare(engine, root, context, result, current)? } else { None }; + let forked = if lanes { + forked_lane_id(context) + .filter(|id| id != current) + .map(|id| prepare_forked(root, context, result, current, &id)) + .transpose()? + } else { + None + }; + Ok(Self { main, forked }) + } + + #[inline] + pub(super) fn attach(&self, result: &mut Value, context: &Value) { + if let Some(forked) = forked_lane_id(context) { + result["forkedLaneId"] = Value::String(forked); + } + if let Some((_, updates, _, _)) = &self.main { + result["pendingUpdatesFromMain"] = updates["pendingUpdatesFromMain"].clone(); + } + if let Some((_, _, updates, _)) = &self.forked { + result["updatesFromForked"] = updates["updatesFromForked"].clone(); + } + } + + #[inline] + pub(super) fn progress(&self, context: &Value) -> super::loader::Progress { + super::loader::Progress { + main_count: self.main.as_ref().map_or(0, |(_, _, count, _)| *count), + main_objects: self.main.as_ref().map_or(0, |(_, _, _, objects)| *objects), + forked_count: self.forked.as_ref().map_or(0, |(_, _, _, count)| *count), + forked_id: forked_lane_id(context), + ..Default::default() + } + } + + #[inline] + pub(super) fn apply(&mut self, engine: &mut Engine) -> io::Result<()> { + if let Some((token, _, _, _)) = &self.main { + super::super::import::apply_status_refresh(engine, token)?; + } + if let Some((forked_engine, token, _, _)) = &mut self.forked { + super::super::import::apply_status_refresh(forked_engine, token)?; + } + Ok(()) + } +} + +pub(super) fn prepare( + engine: &mut Engine, + root: &Path, + context: &Value, + result: &Value, + current_lane_id: &str, +) -> io::Result> { + let targets = main_targets(context)?; + if targets.is_empty() { + return Ok(None); + } + let ids = targets + .iter() + .map(|target| text(target.get("identity")).map(|id| id.trim().to_owned())) + .collect::>>()?; + let prepared = super::super::import::prepare_status_refresh(engine, root, &ids)?; + let token = prepared.get("token").ok_or_else(|| decline("status main refresh token"))?; + let updates: Value = engine.request(&json!({ + "kind":"statusLaneUpdates", "root":root,"token":token, + "currentLaneId":current_lane_id,"targets":targets, + "duringMerge":result["componentsDuringMergeState"], + }))?; + array(updates.get("pendingUpdatesFromMain"), "pending main updates")?; + let count = parse_refresh_metric(&prepared, "refreshCount", "status main refresh count")?; + let objects = parse_refresh_metric(&prepared, "refreshObjects", "status main refresh objects")?; + Ok(Some((token.clone(), updates, count, objects))) +} + +#[inline] +fn parse_refresh_metric(prepared: &Value, key: &str, label: &str) -> io::Result { + prepared[key] + .as_u64() + .and_then(|count| usize::try_from(count).ok()) + .ok_or_else(|| decline(label)) +} + +fn main_targets(context: &Value) -> io::Result> { + let known = array(context.get("models"), "models")? + .iter() + .filter_map(|model| { + let scope = model["scope"] + .as_str() + .map(str::trim) + .filter(|val| !val.is_empty())?; + let name = model["name"] + .as_str() + .map(str::trim) + .filter(|val| !val.is_empty())?; + Some(format!("{scope}/{name}")) + }) + .collect::>(); + Ok(array(context.get("targets"), "targets")? + .iter() + .filter(|target| { + target["locallyRemoved"] != true + && target["version"] + .as_str() + .is_some_and(|version| !version.trim().is_empty()) + && target["identity"] + .as_str() + .is_some_and(|id| known.contains(id.trim())) + }) + .map(|target| json!({"identity":target["identity"],"version":target["version"]})) + .collect::>()) +} + +pub(super) fn prepare_forked( + root: &Path, + context: &Value, + result: &Value, + current_lane_id: &str, + forked_lane_id: &str, +) -> io::Result { + let mut engine = Engine::new()?; + let prepared = + super::super::import::prepare_status_forked_refresh(&mut engine, root, forked_lane_id)?; + let token = prepared.get("token").ok_or_else(|| decline("status forked refresh token"))?; + let targets = array(context.get("targets"), "targets")? + .iter() + .filter(|target| { + target["version"] + .as_str() + .is_some_and(|version| !version.trim().is_empty()) + }) + .map(|target| json!({"identity":target["identity"],"version":target["version"]})) + .collect::>(); + let updates: Value = engine.request(&json!({ + "kind":"statusLaneUpdates", "root":root,"token":token, + "currentLaneId":current_lane_id,"forkedLaneId":forked_lane_id, + "targets":targets,"duringMerge":result["componentsDuringMergeState"], + }))?; + array(updates.get("updatesFromForked"), "forked lane updates")?; + let count = parse_refresh_metric(&prepared, "refreshCount", "status forked refresh count")?; + Ok((engine, token.clone(), updates, count)) +} + +#[inline] +fn forked_lane_id(context: &Value) -> Option { + if let Some(id) = context["forkedFrom"] + .as_str() + .map(str::trim) + .filter(|val| !val.is_empty()) + { + return Some(id.to_owned()); + } + let scope = context["forkedFrom"]["scope"] + .as_str() + .map(str::trim) + .filter(|val| !val.is_empty())?; + let name = context["forkedFrom"]["name"] + .as_str() + .map(str::trim) + .filter(|val| !val.is_empty())?; + Some(format!("{scope}/{name}")) +} diff --git a/native/cli/src/commands/status/loader.rs b/native/cli/src/commands/status/loader.rs new file mode 100644 index 000000000000..d5723ec0c730 --- /dev/null +++ b/native/cli/src/commands/status/loader.rs @@ -0,0 +1,84 @@ +//! `StatusCmd.report` progress messages after status preflight. +//! +//! [`Progress`] and [`render`] flush buffered startup, load, and issue stdout around [`Output`] spinner phases. + +use bit_install::output::Output; +use std::io; + +/// Buffered stdout and imported object counts rendered around `StatusCmd.report` progress spinners. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub(super) struct Progress { + pub(super) startup_stdout: String, + pub(super) load_stdout: String, + pub(super) issue_stdout: String, + pub(super) main_count: usize, + pub(super) main_objects: usize, + pub(super) forked_count: usize, + pub(super) forked_id: Option, +} + +/// Emits `fetching status`, component load counts, and lane import progress messages onto `output`. +pub(super) fn render( + output: &Output, + loaded: usize, + loads: usize, + progress: &Progress, +) -> io::Result<()> { + output.status("fetching status"); + render_loads(output, loaded, loads, progress); + if progress.main_count > 0 { + let reason = "main components of the current lane to check for updates"; + output.status(&format!("importing {} components {reason}", progress.main_count)); + let (imported, unit) = if progress.main_objects > 0 { + (progress.main_objects, "objects") + } else { + (progress.main_count, "components") + }; + output.status(&format!("successfully imported {imported} {unit} {reason}")); + } + if progress.forked_count > 0 { + let reason = forked_lane_reason(progress.forked_id.as_deref()); + output.status(&format!("importing {} components {reason}", progress.forked_count)); + output.status(&format!("successfully imported {} objects {reason}", progress.forked_count)); + } + Ok(()) +} + +/// Renders `loading component(s)` spinner lines and flushes buffered load/issue stdout. +#[inline] +fn render_loads(output: &Output, loaded: usize, loads: usize, progress: &Progress) { + let mut printed_load_stdout = false; + if loaded > 0 { + for _ in 0..loads { + output.status(&format!("loading {loaded} component(s)")); + if !printed_load_stdout && !progress.load_stdout.is_empty() { + output.raw(&progress.load_stdout); + printed_load_stdout = true; + } + } + } + if !printed_load_stdout && !progress.load_stdout.is_empty() { + output.raw(&progress.load_stdout); + } + if !progress.issue_stdout.is_empty() { + output.raw(&progress.issue_stdout); + } +} + +#[inline] +fn forked_lane_reason(forked_id: Option<&str>) -> String { + let mut parts = forked_id + .into_iter() + .flat_map(|id| id.split('/')) + .map(str::trim) + .filter(|part| !part.is_empty()); + let Some(first) = parts.next() else { + return "for fetching lane".to_owned(); + }; + let mut reason = format!("for fetching lane {first}"); + for part in parts { + reason.push('/'); + reason.push_str(part); + } + reason +} diff --git a/native/cli/src/commands/status/policy.rs b/native/cli/src/commands/status/policy.rs new file mode 100644 index 000000000000..709115fbfa74 --- /dev/null +++ b/native/cli/src/commands/status/policy.rs @@ -0,0 +1,380 @@ +//! The dependency policy inputs of one component and its status metadata, +//! with the environment and component `VariantPolicy` precedence used by +//! the dependency resolver. Pending-configuration projections decline. +//! +//! Re-exports [`current_metadata`], [`peer_metadata`], [`model_of`], and [`pending_context`] over [`Workspace`] policies ([`RESOLVER`], [`FIELDS`], [`PACKAGE_FIELDS`]). + +pub use metadata::{current_metadata, peer_metadata}; +pub(super) use model::model_of; +pub(super) use pending::context as pending_context; + +mod component; +mod extensions; +mod metadata; +mod model; +mod names; +mod pending; +#[cfg(test)] +mod policy_tests; +mod projection; +mod workspace; + +use super::{ + grammar, + js::{array, assign, decline, full_identity, nullish, string_map, text}, +}; +use model::{dependencies, stored_version, validate_harmony_version}; +use names::{extension_identity, identity, names}; +use serde_json::{Map, Value, json}; +use std::io; +use workspace::{Workspace, read as workspace}; + +const RESOLVER: &str = "teambit.dependencies/dependency-resolver"; +const FIELDS: [&str; 3] = ["dependencies", "devDependencies", "peerDependencies"]; +const PACKAGE_FIELDS: [&str; 3] = + ["packageDependencies", "devPackageDependencies", "peerPackageDependencies"]; + +const NODE_DEV_DEPENDENCIES: [(&str, &str); 2] = + [("@types/jest", "26.0.20"), ("@types/node", "22.10.5")]; +const NODE_PEERS: [(&str, &str); 2] = + [("react", "^17.0.0 || ^18.0.0 || ^19.0.0"), ("react-dom", "^17.0.0 || ^18.0.0 || ^19.0.0")]; + +/// Effective dependency policy, merged extensions, overrides, and package naming for one component. +pub struct Policy { + pub input: Value, + pub extensions: Vec, + pub model_packages: [Map; 3], + pub overrides: Value, + pub binding_prefix: String, + pub package_name: String, + pub merge_conflict: bool, +} + +impl Policy { + pub fn ignored_issues(&self) -> io::Result> { + let config = self.extensions + .iter() + .find(|entry| component::matches_aspect(entry, "teambit.component/issues")); + array( + config.and_then(|entry| entry["config"].get("ignoreIssues")), + "component ignoreIssues", + )? + .iter() + .filter_map(|value| match text(Some(value)) { + Ok(item) => { + let trimmed = item.trim(); + (!trimmed.is_empty()).then(|| Ok(trimmed.to_string())) + } + Err(error) => Some(Err(error)), + }) + .collect() + } + + pub fn issues(&self, dependencies: &Value) -> io::Result { + let mut issues = array(dependencies.get("issues"), "dependency issues")?.to_vec(); + issues.retain(|issue| issue["type"] != "PinnedDependencies"); + if self.merge_conflict { + issues.push(json!({"type":"MergeConfigHasConflict","data":"true"})); + } + let pinned = self.pinned_dependencies(dependencies)?; + if !pinned.is_empty() { + let data = serde_json::to_string(&pinned).map_err(io::Error::other)?; + issues.push(json!({"type":"PinnedDependencies","data":data})); + } + Ok(json!(issues)) + } + + fn workspace_base_ids(&self) -> io::Result> { + Ok(array(self.input["resolution"]["sources"].get("bitmap"), "bitmap sources")? + .iter() + .filter_map(Value::as_str) + .map(str::trim) + .map(|id| { + id.rfind('@') + .filter(|&sep| sep > 0) + .map_or(id, |sep| id[..sep].trim()) + }) + .filter(|id| !id.is_empty()) + .collect()) + } + + fn pinned_dependencies(&self, dependencies: &Value) -> io::Result> { + let workspace_ids = self.workspace_base_ids()?; + let entries = array(dependencies["state"].get("entries"), "dependency entries")?; + let mut pinned = Vec::new(); + for indices in array(dependencies["state"].get("components"), "dependency lifecycles")? { + for index in array(Some(indices), "dependency indices")? { + let entry = index + .as_u64() + .and_then(|offset| entries.get(offset as usize)) + .ok_or_else(|| decline("status dependency entry"))?; + if let Some(formatted) = format_pinned_entry(entry, &workspace_ids)? { + pinned.push(formatted); + } + } + } + Ok(pinned) + } +} + +fn format_pinned_entry( + entry: &Value, + workspace_ids: &std::collections::HashSet<&str>, +) -> io::Result> { + if entry.get("versionFromConfig") != Some(&Value::Bool(true)) { + return Ok(None); + } + let identity = text(entry.get("identity"))?.trim(); + let (base_id, version) = identity + .rsplit_once('@') + .map_or((identity, ""), |(base, ver)| (base.trim(), ver.trim())); + if !workspace_ids.contains(base_id) { + return Ok(None); + } + let package = entry + .get("packageName") + .and_then(Value::as_str) + .map(str::trim) + .filter(|pkg| !pkg.is_empty()) + .unwrap_or(base_id); + Ok(Some(format!("{package}@{version}"))) +} + +#[inline] +pub fn status_policy(context: &Value, target: &Value) -> io::Result { + projected_policy(context, target, None) +} + +pub fn projected_policy( + context: &Value, + target: &Value, + projected: Option<&Value>, +) -> io::Result { + if target.get("recoveryConfig").is_some() { + return Err(decline("status projected configuration")); + } + let identity = text(target.get("identity"))?; + let model = model_of(context, identity); + let version = + stored_version(context, target, model, projected.filter(|version| !version.is_null()))?; + validate_harmony_version(version)?; + let projected_extensions = + json!({ "projectedExtensions": projection::extensions(context, target, version)? }); + let extensions = extensions::status_extensions(&projected_extensions)?; + let merged = component::Policy::read(context, target, &extensions)?; + let overrides = merged.overrides(); + let workspace = workspace(context)?; + let input = Sources { + context, + target, + version, + policy: &merged, + extensions: &extensions, + workspace: &workspace, + } + .input()?; + let (binding_prefix, package_name) = names(identity, model, &extensions); + Ok(Policy { + input, + extensions, + model_packages: model_packages(version)?, + overrides, + binding_prefix, + package_name, + merge_conflict: context["pendingConfigurations"][identity]["conflict"] == true, + }) +} + +pub(in crate::commands) fn environment_target( + context: &Value, + target: &Value, +) -> io::Result { + let model = model_of(context, text(target.get("identity"))?); + let version = + if model.is_some() { stored_version(context, target, model, None)? } else { None }; + let rows = projection::extensions(context, target, version)?; + let environment = rows + .iter() + .find(|row| component::matches_aspect(row, "teambit.envs/envs")) + .and_then(|row| row["config"]["env"].as_str()) + .map(str::to_string); + let mut config = Map::new(); + for row in rows { + let name = row + .get("name") + .and_then(Value::as_str) + .map_or_else(|| extension_identity(&row["extensionId"]), str::to_string); + config.insert(name, row["config"].clone()); + } + if let Some(environment) = environment { + config + .entry("teambit.envs/envs") + .or_insert_with(|| json!({}))["env"] = json!(environment); + } + let mut effective = target.clone(); + effective["config"] = json!({"extensions":config,"propagate":true}); + effective + .as_object_mut() + .ok_or_else(|| decline("status target"))? + .remove("componentConfig"); + Ok(effective) +} + +#[inline] +fn model_packages(version: Option<&Value>) -> io::Result<[Map; 3]> { + let field = |name: &str| string_map(version.and_then(|stored| stored.get(name))); + Ok([field(PACKAGE_FIELDS[0])?, field(PACKAGE_FIELDS[1])?, field(PACKAGE_FIELDS[2])?]) +} + +struct Sources<'s> { + context: &'s Value, + target: &'s Value, + version: Option<&'s Value>, + policy: &'s component::Policy, + extensions: &'s [Value], + workspace: &'s Workspace, +} + +impl Sources<'_> { + fn input(&self) -> io::Result { + let mut merge = merge_policy(self.target)?; + let identity = text(self.target.get("identity"))?; + if let Some(auto) = self.context["pendingConfigurations"][identity].get("auto") { + for (field, additions) in merge + .iter_mut() + .zip(component::automatic(auto)?) + { + assign(field, &additions); + } + } + let mut input = json!({ + "policies": self.policies(&merge)?, + "finish": self.finish()?, + "resolution": self.resolution(&merge)?, + }); + input["resolution"]["grammar"] = grammar::dependency(&input); + Ok(input) + } + + #[inline] + fn field(&self, name: &str) -> io::Result> { + string_map(self.version.and_then(|stored| stored.get(name))) + } + + fn policies(&self, merge: &[Map; 3]) -> io::Result { + let mut automatic = self.policy.automatic.clone(); + for (policy, merge) in automatic.iter_mut().zip(merge) { + assign(policy, merge); + } + Ok(json!({ + "modelPeers": self.field("peerPackageDependencies")?, + "packagePolicy": { + "dependencies": self.workspace.package_json[0], + "peerDependencies": self.workspace.package_json[2], + }, + "workspacePolicy": self.workspace.policy, + "automatic": automatic, + })) + } + + fn finish(&self) -> io::Result { + Ok(json!({ + "manual": self.policy.manual, + "packageJson": self.workspace.package_json, + "selfIdentity": full_identity(self.target)?, + "environment": { + "policy": self.policy.env_own, + "workspace": self.workspace.policy, + "modelComponents": model_components(self.version)?, + "modelPackages": self.field("packageDependencies")?, + }, + })) + } + + fn resolution(&self, merge: &[Map; 3]) -> io::Result { + let extensions = + array(self.version.and_then(|stored| stored.get("extensions")), "extensions")?; + let resolver_data = extensions + .iter() + .find(|extension| component::matches_aspect(extension, RESOLVER)) + .and_then(|extension| extension.get("data")); + let prefix = nullish(resolver_data.and_then(|data| data.get("componentRangePrefix"))); + let ext_ids = self.extensions + .iter() + .filter_map(|extension| { + extension + .get("extensionId") + .or_else(|| extension.get("legacyId")) + .or_else(|| extension.get("id")) + }) + .map(extension_identity) + .collect::>(); + Ok(json!({ + "sources": { + "bitmap": bitmap_identities(self.context)?, + "lane": [], + "model": model_dependencies(self.version)?, + "componentConfig": {}, + }, + "extensions": ext_ids, + "grammar": {}, + "prefix": prefix, + "merge": merge, + })) + } +} + +fn merge_policy(target: &Value) -> io::Result<[Map; 3]> { + let Some(value) = nullish(target.get("autoDetectMergePolicy")) else { + return Ok([Map::new(), Map::new(), Map::new()]); + }; + match value.as_array().map(Vec::as_slice) { + Some([first, second, third]) => { + Ok([string_map(Some(first))?, string_map(Some(second))?, string_map(Some(third))?]) + } + _ => Err(decline("command automatic merge policy")), + } +} + +fn model_components(version: Option<&Value>) -> io::Result> { + array(version.and_then(|stored| stored.get("dependencies")), "dependencies")? + .iter() + .map(|dependency| { + let field = |name| nullish(dependency.get(name)).cloned().unwrap_or(Value::Null); + Ok(json!({ + "identity": identity(Some(dependency))?, + "packageName": field("packageName"), + "versionRange": field("versionRange"), + })) + }) + .collect() +} + +#[inline] +fn model_dependencies(version: Option<&Value>) -> io::Result> { + dependencies(version)? + .into_iter() + .map(|dependency| identity(Some(dependency))) + .collect() +} + +#[inline] +fn bitmap_identities(context: &Value) -> io::Result> { + array(context.get("targets"), "targets")? + .iter() + .map(full_identity) + .collect() +} + +#[inline] +pub(in crate::commands) fn export_package_config( + context: &Value, + target: &Value, + version: &Value, +) -> io::Result> { + let rows = projection::extensions(context, target, Some(version))?; + Ok(bit_install::workspace::aspects::from_model(&rows) + .into_iter() + .map(|entry| (entry.id, entry.config)) + .collect()) +} diff --git a/native/cli/src/commands/status/policy/component.rs b/native/cli/src/commands/status/policy/component.rs new file mode 100644 index 000000000000..ca8b6e4003ce --- /dev/null +++ b/native/cli/src/commands/status/policy/component.rs @@ -0,0 +1,231 @@ +//! `EnvPolicy.fromConfigObject`, `VariantPolicy.mergePolices`, and their +//! legacy forced/auto-detected projections. Later policies win per lifecycle. +//! +//! [`Policy`] merges [`FIELDS`] and [`RESOLVER`] dependency policies across environment and variant layers. + +#[cfg(test)] +mod tests; + +use super::{FIELDS, NODE_DEV_DEPENDENCIES, NODE_PEERS, RESOLVER}; +use crate::commands::status::js::{array, decline, nullish, object, text, truthy}; +use serde_json::{Map, Value, json}; +use std::{collections::HashSet, io}; + +type Fields = [Map; 3]; + +/// Merged manual, automatic, and environment-owned dependency policies for one component across [`FIELDS`]. +pub(super) struct Policy { + pub(super) manual: Fields, + pub(super) automatic: Fields, + pub(super) env_own: Map, +} + +/// Returns `true` when `entry` matches aspect `id` ignoring version suffixes. +#[inline] +pub(super) fn matches_aspect(entry: &Value, id: &str) -> bool { + crate::commands::update_cmd::matches_aspect(entry, id) +} + +struct Entry { + name: String, + version: String, + field: usize, + force: bool, +} + +impl Policy { + pub(super) fn read(context: &Value, target: &Value, extensions: &[Value]) -> io::Result { + let identity = text(target.get("identity"))?.trim(); + if identity.is_empty() { + return Err(decline("status component policy identity")); + } + let facts = &context["environmentFacts"][identity]; + let default = default_environment(); + let raw = nullish(facts.get("envPolicy")).unwrap_or(&default); + let mut entries = environment(raw)?; + let env_own = match nullish(facts.get("envOwnPolicy")) { + Some(own) => { + let own_entries = variant(own, Some(true))?; + let map = manifest(&own_entries); + entries.extend(own_entries); + map + } + None => Map::new(), + }; + entries.extend(registered(facts)?); + let config = extensions.iter().find(|entry| matches_aspect(entry, RESOLVER)); + if let Some(policy) = nullish(config.and_then(|entry| entry["config"].get("policy"))) { + entries.extend(variant(policy, None)?); + } + Ok(Self::project(merge(entries), env_own)) + } + + fn project(entries: Vec, env_own: Map) -> Self { + let mut manual = Fields::default(); + let mut automatic = Fields::default(); + let mut used = HashSet::with_capacity(entries.len()); + for entry in entries { + if !entry.force { + automatic[entry.field].insert(entry.name, json!(entry.version)); + } else if entry.version == "-" || used.insert(entry.name.clone()) { + manual[entry.field].insert(entry.name, json!(entry.version)); + } + } + Self { manual, automatic, env_own } + } + + #[inline] + pub(super) fn overrides(&self) -> Value { + let mut value = Map::new(); + for (field, map) in FIELDS.iter().zip(&self.manual) { + if !map.is_empty() { + value.insert((*field).to_string(), json!(map)); + } + } + Value::Object(value) + } +} + +#[inline] +pub(super) fn automatic(raw: &Value) -> io::Result { + let mut fields = Fields::default(); + for entry in variant(raw, None)? { + if !entry.force { + fields[entry.field].insert(entry.name, json!(entry.version)); + } + } + Ok(fields) +} + +#[inline] +fn default_environment() -> Value { + let dev: Map = NODE_DEV_DEPENDENCIES + .iter() + .map(|(name, version)| ((*name).to_string(), json!(version))) + .collect(); + let peers: Vec = NODE_PEERS + .iter() + .map(|(name, range)| json!({"name":name,"supportedRange":range})) + .collect(); + json!({"devDependencies":dev,"peers":peers}) +} + +fn registered(facts: &Value) -> io::Result> { + let mut entries = Vec::new(); + for policy in array(facts.get("registeredEnvPolicy"), "registered policies")? { + entries.extend(variant(policy, None)?); + entries = merge(entries); + } + Ok(entries) +} + +fn environment(raw: &Value) -> io::Result> { + let mut entries = variant(raw, Some(true))?; + let mut modern = Vec::new(); + for (name, field, version) in + [("peers", 2, "supportedRange"), ("dev", 1, "version"), ("runtime", 0, "version")] + { + for value in array(raw.get(name), "environment policy")? { + modern.push(entry( + text(value.get("name"))?, + value.get(version), + field, + truthy(value.get("force")), + )?); + } + } + entries.extend(unique(modern)); + Ok(merge(entries)) +} + +fn variant(raw: &Value, force: Option) -> io::Result> { + let raw = object(raw)?; + let mut entries = Vec::new(); + for (field, name) in FIELDS.iter().enumerate() { + let Some(value) = raw + .get(*name) + .filter(|value| truthy(Some(value))) + else { + continue; + }; + if let Some(values) = value.as_array() { + array_entries(values, field, force, &mut entries)?; + } else { + object_entries(value, field, force, &mut entries)?; + } + } + Ok(unique(entries)) +} + +fn object_entries( + value: &Value, + field: usize, + force: Option, + entries: &mut Vec, +) -> io::Result<()> { + for (name, value) in object(value)? { + if truthy(Some(value)) { + entries.push(entry(name, Some(value), field, force.unwrap_or(true))?); + } + } + Ok(()) +} + +fn array_entries( + values: &[Value], + field: usize, + force: Option, + entries: &mut Vec, +) -> io::Result<()> { + for value in values { + entries.push(entry( + text(value.get("name"))?, + value.get("version"), + field, + force.unwrap_or_else(|| truthy(value.get("force"))), + )?); + } + Ok(()) +} + +fn entry(name: &str, value: Option<&Value>, field: usize, force: bool) -> io::Result { + let name = name.trim(); + if name.is_empty() { + return Err(decline("status empty policy name")); + } + let value = value.ok_or_else(|| decline("status missing policy version"))?; + let version = value + .as_str() + .map(str::trim) + .filter(|version| !version.is_empty()) + .or_else(|| { + value + .get("version") + .and_then(Value::as_str) + .map(str::trim) + .filter(|version| !version.is_empty()) + }) + .ok_or_else(|| decline("status invalid policy version"))?; + Ok(Entry { name: name.to_string(), version: version.to_string(), field, force }) +} + +#[inline] +fn merge(mut entries: Vec) -> Vec { + entries.reverse(); + unique(entries) +} + +#[inline] +fn unique(mut entries: Vec) -> Vec { + let mut seen = HashSet::with_capacity(entries.len()); + entries.retain(|entry| seen.insert((entry.name.clone(), entry.field))); + entries +} + +#[inline] +fn manifest(entries: &[Entry]) -> Map { + entries + .iter() + .map(|entry| (entry.name.clone(), json!(entry.version))) + .collect() +} diff --git a/native/cli/src/commands/status/policy/component/tests.rs b/native/cli/src/commands/status/policy/component/tests.rs new file mode 100644 index 000000000000..4380f8846ede --- /dev/null +++ b/native/cli/src/commands/status/policy/component/tests.rs @@ -0,0 +1,105 @@ +//! Unit tests for component dependency [`Policy`] resolution and environment policy override precedence. +//! +//! Verifies [`policy`] manual dependency entries, `-` removals, and `resolveFromEnv` peer settings. + +use super::Policy; +use serde_json::{Value, json}; + +/// Constructs a component dependency [`Policy`] from a `dependency-resolver` policy JSON object. +fn policy(config: &Value) -> Policy { + Policy::read( + &json!({}), + &json!({"identity":"scope/name"}), + &[json!({"name":"teambit.dependencies/dependency-resolver","config":{"policy":config}})], + ) + .unwrap() +} + +/// Verifies that forced component dependency [`policy`] entries override environment defaults and retain `-` removals. +#[test] +fn forced_component_policy_overrides_environment_and_keeps_removals() { + let policy = policy(&json!({ + "dependencies":{"react":"1.2.3","same":"1"}, + "devDependencies":{"@types/node":"-","same":"2"}, + "peerDependencies":{"same":"3","react-dom":{"version":"4","resolveFromEnv":true}} + })); + assert_eq!( + policy.manual, + [ + json!({"react":"1.2.3"}) + .as_object() + .unwrap() + .clone(), + json!({"@types/node":"-","@types/jest":"26.0.20"}) + .as_object() + .unwrap() + .clone(), + json!({"react-dom":"4","same":"3"}) + .as_object() + .unwrap() + .clone(), + ], + ); +} + +#[test] +fn array_entries_keep_first_duplicate_and_force_controls_detection() { + let policy = policy(&json!({"dependencies":[ + {"name":"automatic","version":"1"}, + {"name":"automatic","version":"2","force":true}, + {"name":"forced","version":{"version":"3","resolveFromEnv":true},"force":true} + ]})); + assert_eq!( + policy.automatic[0], + json!({"automatic":"1"}) + .as_object() + .unwrap() + .clone(), + ); + assert_eq!( + policy.manual[0], + json!({"forced":"3"}) + .as_object() + .unwrap() + .clone(), + ); +} + +#[test] +fn environment_and_registered_policy_precedence_match_variant_policy() { + let context = json!({"environmentFacts":{"scope/name":{ + "envPolicy":{"dependencies":{"legacy":"1"},"runtime":[{"name":"legacy","version":"2"}], + "dev":[{"name":"tool","version":"3","force":true}], + "peers":[{"name":"peer","version":"4","supportedRange":"^4"}]}, + "registeredEnvPolicy":[{"dependencies":{"legacy":"5"}}] + }}}); + let policy = Policy::read(&context, &json!({"identity":"scope/name"}), &[]).unwrap(); + assert_eq!( + policy.manual[0], + json!({"legacy":"5"}) + .as_object() + .unwrap() + .clone(), + ); + assert_eq!( + policy.manual[1], + json!({"tool":"3"}) + .as_object() + .unwrap() + .clone(), + ); + assert_eq!( + policy.automatic[2], + json!({"peer":"^4"}) + .as_object() + .unwrap() + .clone(), + ); + assert!(!policy.automatic[0].contains_key("legacy")); +} + +#[test] +fn empty_extension_config_reads_default_jest_types_in_manual_dev_dependencies() { + let policy = Policy::read(&json!({}), &json!({"identity":"scope/name"}), &[]).unwrap(); + assert_eq!(policy.manual[1]["@types/jest"], "26.0.20"); +} diff --git a/native/cli/src/commands/status/policy/extensions.rs b/native/cli/src/commands/status/policy/extensions.rs new file mode 100644 index 000000000000..25989da3c60a --- /dev/null +++ b/native/cli/src/commands/status/policy/extensions.rs @@ -0,0 +1,160 @@ +//! `statusExtensions(version, target)`: the core aspects configured on the +//! stored version, with bitmap configuration applied, for components that +//! only use the built-in Node environment. +//! +//! Merges [`CORES`], stored version, and `.bitmap` extension entries into JSON [`Value`] arrays. + +#[cfg(test)] +mod tests; + +use super::super::js::{array, decline, nullish, object}; +use serde_json::{Map, Value, json}; +use std::io; + +/// Aspect ID for the dependency resolver (`teambit.dependencies/dependency-resolver`). +const RESOLVER: &str = "teambit.dependencies/dependency-resolver"; +/// Aspect ID for the deprecation extension (`teambit.component/deprecation`). +const DEPRECATION: &str = "teambit.component/deprecation"; +/// Aspect ID for the removal extension (`teambit.component/remove`). +const REMOVAL: &str = "teambit.component/remove"; +/// Built-in core aspect IDs accepted by `statusExtensions` without custom host hydration. +const CORES: [&str; 13] = [ + "teambit.envs/envs", + RESOLVER, + "teambit.component/dev-files", + "teambit.semantics/schema", + "teambit.pkg/pkg", + "teambit.preview/preview", + "teambit.compositions/compositions", + "teambit.docs/docs", + DEPRECATION, + "teambit.component/internalize", + "teambit.component/issues", + REMOVAL, + "teambit.pipelines/builder", +]; + +/// Core/custom aspect configurations after the shared native origin merge. +#[inline] +pub fn status_extensions(target: &Value) -> io::Result> { + array(target.get("projectedExtensions"), "projected extensions")? + .iter() + .map(projected_extension) + .collect() +} + +#[inline] +fn projected_extension(extension: &Value) -> io::Result { + if nullish(extension.get("extensionId")).is_some() + || nullish(extension.get("legacyId")).is_some() + || nullish(extension.get("id")).is_some() + { + return Ok(extension.clone()); + } + let name = extension["name"] + .as_str() + .map(str::trim) + .filter(|name| !name.is_empty()) + .ok_or_else(|| decline("status projected extension name"))?; + model_extension(extension, name) +} + +fn model_extension(extension: &Value, name: &str) -> io::Result { + let bare = bit_install::config::aspect_id_without_version(name); + if !CORES.contains(&bare) + && !bit_install::workspace::CORE_ENVS.contains(&bare) + && !core_aspect(bare) + { + return Err(decline("status configured model extension")); + } + let config = match nullish(extension.get("config")) { + None => json!({}), + Some(config) => { + validate_config(bare, object(config)?)?; + config.clone() + } + }; + Ok(json!({ "name": bare, "config": config })) +} + +#[inline] +fn validate_config(name: &str, config: &Map) -> io::Result<()> { + let valid = match name { + DEPRECATION => config.iter().all(|(key, value)| deprecation_field(key, value)), + REMOVAL => return validate_removal_config(config), + _ => true, + }; + if valid { Ok(()) } else { Err(decline("status unsupported core extension configuration")) } +} + +#[inline] +fn deprecation_field(key: &str, value: &Value) -> bool { + match key { + "__specific" => true, + "deprecate" => value.is_boolean(), + "range" => value.is_string() || value.is_null(), + "newId" => replacement_id(value), + _ => false, + } +} + +fn replacement_id(value: &Value) -> bool { + if value.is_null() + || value + .as_str() + .is_some_and(|text| text.trim().is_empty()) + { + return true; + } + value + .as_object() + .is_some_and(|id| { + id.get("name") + .and_then(Value::as_str) + .is_some_and(|name| !name.trim().is_empty()) + && id + .iter() + .all(|(key, value)| { + matches!(key.as_str(), "scope" | "name" | "version") && value.is_string() + }) + }) +} + +fn validate_removal_config(config: &Map) -> io::Result<()> { + if !config + .iter() + .all(|(key, value)| match key.as_str() { + "__specific" => true, + "removed" | "removeOnMain" => value.is_boolean(), + "range" => value + .as_str() + .is_some_and(|range| node_semver::Range::parse(range.trim()).is_ok()), + "snaps" => valid_snap_list(value), + _ => false, + }) + { + return Err(decline("status unsupported removal configuration")); + } + Ok(()) +} + +#[inline] +fn valid_snap_list(value: &Value) -> bool { + value + .as_array() + .is_some_and(|snaps| { + snaps + .iter() + .all(|snap| { + snap.as_str() + .is_some_and(|hash| !hash.trim().is_empty()) + }) + }) +} + +#[inline] +fn core_aspect(name: &str) -> bool { + include_str!("../../../../../../scopes/harmony/testing/load-aspect/core-aspects-ids.json") + .split('"') + .any(|id| id == name) +} diff --git a/native/cli/src/commands/status/policy/extensions/tests.rs b/native/cli/src/commands/status/policy/extensions/tests.rs new file mode 100644 index 000000000000..a3169b2ed865 --- /dev/null +++ b/native/cli/src/commands/status/policy/extensions/tests.rs @@ -0,0 +1,36 @@ +//! Unit tests for `teambit.component/remove` extension configuration validation via [`validate_removal_config`]. +//! +//! Verifies valid `removed`, `range`, and `snaps` configurations and rejects unknown or malformed fields. + +use super::validate_removal_config; +use serde_json::json; + +/// Verifies [`validate_removal_config`] accepts published `removed`, `range`, and `snaps` configurations. +#[test] +fn removal_configuration_accepts_published_range_snaps_and_false() { + for config in [ + json!({"removed":false}), + json!({"range":"^0.0.1"}), + json!({"snaps":["0123456789012345678901234567890123456789"]}), + json!({"removed":true,"removeOnMain":false}), + ] { + assert!(validate_removal_config(config.as_object().unwrap()).is_ok()); + } +} +/// Verifies that [`validate_removal_config`] rejects unknown keys and non-conforming field types. +#[test] +fn removal_configuration_rejects_unknown_fields_and_corrupt_values() { + for config in [ + json!({"removed":"false"}), + json!({"range":"not a range"}), + json!({"snaps":[false]}), + json!({"unknown":true}), + ] { + assert!(validate_removal_config(config.as_object().unwrap()).is_err()); + } +} + +#[test] +fn empty_removal_config_object_is_valid() { + assert!(validate_removal_config(json!({}).as_object().unwrap()).is_ok()); +} diff --git a/native/cli/src/commands/status/policy/metadata.rs b/native/cli/src/commands/status/policy/metadata.rs new file mode 100644 index 000000000000..8dc481085011 --- /dev/null +++ b/native/cli/src/commands/status/policy/metadata.rs @@ -0,0 +1,133 @@ +//! `currentStatusMetadata`: the component metadata `statusCompare` compares +//! with the stored version. +//! +//! [`current_metadata`] projects [`Policy`] and [`PACKAGE_FIELDS`] into status comparison [`Value`] payloads. + +use super::{ + super::js::{array, decline, text, truthy}, + PACKAGE_FIELDS, Policy, +}; +use serde_json::{Map, Value, json}; +use std::io; + +/// Builds `currentStatusMetadata(target, policy, output)` for comparison with the stored version (`undefined` fields omitted). +pub fn current_metadata(target: &Value, policy: &Policy, output: &Value) -> io::Result { + let state = &output["state"]; + let entries = array(state.get("entries"), "dependency entries")?; + let lifecycles = array(state.get("components"), "dependency lifecycles")?; + let dependencies = lifecycles + .iter() + .map(|indices| metadata_dependencies(indices, entries)) + .collect::>>()?; + let mut metadata = Map::new(); + if let Some(main) = target + .get("mainFile") + .filter(|val| { + !val.is_null() + && val + .as_str() + .is_none_or(|main| !main.trim().is_empty()) + }) + { + metadata.insert("mainFile".into(), main.clone()); + } + for (index, field) in ["dependencies", "devDependencies"].into_iter().enumerate() { + if let Some(value) = dependencies.get(index) { + metadata.insert(field.into(), value.clone()); + } + } + for (index, field) in PACKAGE_FIELDS.into_iter().enumerate() { + if let Some(value) = state + .get("packages") + .and_then(|packages| packages.get(index)) + { + metadata.insert(field.into(), value.clone()); + } + } + metadata.insert("bindingPrefix".into(), json!(policy.binding_prefix)); + metadata.insert("overrides".into(), policy.overrides.clone()); + metadata.insert("extensions".into(), projected_extensions(policy, output)?); + Ok(Value::Object(metadata)) +} + +/// Replaces each extension's `extensionId` in `policy.extensions` with its resolved version from `output`. +fn projected_extensions(policy: &Policy, output: &Value) -> io::Result { + let ids = array(output.get("extensions"), "extension versions")?; + let mut extensions = policy.extensions.clone(); + let mut selected = ids.iter(); + for extension in &mut extensions { + if ["extensionId", "legacyId", "id"] + .into_iter() + .any(|key| extension.get(key).is_some()) + { + let id = selected.next().ok_or_else(|| decline("status extension versions"))?; + extension["extensionId"] = super::projection::extension_id(text(Some(id))?)?; + } + } + if selected.next().is_some() { + return Err(decline("status extra extension versions")); + } + Ok(json!(extensions)) +} + +/// Peer component metadata for the published `ConsumerComponent` adapter. +#[inline] +pub fn peer_metadata(state: &Value) -> io::Result { + let entries = array(state.get("entries"), "peer dependency entries")?; + let lifecycles = array(state.get("components"), "dependency lifecycles")?; + metadata_dependencies(lifecycles.get(2).unwrap_or(&Value::Null), entries) +} + +fn metadata_dependencies(indices: &Value, entries: &[Value]) -> io::Result { + let indices = array(Some(indices), "dependency indices")?; + let rows = indices + .iter() + .map(|index| { + let entry = index + .as_u64() + .and_then(|index| usize::try_from(index).ok()) + .and_then(|index| entries.get(index)); + let entry = entry.ok_or_else(|| decline("status dependency entry"))?; + let mut row = Map::with_capacity(4); + row.insert("id".into(), identity_object(text(entry.get("identity"))?)?); + row.insert("relativePaths".into(), json!([])); + for field in ["packageName", "versionRange"] { + if let Some(value) = entry.get(field).filter(|val| non_empty_truthy(val)) { + row.insert(field.into(), value.clone()); + } + } + Ok(Value::Object(row)) + }); + rows.collect::>>().map(Value::Array) +} + +#[inline] +fn non_empty_truthy(val: &Value) -> bool { + truthy(Some(val)) + && val + .as_str() + .is_none_or(|text| !text.trim().is_empty()) +} + +/// `identityObject(value)`: `{ scope, name, version? }`. +#[inline] +fn identity_object(value: &str) -> io::Result { + let value = value.trim(); + let (name, version) = value + .split_once('@') + .map_or((value, None), |(name, ver)| { + (name.trim(), Some(ver.trim()).filter(|ver| !ver.is_empty())) + }); + let (scope, comp_name) = name + .split_once('/') + .map(|(scope, comp)| (scope.trim(), comp.trim())) + .filter(|(scope, comp)| !scope.is_empty() && !comp.is_empty()) + .ok_or_else(|| decline("status unscoped dependency"))?; + let mut id = Map::with_capacity(3); + id.insert("scope".into(), json!(scope)); + id.insert("name".into(), json!(comp_name)); + if let Some(version) = version { + id.insert("version".into(), json!(version)); + } + Ok(Value::Object(id)) +} diff --git a/native/cli/src/commands/status/policy/model.rs b/native/cli/src/commands/status/policy/model.rs new file mode 100644 index 000000000000..8beffb7a5418 --- /dev/null +++ b/native/cli/src/commands/status/policy/model.rs @@ -0,0 +1,187 @@ +//! Stored `Version` selection and Harmony dependency validation for status metadata. +//! +//! [`model_of`] locates the stored component [`Value`] model for a workspace identity. + +use crate::commands::status::js::{array, decline, nullish, truthy}; +use serde_json::Value; +use std::io; + +/// Finds the stored model [`Value`] in `context["models"]` matching `identity` (used by [`stored_version`]). +pub(in crate::commands::status) fn model_of<'c>( + context: &'c Value, + identity: &str, +) -> Option<&'c Value> { + let models = context.get("models").and_then(Value::as_array)?; + let bare_id = crate::commands::status::js::without_version(identity); + let (target_scope, target_name) = bare_id + .split_once('/') + .map_or(("", bare_id), |(scope, name)| (scope.trim(), name.trim())); + if target_name.is_empty() { + return None; + } + models + .iter() + .find(|model| { + let scope = model + .get("scope") + .and_then(Value::as_str) + .map(str::trim) + .unwrap_or_default(); + let name = model + .get("name") + .and_then(Value::as_str) + .map(str::trim) + .unwrap_or_default(); + name == target_name && (scope == target_scope || scope.is_empty()) + }) +} + +/// The stored `Version` object the target's bitmap version refers to. +pub(super) fn stored_version<'c>( + context: &'c Value, + target: &Value, + model: Option<&'c Value>, + projected: Option<&'c Value>, +) -> io::Result> { + let tagged = target + .get("version") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()); + match (tagged, model) { + (Some(_), None) => return Err(decline("status pending import")), + (None, Some(_)) => return Err(decline("status bitmap normalization required")), + (None, None) => return Ok(projected), + (Some(_), Some(model)) => validate_unscoped_model(target, model)?, + } + if projected.is_some() { + return Ok(projected); + } + let versions = array(context.get("versions"), "versions")?; + let Some(expected) = resolve_tag_reference(model, tagged.unwrap_or_default()) else { + return Err(decline("status version absent")); + }; + let ref_str = expected.as_str().map(str::trim); + let version = versions + .iter() + .find(|row| { + row.get(0) == Some(&expected) + || (ref_str.is_some() + && row + .get(0) + .and_then(Value::as_str) + .map(str::trim) + == ref_str) + }) + .and_then(|row| nullish(row.get(1))); + version.map(Some).ok_or_else(|| decline("status version absent")) +} + +fn resolve_tag_reference(model: Option<&Value>, tag: &str) -> Option { + let tag = tag.trim(); + if matches!(tag, "latest" | "head") { + model + .and_then(|model_val| nullish(model_val.get("head"))) + .cloned() + } else if is_hash(tag) { + Some(Value::String(tag.to_string())) + } else { + let lookup = |field: &str| { + nullish( + model + .and_then(|model_val| model_val.get(field)) + .and_then(|versions_val| versions_val.get(tag)), + ) + }; + lookup("orphanedVersions").or_else(|| lookup("versions")).cloned() + } +} + +fn validate_unscoped_model(target: &Value, model: &Value) -> io::Result<()> { + if !cfg!(windows) + && target["scoped"] != true + && (model["head"].is_string() + || model["versions"] + .as_object() + .is_some_and(|versions| !versions.is_empty()) + || model["orphanedVersions"] + .as_object() + .is_some_and(|versions| !versions.is_empty())) + && !model["state"]["versions"] + .as_object() + .is_some_and(|versions| { + versions + .values() + .any(|entry| entry["local"] == true) + }) + { + return Err(decline("status bitmap normalization required")); + } + Ok(()) +} + +#[inline] +fn is_hash(text: &str) -> bool { + let trimmed = text.trim(); + trimmed.len() == 40 && trimmed.bytes().all(|byte| byte.is_ascii_hexdigit()) +} + +/// `validateHarmonyVersion`. +pub(super) fn validate_harmony_version(version: Option<&Value>) -> io::Result<()> { + let Some(version) = version else { return Ok(()) }; + if !matches!(version.get("schema").and_then(Value::as_str), Some("1.0.0" | "2.0.0")) { + return Err(decline("status legacy component")); + } + for dependency in dependencies(Some(version))? { + if !is_valid_harmony_dep_id(dependency) { + return Err(decline("status unscoped dependency")); + } + if let Some(paths) = nullish(dependency.get("relativePaths")) + && paths + .as_array() + .is_none_or(|paths| !paths.is_empty()) + { + return Err(decline("status relative dependency paths")); + } + } + Ok(()) +} + +#[inline] +fn is_valid_harmony_dep_id(dependency: &Value) -> bool { + let id = dependency.get("id").unwrap_or(dependency); + if let Some(raw) = id.as_str() { + return raw + .trim() + .split_once('/') + .is_some_and(|(scope, rest)| !scope.trim().is_empty() && !rest.trim().is_empty()); + } + truthy(Some(id)) + && non_empty_part(id, dependency, "scope").is_some() + && non_empty_part(id, dependency, "name").is_some() +} + +#[inline] +fn non_empty_part<'a>(id: &'a Value, dependency: &'a Value, key: &str) -> Option<&'a str> { + id.get(key) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .or_else(|| { + dependency + .get(key) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + }) +} + +/// `[...(version?.dependencies ?? []), ...(version?.devDependencies ?? [])]`. +#[inline] +pub(super) fn dependencies(version: Option<&Value>) -> io::Result> { + let field = |name| array(version.and_then(|stored| stored.get(name)), name); + Ok(field("dependencies")? + .iter() + .chain(field("devDependencies")?) + .collect()) +} diff --git a/native/cli/src/commands/status/policy/names.rs b/native/cli/src/commands/status/policy/names.rs new file mode 100644 index 000000000000..3c06cb7f19d3 --- /dev/null +++ b/native/cli/src/commands/status/policy/names.rs @@ -0,0 +1,190 @@ +//! Default dependency binding prefixes and package names. +//! +//! [`default_prefix`] and [`package_name`] derive npm package names from [`component`] metadata. + +use super::{Map, Value, component, decline, io, nullish, text}; + +/// `@` of `owner.scope/name`. +#[inline] +pub(super) fn default_prefix(identity: &str) -> String { + let owner = identity + .trim() + .split('.') + .next() + .unwrap_or_default(); + format!( + "@{}", + owner + .split('/') + .next() + .unwrap_or_default(), + ) +} + +/// Computes `packageName(id, storedPrefix)` from `identity` and an optional `stored_prefix`. +pub(super) fn package_name(identity: &str, stored_prefix: Option<&str>) -> String { + let identity = identity.trim(); + let scope = identity + .split('/') + .next() + .unwrap_or_default(); + let prefix = stored_prefix + .map(str::trim) + .filter(|prefix| !prefix.is_empty()) + .map_or_else( + || { + format!( + "@{}", + scope + .split('.') + .next() + .unwrap_or_default(), + ) + }, + str::to_string, + ); + let name = identity.replace('/', "."); + let owner = prefix.strip_prefix('@').unwrap_or(&prefix); + let trimmed = name + .strip_prefix(owner) + .and_then(|rest| rest.strip_prefix('.')) + .filter(|_| prefix != "@bit") + .unwrap_or(&name); + format!("{prefix}/{trimmed}") +} + +/// Returns `(binding_prefix, package_name)` for `identity` from `model` and merged `extensions`. +#[inline] +pub(super) fn names( + identity: &str, + model: Option<&Value>, + extensions: &[Value], +) -> (String, String) { + let stored = model + .and_then(|model| model.get("bindingPrefix")) + .and_then(non_empty_str); + let prefix = stored.map_or_else(|| default_prefix(identity), str::to_string); + (prefix, configured_package_name(identity, stored, extensions)) +} + +fn configured_package_name(identity: &str, prefix: Option<&str>, extensions: &[Value]) -> String { + let identity = super::super::js::without_version(identity); + let Some(pkg) = + extensions.iter().find(|entry| component::matches_aspect(entry, "teambit.pkg/pkg")) + else { + return package_name(identity, prefix); + }; + let (scope, name) = identity + .split_once('/') + .map_or(("", identity), |(scope, name)| (scope.trim(), name.trim())); + let id = bit_install::model::ComponentId { + scope: (!scope.is_empty()).then(|| scope.to_string()), + default_scope: None, + name: name.to_string(), + version: None, + }; + let mut aspects = Map::new(); + aspects.insert("teambit.pkg/pkg".to_string(), pkg["config"].clone()); + bit_install::workspace::component_package_name(&id, prefix, &aspects) +} + +pub(super) fn extension_identity(id: &Value) -> String { + if let Some(text) = non_empty_str(id).or_else(|| non_empty_str(&id["id"])) { + return strip_latest_or_head(text).to_string(); + } + let id_obj = id + .get("id") + .filter(|sub| sub.is_object()) + .unwrap_or(id); + let raw_name = non_empty_str(&id_obj["name"]) + .or_else(|| non_empty_str(&id["name"])) + .unwrap_or_default(); + let (name, inline_ver) = raw_name + .rsplit_once('@') + .map_or((raw_name, None), |(base, ver)| (base.trim(), Some(ver.trim()))); + let version = non_empty_str(&id_obj["version"]) + .or_else(|| non_empty_str(&id["version"])) + .or_else(|| non_empty_str(&id_obj["head"])) + .or_else(|| non_empty_str(&id["head"])) + .or(inline_ver) + .filter(|version| !matches!(*version, "" | "latest" | "head")) + .map_or_else(String::new, |version| format!("@{version}")); + match non_empty_str(&id_obj["scope"]).or_else(|| non_empty_str(&id["scope"])) { + Some(scope) => format!("{scope}/{name}{version}"), + None => format!("{name}{version}"), + } +} + +#[inline] +fn non_empty_str(value: &Value) -> Option<&str> { + value + .as_str() + .map(str::trim) + .filter(|text| !text.is_empty()) +} + +pub(super) fn identity(value: Option<&Value>) -> io::Result { + let outer = value.ok_or_else(|| decline("status dependency id"))?; + let id = outer.get("id").unwrap_or(outer); + if let Some(text) = id.as_str() { + return identity_from_str(text); + } + let part = |key| text(id.get(key).or_else(|| outer.get(key))); + let raw_name = part("name")?.trim(); + if raw_name.is_empty() { + return Err(decline("status dependency name")); + } + let (name, inline_ver) = raw_name + .rsplit_once('@') + .map_or((raw_name, None), |(base, ver)| (base.trim(), Some(ver.trim()))); + let suffix = resolve_dep_version_suffix(outer, id, inline_ver)?; + if let Some(scope) = part("scope") + .ok() + .map(str::trim) + .filter(|scope| !scope.is_empty()) + { + return Ok(format!("{scope}/{name}{suffix}")); + } + if name.contains('/') { + return Ok(format!("{name}{suffix}")); + } + Err(decline("status dependency scope")) +} + +#[inline] +fn identity_from_str(text: &str) -> io::Result { + let stripped = strip_latest_or_head(text); + if stripped.is_empty() { + return Err(decline("status dependency id")); + } + Ok(stripped.to_string()) +} + +fn resolve_dep_version_suffix( + outer: &Value, + id: &Value, + inline_ver: Option<&str>, +) -> io::Result { + let raw_version = nullish(id.get("version")) + .or_else(|| nullish(outer.get("version"))) + .or_else(|| nullish(id.get("head"))) + .or_else(|| nullish(outer.get("head"))); + let version = match raw_version { + None => inline_ver + .map(str::trim) + .filter(|ver| !matches!(*ver, "" | "latest" | "head")) + .unwrap_or(""), + Some(Value::String(version)) if matches!(version.trim(), "" | "latest" | "head") => "", + Some(Value::String(version)) => version.trim(), + Some(_) => return Err(decline("status dependency version")), + }; + Ok(if version.is_empty() { String::new() } else { format!("@{version}") }) +} + +#[inline] +fn strip_latest_or_head(text: &str) -> &str { + let text = text.trim(); + text.strip_suffix("@latest") + .or_else(|| text.strip_suffix("@head")) + .map_or(text, str::trim) +} diff --git a/native/cli/src/commands/status/policy/pending.rs b/native/cli/src/commands/status/policy/pending.rs new file mode 100644 index 000000000000..5af13728df82 --- /dev/null +++ b/native/cli/src/commands/status/policy/pending.rs @@ -0,0 +1,138 @@ +//! Pending merge configuration (`AspectsMerger.getMergeConfigCombined`), +//! projected without changing merge-conflict or repository state. +//! +//! [`context`] loads [`MergeConfig`] and [`read_proof`] receipts from [`scope_path`] for status policy projection. + +use super::super::js::{array, decline, text}; +use bit_install::{ + config::{MergeConfig, scope_path}, + policy::scope::read_proof, +}; +use serde_json::{Value, json}; +use std::{fs, io, path::Path}; + +/// Projects pending merge configurations (`AspectsMerger.getMergeConfigCombined`) onto `context`. +pub(in crate::commands::status) fn context(root: &Path, context: &Value) -> io::Result { + let input = Inputs::read(root)?; + let rows = array(Some(&input.unmerged), "unmerged configuration")?; + let mut output = context.clone(); + for target in array(context.get("targets"), "pending targets")? { + let identity = text( + target + .get("identity") + .filter(|val| !val.is_null()) + .or_else(|| target.get("id")), + )? + .trim(); + if !identity.is_empty() + && let Some(config) = configuration(&input.merge, rows, identity)? + { + output["pendingConfigurations"][identity] = config; + } + } + output["mergeWorkspacePolicy"] = json!(input.merge.workspace_policy()?); + let mut reads = context["environmentReadSet"] + .as_array() + .cloned() + .unwrap_or_default(); + reads.extend(array(context.get("controlReadSet"), "control proofs")?.iter().cloned()); + reads.extend(input.proofs); + output["environmentReadSet"] = json!(reads); + Ok(output) +} + +/// Parsed `merge-conflict` and `unmerged.json` files paired with their filesystem read proofs. +struct Inputs { + merge: MergeConfig, + unmerged: Value, + proofs: Vec, +} +impl Inputs { + fn read(root: &Path) -> io::Result { + if root.as_os_str().is_empty() + || root + .to_str() + .is_some_and(|path| path.trim().is_empty()) + { + return Err(decline("status pending configuration root")); + } + let paths = [root.join("merge-conflict"), scope_path(root).join("unmerged.json")]; + let proofs = paths + .iter() + .map(|path| read_proof(path)) + .collect::>>()?; + let file = optional(&paths[0])?.unwrap_or_default(); + let merge = MergeConfig::parse(&file); + let unmerged = optional(&paths[1])? + .map_or_else( + || Ok(json!([])), + |text| bit_object_import::parse_jsonc(text.as_bytes()), + )?; + Ok(Self { merge, unmerged, proofs }) + } +} + +fn configuration(merge: &MergeConfig, rows: &[Value], identity: &str) -> io::Result> { + let identity = super::super::js::without_version(identity); + let raw = merge.raw_component(identity)?; + let unmerged = unmerged_config(rows, identity); + let conflict = merge.has_conflict(identity); + if raw.is_none() && unmerged.is_none() && !conflict { + return Ok(None); + } + let combined = combine(raw.unwrap_or_else(|| json!({})), unmerged); + let normalized = MergeConfig::parse(&format!("[*] {identity}\n{combined}")); + Ok(normalized.component(identity)?.map(|component| { + json!({"config":component.config,"auto":component.auto_deps,"conflict":conflict}) + })) +} + +#[inline] +fn optional(path: &Path) -> io::Result> { + let metadata = match fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error), + }; + if !metadata.is_file() || metadata.len() > 1024 * 1024 { + return Err(decline("status pending configuration file")); + } + fs::read_to_string(path).map(Some) +} + +#[inline] +fn unmerged_config<'r>(rows: &'r [Value], identity: &str) -> Option<&'r Value> { + let identity = identity.trim(); + rows.iter() + .find(|row| { + super::super::js::without_version(&super::extension_identity(&row["id"])).trim() + == identity + }) + .and_then(|row| row.get("mergedConfig")) + .filter(|config| !config.is_null()) +} + +#[inline] +fn combine(mut target: Value, incoming: Option<&Value>) -> Value { + let Some(incoming) = incoming else { return target }; + merge(&mut target, incoming); + target +} + +/// lodash `mergeWith`: nested objects merge; existing arrays concatenate. +fn merge(target: &mut Value, incoming: &Value) { + if let (Some(left), Some(right)) = (target.as_array_mut(), incoming.as_array()) { + left.extend(right.clone()); + } else if let (Some(left), Some(right)) = (target.as_object_mut(), incoming.as_object()) { + for (name, value) in right { + match left.get_mut(name) { + Some(existing) => merge(existing, value), + None => { + left.insert(name.clone(), value.clone()); + } + } + } + } else { + *target = incoming.clone(); + } +} diff --git a/native/cli/src/commands/status/policy/policy_tests.rs b/native/cli/src/commands/status/policy/policy_tests.rs new file mode 100644 index 000000000000..c500397641a4 --- /dev/null +++ b/native/cli/src/commands/status/policy/policy_tests.rs @@ -0,0 +1,39 @@ +//! Unit tests for component ID [`package_name`] conversion and pending extension configuration projection. +//! +//! Verifies default `@scope/name` and custom prefix formatting alongside origin-ordered extension projection. + +use super::names::package_name; +use serde_json::json; + +/// Verifies [`package_name`] matches `componentIdToPackageName` across default and custom prefixes. +#[test] +fn package_name_matches_component_id_to_package_name() { + assert_eq!(package_name("my.scope/ui/button", None), "@my/scope.ui.button"); + assert_eq!(package_name("teambit.design/ui/x", Some("@teambit")), "@teambit/design.ui.x"); + assert_eq!(package_name("org.scope/a", Some("@bit")), "@bit/org.scope.a"); + assert_eq!(package_name("scope/a", None), "@scope/a"); +} + +/// Verifies that pending component extension configuration is projected alongside stored version extensions. +#[test] +fn pending_component_configuration_is_projected_by_origin() { + let version = + json!({"extensions":[{"name":"teambit.component/remove","config":{"removed":true}}]}); + let context = json!({"targets":[]}); + let target = json!({"identity":"scope/name","directory":"name","resolveConfig":true, + "mergedConfig":{"teambit.dependencies/dependency-resolver":{"policy":{"dependencies":{"pkg":"1"}}}}}); + let rows = super::projection::extensions(&context, &target, Some(&version)).unwrap(); + assert!( + rows.iter() + .any(|row| row["name"] == "teambit.dependencies/dependency-resolver"), + ); + assert!( + rows.iter() + .any(|row| row["name"] == "teambit.component/remove"), + ); +} + +#[test] +fn package_name_replaces_slashes_in_nested_namespaces_with_dots() { + assert_eq!(package_name("scope/a/b/c", None), "@scope/a.b.c"); +} diff --git a/native/cli/src/commands/status/policy/projection.rs b/native/cli/src/commands/status/policy/projection.rs new file mode 100644 index 000000000000..aa103c056fd9 --- /dev/null +++ b/native/cli/src/commands/status/policy/projection.rs @@ -0,0 +1,208 @@ +//! `AspectsMerger.merge` over captured model/configuration facts. The shared +//! installer [`Merger`] owns [`Origin`] precedence, removals, env filtering and ids. +//! +//! [`extensions`] projects [`ComponentId`] and workspace variant policies into effective status extensions. + +#[cfg(test)] +mod tests; + +use super::super::js::{array, decline, nullish, object, text, truthy}; +use bit_install::{ + model::ComponentId, + workspace::{ + aspects::{self, Merger, Origin, Resolver}, + by_root_dir_and_name, + }, +}; +use serde_json::{Map, Value, json}; +use std::io; + +/// Embedded JSON list of built-in core aspect IDs. +const CORES: &str = + include_str!("../../../../../../scopes/harmony/testing/load-aspect/core-aspects-ids.json"); +/// Aspect ID for the workspace variants extension (`teambit.workspace/variants`). +const VARIANTS: &str = "teambit.workspace/variants"; + +/// Merges stored model, variant, `component.json`, and `.bitmap` aspect configurations for `target`. +pub(super) fn extensions( + context: &Value, + target: &Value, + version: Option<&Value>, +) -> io::Result> { + let id = component_id(target)?; + let workspace = array(context.get("targets"), "projection targets")? + .iter() + .map(component_id) + .collect::>>()?; + let core = core_aspects()?; + let mut merger = Merger::new(Resolver { component: &id, workspace: &workspace }); + populate(&mut merger, core, context, target, version, &id.name)?; + merger + .finish() + .into_iter() + .map(row) + .collect() +} + +/// Returns the lazily deserialized slice of built-in core aspect IDs from [`CORES`]. +#[inline] +fn core_aspects() -> io::Result<&'static [String]> { + static CORE: std::sync::OnceLock> = std::sync::OnceLock::new(); + if let Some(core) = CORE.get() { + return Ok(core); + } + let parsed: Vec = serde_json::from_str(CORES).map_err(io::Error::other)?; + Ok(CORE.get_or_init(|| parsed)) +} + +fn populate( + merger: &mut Merger<'_>, + core: &[String], + context: &Value, + target: &Value, + version: Option<&Value>, + name: &str, +) -> io::Result<()> { + add_config(merger, target.get("componentConfig"), core, Origin::BitmapFile, true)?; + let identity = text(target.get("identity"))?.trim(); + add_config( + merger, + context["pendingConfigurations"][identity].get("config"), + core, + Origin::ConfigMerge, + false, + )?; + if target["resolveConfig"] == true { + add_config(merger, target.get("mergedConfig"), core, Origin::ConfigMerge, false)?; + } + add_config(merger, target["config"].get("extensions"), core, Origin::ComponentJsonFile, true)?; + let stored = aspects::from_model(array( + version.and_then(|version| version.get("extensions")), + "extensions", + )?); + let (specific, ordinary) = stored.into_iter().partition(aspects::is_specific); + merger.add(specific, Origin::ModelSpecific); + let propagate = !truthy(target.get("config")) || truthy(target["config"].get("propagate")); + if add_variants(merger, context, target, name, core, propagate)? { + merger.add(ordinary, Origin::ModelNonSpecific); + } + Ok(()) +} + +fn add_config( + merger: &mut Merger<'_>, + config: Option<&Value>, + core: &[String], + origin: Origin, + specific: bool, +) -> io::Result<()> { + let Some(config) = nullish(config) else { return Ok(()) }; + let mut entries = aspects::from_config_object(object(config)?, core); + if specific { + for entry in &mut entries { + if let Some(config) = entry.config.as_object_mut() { + config.insert("__specific".to_string(), json!(true)); + } + } + } + merger.add(entries, origin); + Ok(()) +} + +fn add_variants( + merger: &mut Merger<'_>, + context: &Value, + target: &Value, + name: &str, + core: &[String], + propagate: bool, +) -> io::Result { + if !propagate { + return Ok(false); + } + let Some(config) = nullish( + crate::commands::update_cmd::aspect_entry(&context["workspace"], VARIANTS) + .map(|(_, val)| val), + ) else { + return Ok(true); + }; + let variants = object(config)? + .iter() + .map(|(pattern, value)| Ok((pattern.trim().to_owned(), object(value)?.clone()))) + .collect::>>()?; + let directory = text( + target + .get("directory") + .filter(|val| !val.is_null()) + .or_else(|| target.get("rootDir")), + )? + .trim(); + let matched = by_root_dir_and_name(&variants, directory, name)?; + let lists = matched.extensions + .iter() + .map(|config| aspects::from_config_object(config, core)) + .collect(); + merger.add(aspects::merge_lists(lists), Origin::WorkspaceVariants); + Ok(matched.propagate) +} + +fn component_id(target: &Value) -> io::Result { + let raw = text(target.get("identity"))?.trim(); + let (identity, inline_ver) = raw + .rsplit_once('@') + .map_or((raw, None), |(id, ver)| (id.trim(), Some(ver.trim()))); + let (scope, name) = identity + .split_once('/') + .map(|(scope, name)| (scope.trim(), name.trim())) + .filter(|(scope, name)| !scope.is_empty() && !name.is_empty()) + .ok_or_else(|| decline("projection scoped component"))?; + let version = target["version"] + .as_str() + .map(str::trim) + .filter(|ver| !ver.is_empty()) + .or(inline_ver) + .filter(|ver| !ver.is_empty() && *ver != "latest" && *ver != "head") + .map(str::to_string); + Ok(ComponentId { + scope: Some(scope.to_string()), + default_scope: None, + name: name.to_string(), + version, + }) +} + +#[inline] +fn row(entry: aspects::AspectEntry) -> io::Result { + if entry.core { + let bare = bit_install::config::aspect_id_without_version(&entry.id); + let mut value = json!({"name":bare,"config":entry.config}); + if bare == "teambit.envs/envs" { + value["data"] = entry.data; + } + return Ok(value); + } + let mut row = Map::new(); + row.insert("extensionId".to_string(), extension_id(&entry.id)?); + row.insert("config".to_string(), entry.config); + Ok(Value::Object(row)) +} + +#[inline] +pub(super) fn extension_id(id: &str) -> io::Result { + let id = id.trim(); + let (identity, version) = id + .rsplit_once('@') + .map_or((id, None), |(id, version)| (id.trim(), Some(version.trim()))); + let (scope, name) = identity + .split_once('/') + .map(|(scope, name)| (scope.trim(), name.trim())) + .filter(|(scope, name)| !scope.is_empty() && !name.is_empty()) + .ok_or_else(|| decline("projection extension scope"))?; + let mut value = json!({"scope":scope,"name":name}); + if let Some(version) = + version.filter(|version| !version.is_empty() && !matches!(*version, "latest" | "head")) + { + value["version"] = json!(version); + } + Ok(value) +} diff --git a/native/cli/src/commands/status/policy/projection/tests.rs b/native/cli/src/commands/status/policy/projection/tests.rs new file mode 100644 index 000000000000..1177dd5974d7 --- /dev/null +++ b/native/cli/src/commands/status/policy/projection/tests.rs @@ -0,0 +1,59 @@ +//! Unit tests for aspect configuration origin precedence and variant specificity rules ([`RESOLVER`]) in workspace status. +//! +//! Verifies `.bitmap` (`componentConfig`), `component.json`, variant, and stored version merge order. + +use serde_json::{Value, json}; + +/// Canonical aspect ID for `teambit.dependencies/dependency-resolver`. +const RESOLVER: &str = "teambit.dependencies/dependency-resolver"; + +/// Builds a sample component target JSON fixture for aspect configuration projection tests. +fn target() -> Value { + json!({"identity":"scope/ui/button","directory":"comps/ui/button","version":"1.0.0"}) +} + +#[test] +fn origin_precedence_matches_aspects_merger() { + let mut target = target(); + target["componentConfig"] = json!({RESOLVER:{"policy":{"dependencies":{"bitmap":"1"}}}}); + target["config"] = + json!({"propagate":true,"extensions":{RESOLVER:{"policy":{"dependencies":{"file":"2"}}}}}); + let context = json!({"targets":[&target],"workspace":{"teambit.workspace/variants":{"*":{RESOLVER:{"policy":{"dependencies":{"variant":"3"}}}}}}}); + let version = json!({"extensions":[{"name":RESOLVER,"config":{"__specific":true,"policy":{"dependencies":{"stored":"4"}}}}]}); + let rows = super::extensions(&context, &target, Some(&version)).unwrap(); + assert_eq!( + rows[0]["config"], + json!({"policy":{"dependencies":{"bitmap":"1"}},"__specific":true}), + ); + target + .as_object_mut() + .unwrap() + .remove("componentConfig"); + let rows = super::extensions(&context, &target, Some(&version)).unwrap(); + assert_eq!( + rows[0]["config"], + json!({"policy":{"dependencies":{"file":"2"}},"__specific":true}), + ); +} + +#[test] +fn variant_specificity_exclusions_and_propagation_share_installer_rules() { + let target = target(); + let context = json!({"targets":[&target],"workspace":{"teambit.workspace/variants":{ + "*":{RESOLVER:{"policy":{"dependencies":{"global":"1"}}}}, + "comps/ui":{RESOLVER:{"policy":{"dependencies":{"directory":"2"}}},"propagate":false}, + "comps/ui, !comps/ui/excluded":{"teambit.pkg/pkg":{"packageJson":{"name":"@custom/{name}"}}}, + "{ui/**}":{RESOLVER:{"policy":{"dependencies":{"namespace":"3"}}}} + }}}); + let rows = super::extensions(&context, &target, None).unwrap(); + assert_eq!(rows.len(), 2); + assert_eq!(rows[0]["name"], "teambit.pkg/pkg"); + assert_eq!(rows[1]["config"]["policy"]["dependencies"], json!({"directory":"2"})); +} + +#[test] +fn target_without_variant_or_stored_config_projects_empty_extensions() { + let target = target(); + let context = json!({"targets":[&target],"workspace":{}}); + assert!(super::extensions(&context, &target, None).unwrap().is_empty()); +} diff --git a/native/cli/src/commands/status/policy/workspace.rs b/native/cli/src/commands/status/policy/workspace.rs new file mode 100644 index 000000000000..5548729dea31 --- /dev/null +++ b/native/cli/src/commands/status/policy/workspace.rs @@ -0,0 +1,93 @@ +//! `WorkspacePolicyFactory` and `package.json` precedence for status dependency facts. +//! +//! [`Workspace`] and [`read`] merge [`RESOLVER`] and root `package.json` [`FIELDS`] into a unified policy map. + +use super::{FIELDS, RESOLVER}; +use crate::commands::status::js::{assign, decline, nullish, object, string_map}; +use serde_json::{Map, Value, json}; +use std::io; + +/// The workspace policy merged with the root `package.json`, and the +/// `package.json` fields themselves. +pub(super) struct Workspace { + pub(super) policy: Value, + pub(super) package_json: [Map; 3], +} + +/// Reads and merges the workspace dependency resolver policy with the root `package.json` dependency fields. +pub(super) fn read(context: &Value) -> io::Result { + let policy = configured_policy(context)?; + let package_json = nullish(context.get("packageJson")); + let field = |name: &str| string_map(package_json.and_then(|json| json.get(name))); + let package_json = [field(FIELDS[0])?, field(FIELDS[1])?, field(FIELDS[2])?]; + let root = match policy { + Some(policy) => root_policy(policy)?, + None => [Map::new(), Map::new()], + }; + let mut dependencies = root[0].clone(); + assign(&mut dependencies, &package_json[1]); + assign(&mut dependencies, &package_json[0]); + let mut peers = root[1].clone(); + assign(&mut peers, &package_json[2]); + if let Some(merge) = nullish(context.get("mergeWorkspacePolicy")) { + let merged = root_policy(merge)?; + assign(&mut dependencies, &merged[0]); + assign(&mut peers, &merged[1]); + } + let policy = json!({ "dependencies": dependencies, "peerDependencies": peers }); + Ok(Workspace { policy, package_json }) +} + +/// Extracts `[dependencies, peerDependencies]` maps from a workspace dependency resolver `policy` object. +fn root_policy(policy: &Value) -> io::Result<[Map; 2]> { + for field in ["dependencies", "peerDependencies"] { + let Some(values) = nullish(policy.get(field)) else { continue }; + if !object(values)? + .iter() + .all(|(name, val)| !name.trim().is_empty() && is_valid_policy_version_entry(val)) + { + return Err(decline("status root dependency policy version")); + } + } + let entries = bit_install::policy::entries::from_workspace_config(policy); + let manifest = bit_install::policy::workspace_manifest(&entries); + Ok(manifest.map(|field| { + field + .into_iter() + .map(|(name, version)| (name.trim().to_owned(), json!(version.trim()))) + .collect() + })) +} + +#[inline] +fn configured_policy(context: &Value) -> io::Result> { + let resolver = nullish( + crate::commands::update_cmd::aspect_entry(&context["workspace"], RESOLVER) + .map(|(_, val)| val), + ); + let Some(policy) = nullish(resolver.and_then(|res| res.get("policy"))) else { + return Ok(None); + }; + object(policy)?; + Ok(Some(policy)) +} + +#[inline] +fn is_valid_policy_version_entry(value: &Value) -> bool { + let valid_preserve = value + .get("preserve") + .is_none_or(|preserve| preserve.is_null() || preserve.is_boolean()); + valid_preserve + && value + .as_str() + .map(str::trim) + .filter(|ver| !ver.is_empty()) + .or_else(|| { + value + .get("version") + .and_then(Value::as_str) + .map(str::trim) + .filter(|ver| !ver.is_empty()) + }) + .is_some() +} diff --git a/native/cli/src/commands/status/reading.rs b/native/cli/src/commands/status/reading.rs new file mode 100644 index 000000000000..cb5d33e30db8 --- /dev/null +++ b/native/cli/src/commands/status/reading.rs @@ -0,0 +1,231 @@ +//! Read, compute, prepare presentation, then publish status caches and retained history. +//! +//! [`read`] coordinates [`Engine`], [`capture`], [`grammar`], [`issues`], [`lane_refresh`], and [`report`] for `bit status`. + +use super::{ + Component, Engine, active_components, array, capture, current_lane_id, decline, grammar, + issues, lane_refresh, loader, report, result_targets, semver_values, sort_rows, status_capture, + text, +}; +use serde_json::{Value, json}; +use std::{io, path::Path}; + +/// The status result, after its history plans are +/// committed. `prepare` runs on the result and the context before the commit. +pub(super) enum ReadFailure { + Declined(io::Error), + Committed(io::Error), +} + +impl From for ReadFailure { + #[inline] + fn from(error: io::Error) -> Self { + Self::Declined(error) + } +} + +/// Reads, computes, prepares via `prepare`, and commits status caches and history for `root`. +pub(super) fn read( + root: &Path, + home: &Path, + flags: &report::Flags, + prepare: impl FnOnce(&Value, &Value) -> io::Result, +) -> Result<(Value, Prepared, loader::Progress), ReadFailure> { + let mut startup = super::autosync::Startup::default(); + let result = read_prepared(root, home, flags, prepare, &mut startup); + match result { + Err(ReadFailure::Declined(error)) if startup.committed => { + use std::io::Write; + io::stdout().write_all(startup.stdout.as_bytes()).map_err(ReadFailure::Committed)?; + Err(ReadFailure::Committed(error)) + } + result => result, + } +} + +/// Runs the full `bit status` capture, lane refresh, sorting, `prepare`, and commit pipeline. +fn read_prepared( + root: &Path, + home: &Path, + flags: &report::Flags, + prepare: impl FnOnce(&Value, &Value) -> io::Result, + startup: &mut super::autosync::Startup, +) -> Result<(Value, Prepared, loader::Progress), ReadFailure> { + let mut engine = Engine::new()?; + let (mut context, environments) = + super::environments::read_context(&mut engine, root, home, true, None, startup)?; + context["ignoreCircularDependencies"] = json!(flags.ignore_circular); + let (mut result, capture, graph_proofs) = + local_result(&mut engine, root, home, &context, Some(environments))?; + let mut refreshes = + lane_refresh::Refreshes::prepare(&mut engine, root, &context, &result, flags.lanes)?; + refreshes.attach(&mut result, &context); + attach_removed(&mut result, &capture, &context)?; + sort_rows(root, &mut result)?; + let prepared = prepare(&result, &context)?; + validate_graph(&mut engine, &graph_proofs)?; + capture::commit(&mut engine, root, &context, &capture).map_err(ReadFailure::Committed)?; + let mut progress = callback_progress(refreshes.progress(&context), &capture); + progress.startup_stdout.insert_str(0, &startup.stdout); + refreshes.apply(&mut engine).map_err(ReadFailure::Committed)?; + super::cleanup_temporary(root).map_err(ReadFailure::Committed)?; + Ok((result, prepared, progress)) +} + +pub(super) fn local_result( + engine: &mut Engine, + root: &Path, + home: &Path, + context: &Value, + environments: Option, +) -> io::Result<(Value, capture::Capture, Vec)> { + let capture = status_capture(engine, root, home, context, environments)?; + let compared: Value = engine.request( + &json!({"kind": "statusCompare", "root": root, "targets": capture.comparisons}), + )?; + let (issues, graph_proofs) = captured_issues(engine, root, context, &capture)?; + let issues = capture::issue_callbacks(context, &capture, &issues)?; + let targets = + result_targets(context, &capture.components, &capture.invalid, &compared, &issues)?; + let semver = grammar::snapshot(semver_values(context, &targets)?.iter().map(String::as_str)) + .ok_or_else(|| decline("status semver grammar limits"))?; + let current_lane_id = current_lane_id(context)?; + let mut result: Value = engine.request(&json!({ + "kind": "statusResult", + "currentLaneId": current_lane_id, + "targets": targets, + "models": context["models"], + "histories": context["histories"], + "semver": semver, + "unavailableOnMain": context["unavailableOnMain"], + "updateDependents": context["updateDependents"], + "duringMerge": during_merge(context)?, + }))?; + result["workspaceIssues"] = json!(super::workspace_issues::collect(root, &capture)?); + Ok((result, capture, graph_proofs)) +} + +fn during_merge(context: &Value) -> io::Result> { + context["unmerged"] + .as_array() + .into_iter() + .flatten() + .map(|row| { + let (scope, name) = crate::commands::version_history::build::row_scope_and_name(row) + .ok_or_else(|| decline("status unmerged identity"))?; + let (scope, name) = (scope.trim(), name.trim()); + Ok(if scope.is_empty() { name.to_owned() } else { format!("{scope}/{name}") }) + }) + .collect() +} + +fn status_issues( + engine: &mut Engine, + root: &Path, + context: &Value, + components: &[Component], +) -> io::Result<(Vec, Vec)> { + if let Some(issues) = issues::collect(root, context, components)? { + return Ok((issues, Vec::new())); + } + if !issues::ordinary_model_heads(context)? { + return Err(decline("status deprecated model issues require canonical loading")); + } + let response: Value = engine.request(&json!({"kind":"statusGraph","root":root,"versions":context["versions"],"components":components.iter().map(|component|json!({"identity":component.identity,"dependencies":component.dependencies})).collect::>()}))?; + let mut graph = components.to_vec(); + for row in array(response.get("external"), "external status graph")? { + graph.push(external_component(row)?); + } + let mut issue_context = context.clone(); + issue_context["models"] + .as_array_mut() + .ok_or_else(|| decline("status models"))? + .extend(array(response.get("models"), "external models")?.iter().cloned()); + issue_context["versions"] + .as_array_mut() + .ok_or_else(|| decline("status versions"))? + .extend(array(response.get("versions"), "external head versions")?.iter().cloned()); + let issues = issues::collect_graph(root, &issue_context, components, &graph)? + .ok_or_else(|| decline("status issue graph is incomplete"))?; + Ok((issues, array(response.get("readSet"), "status graph proofs")?.to_vec())) +} + +fn external_component(row: &Value) -> io::Result { + let identity = text(row.get("identity"))?.trim().to_owned(); + if identity.is_empty() { + return Err(decline("external status graph identity")); + } + let mut dependencies = Vec::new(); + for id in array(row.get("dependencies"), "external dependencies")? { + let trimmed = text(Some(id))?.trim(); + if !trimmed.is_empty() { + dependencies.push(trimmed.to_owned()); + } + } + Ok(Component { + ignored: Vec::new(), + identity, + package_name: String::new(), + dependencies, + issues: Value::Null, + }) +} + +#[inline] +fn validate_graph(engine: &mut Engine, proofs: &[Value]) -> io::Result<()> { + if proofs.is_empty() { + return Ok(()); + } + let response: Value = + engine.request(&json!({"kind":"dependencyFreshValidate","readSet":proofs}))?; + if response["fresh"] != true { + return Err(decline("status dependency graph changed before commit")); + } + Ok(()) +} + +fn attach_removed( + result: &mut Value, + capture: &capture::Capture, + context: &Value, +) -> io::Result<()> { + if capture.remotely_removed.is_empty() { + result["remotelySoftRemoved"] = json!([]); + return Ok(()); + } + let during = during_merge(context)?; + result["remotelySoftRemoved"] = json!( + capture.remotely_removed + .iter() + .map(|id| id.trim()) + .filter(|id| !id.is_empty() + && !during + .iter() + .any(|merged| merged == *id)) + .collect::>() + ); + Ok(()) +} + +#[inline] +fn callback_progress( + mut progress: loader::Progress, + capture: &capture::Capture, +) -> loader::Progress { + progress.startup_stdout = capture::callback_startup_stdout(capture); + progress.load_stdout = capture::callback_load_stdout(capture); + progress.issue_stdout = capture::callback_issue_stdout(capture); + progress +} + +#[inline] +fn captured_issues( + engine: &mut Engine, + root: &Path, + context: &Value, + capture: &capture::Capture, +) -> io::Result<(Vec, Vec)> { + let mut issue_context = context.clone(); + issue_context["environmentFacts"] = capture.environment_facts.clone(); + status_issues(engine, root, &issue_context, &active_components(context, &capture.components)?) +} diff --git a/native/cli/src/commands/status/report.rs b/native/cli/src/commands/status/report.rs new file mode 100644 index 000000000000..bb4d337a12ab --- /dev/null +++ b/native/cli/src/commands/status/report.rs @@ -0,0 +1,369 @@ +//! `bit status` without `--json`: `StatusCmd.report` and `formatStatusOutput`. +//! +//! Coordinates [`item`], [`lane_updates`], [`render`], and [`sections`] to format status output with [`Formatter`] and [`Chalk`]. + +pub(in crate::commands) use item::issue_output; + +mod item; +mod lane_updates; +mod render; +#[cfg(test)] +mod report_tests; +mod sections; + +use super::js::{array, decline, text}; +use crate::commands::{color::Chalk, workspace_command::format::Formatter}; +use serde_json::Value; +use std::{collections::HashMap, io}; + +const FAILURE: &str = "issues found"; +const WARNINGS: &str = "warnings found"; +const CLEAN: &str = r#"nothing to tag or export (use "bit create