diff --git a/.github/result b/.github/result new file mode 120000 index 00000000..2ab517c2 --- /dev/null +++ b/.github/result @@ -0,0 +1 @@ +/nix/store/ziibsvpd0llkiwq39sqsjs5y40zd41wi-pointbreak-0.8.0 \ No newline at end of file diff --git a/.github/workflows/cache-gc.yml b/.github/workflows/cache-gc.yml new file mode 100644 index 00000000..4b39566d --- /dev/null +++ b/.github/workflows/cache-gc.yml @@ -0,0 +1,67 @@ +name: Cache GC + +# Prunes the hestia binary cache so the Nix lane stays inside GitHub's 10GB +# per-repository cache quota, which it shares with ci.yml's rust-cache entries. +# +# Scope: this deletes only paths hestia itself pushed. Hestia tracks what is alive +# through its own "roots" (one per branch and system, e.g. `main-x86_64-linux`), and +# collects paths unreachable from any root once they fall out of the push grace period. +# Cache entries written by other actions — Swatinem/rust-cache, setup-node, the Nix +# installer — are not in hestia's manifest and are never considered. +# +# It has to run on the default branch: a pull request's cache scope is read-only towards +# the default branch and disappears with the PR, so a PR-scoped run cannot prune what the +# default branch owns. +# +# See docs/ci-architecture.md#store-caching. + +concurrency: + # Hestia handles concurrent pushes but not a concurrent GC: a second run's repack reads + # race the first run's post-commit deletes. Queue behind a running GC, never cancel it + # partway through a delete pass. + group: hestia-gc + cancel-in-progress: false + +on: + schedule: + # Daily and off-peak. Deliberately not 07:00 UTC, which is the nightly full flake + # check in nightly.yml. + - cron: "23 3 * * *" + workflow_dispatch: + inputs: + dry-run: + description: Plan only; do not repack, touch, or delete anything. + type: boolean + default: false + +permissions: + contents: read + +jobs: + gc: + name: prune the hestia cache + runs-on: ubuntu-latest + permissions: + # The cache deletes go through the REST API, which needs actions:write. This is the + # only workflow here that gets it. + actions: write + contents: read + steps: + # The action writes Nix configuration when it installs, so Nix has to exist first. + - uses: DeterminateSystems/nix-installer-action@main + + # Installs the released binary and exports HESTIA_BIN. Same SHA pin as the lanes + # that populate the cache — a GC from a different version than the writers is not + # a combination worth discovering at 03:23. + - uses: Mic92/hestia@fb239a2f72d4b6e26eec5425f289dea23b27a527 # v2 + + - name: Run garbage collection + env: + GITHUB_TOKEN: ${{ github.token }} + DRY_RUN: ${{ inputs.dry-run }} + run: | + if [ "$DRY_RUN" = "true" ]; then + "$HESTIA_BIN" gc --dry-run + else + "$HESTIA_BIN" gc + fi diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5fc9849a..59eec1b0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,7 +9,7 @@ permissions: contents: read env: - CARGO_TERM_COLOR: always + CARGO_TERM_COLOR: never RUST_BACKTRACE: 1 jobs: @@ -93,84 +93,167 @@ jobs: working-directory: extensions/vscode run: npm run check - test: - name: ${{ matrix.name }} - # Windows is the CI long pole and is spawn-bound (per-CreateProcess cost), so its - # ~226s execution is fanned across parallel slice-partitioned shards while ubuntu and - # macOS stay single legs. Lint runs on Linux only; the cfg(windows)/cfg(not(unix)) - # arms are type-checked once on a dedicated Windows check leg; the shard legs run - # tests only. All Windows legs share one rust-cache entry (shared-key) since they - # compile the identical test-binary set. - strategy: - fail-fast: false - matrix: - include: - - { - name: "test (ubuntu-latest)", - os: ubuntu-latest, - gate: lint, - run_tests: true, - } - - { - name: "test (macos-latest)", - os: macos-latest, - gate: check, - run_tests: true, - } - - { - name: "test (windows-latest check)", - os: windows-latest, - gate: check, - run_tests: false, - } - - { - name: "test (windows-latest 1/3)", - os: windows-latest, - run_tests: true, - shard: 1, - shards: 3, - } - - { - name: "test (windows-latest 2/3)", - os: windows-latest, - run_tests: true, - shard: 2, - shards: 3, - } - - { - name: "test (windows-latest 3/3)", - os: windows-latest, - run_tests: true, - shard: 3, - shards: 3, - } - runs-on: ${{ matrix.os }} + test-linux: + # Named for the platform, not the mechanism: alongside test (macos-latest) and + # test (windows-latest N/3) this is what makes the gate legible as covering all + # three. It does more than test — fmt, clippy, and the artifact build run here too — + # and unlike the other two it runs as sandboxed derivations rather than plain cargo. + # See docs/ci-architecture.md#why-keep-a-nix-lane. + name: test (ubuntu-latest) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: DeterminateSystems/nix-installer-action@main + + # Third approach here; the first two each hit a different GitHub ceiling. + # cache-nix-action tarred the whole /nix/store (4.87GB Linux + 4.46GB macOS + # against a 10GB STORAGE limit, so the platforms evicted each other). Magic Nix + # Cache stored one entry per store path (1788 of them), which tripped the API + # RATE limit and then disabled itself mid-run. Hestia packs paths into + # content-defined chunks — few large entries — so it avoids both. + # Uploads authenticate with the runner-injected ACTIONS_RUNTIME_TOKEN, so + # `permissions: contents: read` above is sufficient. + # See docs/ci-architecture.md#store-caching. + - uses: Mic92/hestia@fb239a2f72d4b6e26eec5425f289dea23b27a527 # v2 + + # Named individually on purpose: `nix flake check` batches every check in + # arbitrary order and so cannot return early on a cheap failure. nightly.yml runs + # the un-named form so an added check cannot be left ungated. + # See docs/ci-architecture.md#the-pr-gate-names-its-checks-the-nightly-one-does-not. + + # Phase 1 — formatting. Compiles nothing, so this is a true early return. + - name: fmt + run: nix build .#cli-fmt + + # Phase 2 — clippy, with the shared test-profile dependency artifacts. + - name: clippy + run: nix build .#cli-clippy + + # Phase 3 — the full suite in the sandbox, plus pinned-tool drift. + - name: test + run: nix build .#cli-nextest .#devshell-tools + + # Phase 4 — hermetic, network-free build of everything the project ships. + - name: build + run: nix build .#build-all + + # `nix build` prints nothing on success, so a green run showed no evidence the suite + # had run at all. Recover the counts from the derivation's own log rather than + # restoring `-L`, which streams every compile line and made these runs unreadable. + # + # Nextest colourises even in the sandbox — it has no TTY, but nothing tells it not to, + # and the workflow's CARGO_TERM_COLOR does not reach inside a derivation — so the + # escape codes have to come out before matching. + - name: suite summary + run: | + drv="$(nix eval --raw .#cli-nextest.drvPath)" + log="$(nix log "$drv" 2>/dev/null || true)" + if [ -n "$log" ]; then + printf '%s\n' "$log" | sed 's/\x1b\[[0-9;]*m//g' \ + | grep -aE 'Starting [0-9]+ tests|Summary \[|^ +(FAIL|SLOW)' || true + else + echo "No local build log: the suite result was substituted from the cache." + echo "Identical inputs, so the derivation was not rebuilt and the tests did not re-run." + fi + + - name: dump failing build log + if: failure() + run: nix log "$(nix eval --raw .#cli-nextest.drvPath)" 2>/dev/null | tail -150 || true + + test-macos: + name: test (macos-latest) + # The only platform whose suite runs as a plain cargo invocation. Linux is covered + # hermetically by the sandboxed test-linux job above; Windows by the cross-compiled + # archive below. This leg keeps rust-cache's incremental target/, which is the whole reason it + # is a `nix develop` run and not a derivation. + # See docs/ci-architecture.md#toolchain-comes-from-the-flake. + runs-on: macos-latest + steps: + - uses: actions/checkout@v6 + - uses: DeterminateSystems/nix-installer-action@main + - uses: Mic92/hestia@fb239a2f72d4b6e26eec5425f289dea23b27a527 # v2 + # rust-cache belongs on this leg and NOT on the Nix lane: it caches ./target, + # which a sandboxed `nix build` never writes. See + # docs/ci-architecture.md#rejected-alternatives. + - uses: Swatinem/rust-cache@v2 + - name: just check-types + run: nix develop .#ci -c just check-types + - name: just test-ci + run: nix develop .#ci -c just test-ci + + test-windows-check: + name: test (windows-latest check) + # Type-checks only; the shards below run the tests. Kept on rustup, and kept at all, + # because `just check-types` is `--all-features` while the archive is built with + # default features: the cfg(windows) arms behind `bench` and `gix-parity` are compiled + # for Windows nowhere else. Nix cannot supply this — no native Windows support — and + # store-foundation-qualification and git-parity need rustup on Windows anyway. + runs-on: windows-latest steps: - uses: actions/checkout@v6 - uses: dtolnay/rust-toolchain@stable with: components: rustfmt, clippy - - uses: dtolnay/rust-toolchain@nightly - with: - components: rustfmt - uses: taiki-e/install-action@just - - uses: taiki-e/install-action@nextest + # No longer shared with anything: the sharded test legs run prebuilt binaries and + # have no target/ to cache. Keyed on its own so it stops colliding with them. - uses: Swatinem/rust-cache@v2 with: - shared-key: ${{ runner.os == 'Windows' && 'windows-shards' || '' }} - - name: just lint - if: matrix.gate == 'lint' - run: just lint - - name: just check-types - if: matrix.gate == 'check' - run: just check-types - - name: configure Windows test debuginfo - if: runner.os == 'Windows' + shared-key: windows-check + - run: just check-types + + windows-cross-archive: + name: cross-compile nextest archive (ubuntu → msvc) + # Compiles the Windows test binaries once, on Linux, with cargo-xwin against the MSVC + # CRT. The shards below then execute them with no Rust toolchain on Windows at all. + # See docs/ci-architecture.md#windows. + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: DeterminateSystems/nix-installer-action@main + - uses: Mic92/hestia@fb239a2f72d4b6e26eec5425f289dea23b27a527 # v2 + # cargo-xwin fetches the MSVC CRT/SDK here; XWIN_ACCEPT_LICENSE is set in the shell. + - name: cross-compile x86_64-pc-windows-msvc nextest archive + run: nix develop .#windows-cross --command just windows-cross-archive x86_64-pc-windows-msvc + - uses: actions/upload-artifact@v7 + with: + name: nextest-archive-x86_64-pc-windows-msvc + path: target/nextest/pointbreak-x86_64-pc-windows-msvc.tar.zst + if-no-files-found: error + retention-days: 3 + + test-windows: + name: test (windows-latest ${{ matrix.shard }}/${{ matrix.shards }}) + needs: windows-cross-archive + runs-on: windows-latest + # Sharded because the suite is spawn-bound on per-CreateProcess cost, so execution + # parallelises across machines far better than across cores on one. Unlike the legs + # this replaced, no shard compiles anything — all three consume the single archive + # above — so sharding buys execution time without multiplying build time. + strategy: + fail-fast: false + matrix: + include: + - { shard: 1, shards: 3 } + - { shard: 2, shards: 3 } + - { shard: 3, shards: 3 } + steps: + - uses: actions/checkout@v6 + - uses: taiki-e/install-action@nextest + - uses: actions/download-artifact@v8 + with: + name: nextest-archive-x86_64-pc-windows-msvc + path: archive + # --workspace-remap points the runtime-resolved fixture and binary lookups at this + # checkout; see tests/support/env.rs for why they are resolved at runtime. + - name: run archived suite shell: bash - run: echo "CARGO_PROFILE_TEST_SPLIT_DEBUGINFO=packed" >> "$GITHUB_ENV" - - name: just test-ci - if: matrix.run_tests - run: just test-ci ${{ matrix.shard && format('--partition slice:{0}/{1}', matrix.shard, matrix.shards) || '' }} + run: | + cargo nextest run \ + --archive-file archive/pointbreak-x86_64-pc-windows-msvc.tar.zst \ + --workspace-remap . \ + --partition "slice:${{ matrix.shard }}/${{ matrix.shards }}" \ + --no-fail-fast store-foundation-qualification: name: store foundation qualification (${{ matrix.os }}) diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml new file mode 100644 index 00000000..f0387a59 --- /dev/null +++ b/.github/workflows/nightly.yml @@ -0,0 +1,47 @@ +name: Nightly + +# What ci.yml cannot afford to run per pull request. +# +# ci.yml's test-linux job already runs the sandboxed suite on Linux for every change. +# This adds the two things too slow to gate on: the same hermetic check on macOS, which +# measured 22m37s against Linux's 15m46s, and `nix flake check` in full, which builds +# every check in the flake rather than the four ci.yml names explicitly. +# +# Nothing here runs on a pull request, deliberately. A job that only skips is noise in +# the checks list. + +on: + schedule: + - cron: "0 7 * * *" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: nightly-${{ github.ref }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: never + RUST_BACKTRACE: 1 + +jobs: + full-check: + name: full flake check (${{ matrix.os }}) + # Runs `nix flake check` rather than naming the checks the way ci.yml's test-linux + # job does, so that a check added to the flake cannot be silently left ungated. + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v6 + - uses: DeterminateSystems/nix-installer-action@main + - uses: Mic92/hestia@fb239a2f72d4b6e26eec5425f289dea23b27a527 # v2 + - name: nix flake check + run: nix flake check + - name: dump failing build log + if: failure() + run: nix log "$(nix eval --raw .#cli-nextest.drvPath)" 2>/dev/null | tail -150 || true diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index 6d9874df..a99fd9bf 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -32,7 +32,14 @@ jobs: - name: Install just uses: taiki-e/install-action@just - # nixfmt --check + statix + deadnix + `nix flake check`. Runs only on Nix - # changes (see paths above); the Rust gate stays in ci.yml. - - name: Run nix-check - run: just nix-check + # nixfmt --check + statix + deadnix. Builds nothing, so this job matches its + # name and finishes in seconds. + # + # See docs/ci-architecture.md#nixyml-lints-nix-files-and-nothing-else. + # It used to run `just nix-check`, whose `nix flake check` BUILDS every check + # — clippy, the full nextest suite, tool drift — with no store cache, taking + # 14-18 minutes. Since ci.yml realises those same derivations on every + # pull request, this job was running the entire suite a second time, in + # parallel, from cold. Linting the Nix files is what is unique to this lane. + - name: Run nix-lint + run: just nix-lint diff --git a/.github/workflows/windows-ci-measure.yml b/.github/workflows/windows-ci-measure.yml deleted file mode 100644 index 299e8046..00000000 --- a/.github/workflows/windows-ci-measure.yml +++ /dev/null @@ -1,143 +0,0 @@ -name: windows ci measure - -# Dispatch-only measurement harness for the Windows test leg. -# This workflow NEVER runs on push or pull_request — it only exists so the wall-time -# levers can be measured on the real runner image and reverted. Each instrument is -# behind its own boolean input and is meant to be dispatched ONE AT A TIME: the -# Defender step changes the runner's security posture for the rest of the job, so -# enabling it alongside another instrument would skew that instrument's wall. -on: - workflow_dispatch: - inputs: - sys_user: - description: "Run the sys:user split (bash time)" - type: boolean - default: true - git_latency: - description: "Run the git per-spawn latency loop (native CreateProcess)" - type: boolean - default: false - defender_toggle: - description: "Re-enable Defender for one run to measure headroom (reverts)" - type: boolean - default: false - shard_probe: - description: "Run the 3-shard rust-cache-warmth probe" - type: boolean - default: false - quiet_log: - description: "Compare chatty vs quiet test-ci log output on one runner" - type: boolean - default: false - -permissions: - contents: read - -env: - CARGO_TERM_COLOR: always - RUST_BACKTRACE: 1 - -jobs: - measure: - name: measure (windows-latest) - runs-on: windows-latest - steps: - - uses: actions/checkout@v6 - - uses: dtolnay/rust-toolchain@stable - with: - components: rustfmt, clippy - - uses: taiki-e/install-action@just - - uses: taiki-e/install-action@nextest - - uses: Swatinem/rust-cache@v2 - - # --- sys:user split ------------------------------------------------------- - # The suite is parallel (CPU >> wall), so a high sys:user ratio is the - # spawn-bound signal. `time` writes real/user/sys (including children) to - # stderr; read those off the run log. This run's `real` is also the - # pre-change Windows wall baseline. - - name: (measure) sys:user split - if: ${{ inputs.sys_user }} - shell: bash - run: | - time just test-ci - - # --- Defender headroom ---------------------------------------------------- - # Restore the unmitigated posture (real-time monitoring on, drive exclusions - # removed) to MEASURE how much Defender costs on this suite. The runner VM is - # ephemeral, so nothing persists past this job; this never adds an exclusion. - - name: (measure) re-enable Defender (reverts at job end) - if: ${{ inputs.defender_toggle }} - shell: pwsh - run: | - Set-MpPreference -DisableRealtimeMonitoring $false - Remove-MpPreference -ExclusionPath "C:\" -ErrorAction SilentlyContinue - Remove-MpPreference -ExclusionPath "D:\" -ErrorAction SilentlyContinue - - name: (measure) suite with Defender on - if: ${{ inputs.defender_toggle }} - shell: bash - run: | - time just test-ci - - # --- git per-spawn latency ------------------------------------------------ - # The git spawn COUNT is OS-independent (taken from the local measurement), so - # only the Windows per-spawn LATENCY is measured here. A PATH shim cannot count - # native spawns on Windows anyway — Rust's Command::new("git") resolves git.exe - # via CreateProcess, which appends only `.exe` and never finds a `.cmd`/extension- - # less shim — so this times git the way the product spawns it: a native - # CreateProcess of git.exe in a tight loop. Multiply this against the local count - # to size the git-spawn share of wall (the deferred gix question). - - name: (measure) git per-spawn latency - if: ${{ inputs.git_latency }} - shell: pwsh - run: | - $git = (Get-Command git).Source - $n = 200 - $sw = [System.Diagnostics.Stopwatch]::StartNew() - for ($i = 0; $i -lt $n; $i++) { & $git rev-parse HEAD | Out-Null } - $sw.Stop() - $total = $sw.Elapsed.TotalMilliseconds - "$n x git rev-parse HEAD: $([math]::Round($total)) ms total (per-spawn ~ $([math]::Round($total / $n, 2)) ms)" - - # --- log-output cost ------------------------------------------------------ - # Does the chatty CI log (status-level=pass + final-status-level=all + forced - # color → ~3500 ANSI lines on Windows) add wall? Run the suite twice on the SAME - # runner to control for the large run-to-run variance: first chatty (the ci - # profile as shipped), then quiet (only failures, no color). The second run reuses - # the first's compiled binaries, so compare the two nextest run-phase `Summary - # [Ns]` lines (both warm, compile excluded) — the delta is the log-output cost. - - name: (measure) log-output cost (chatty vs quiet, same runner) - if: ${{ inputs.quiet_log }} - shell: bash - run: | - echo "=== chatty: ci profile as shipped (status-level=pass, final=all, color always) ===" - time just test-ci - echo "=== quiet: only failures, no color ===" - time env CARGO_TERM_COLOR=never just test-ci --status-level fail --final-status-level fail - - probe: - name: shard probe ${{ matrix.shard }}/3 - if: ${{ inputs.shard_probe }} - runs-on: windows-latest - strategy: - fail-fast: false - matrix: - shard: [1, 2, 3] - steps: - - uses: actions/checkout@v6 - - uses: dtolnay/rust-toolchain@stable - - uses: taiki-e/install-action@just - - uses: taiki-e/install-action@nextest - - uses: Swatinem/rust-cache@v2 - # A/B: shared-key now ACTIVE. All 3 shards restore ONE cache entry (they compile - # the identical test-binary set), instead of N distinct per-leg caches that fight - # the 10GB cap. Dispatch twice: the first run seeds the shared entry (cold), the - # second restores it (warm). Compare cache hit/miss + compile time against the - # earlier default-per-leg probe run (which was cold per-shard). - with: - shared-key: windows-shards - - name: compile-only timing - shell: bash - run: time cargo nextest run --profile ci --no-run - - name: partitioned run timing - shell: bash - run: time just test-ci --partition slice:${{ matrix.shard }}/3 diff --git a/.gitignore b/.gitignore index ca392fac..ce8580a9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +/result /target /extensions/vscode/out/ /extensions/vscode/bin/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a9edc7ac..77180b81 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -20,6 +20,14 @@ nix develop This drops you into a shell with every tool pinned by `flake.nix`. If you use `direnv` (with `nix-direnv`), the checked-in `.envrc` activates it automatically on `cd`. +For an immutable, host-targeted equivalent of `just build-all`, use: + +```bash +nix build .#build-all +``` + +The resulting store output contains the runnable `bin/pointbreak` and installable `pointbreak.vsix`. + ### mise ```bash diff --git a/Justfile b/Justfile index 87e004a7..2328b889 100644 --- a/Justfile +++ b/Justfile @@ -12,6 +12,11 @@ export SKILLS_REF_REV := env_var_or_default("SKILLS_REF_REV", "5d4c1fda3f786fff8 # PowerShell/cmd with "could not find the shell `sh`". set windows-shell := ["C:/Program Files/Git/bin/sh.exe", "-cu"] +# Rustup users select toolchains explicitly. The Fenix Nix shell overrides both +# commands with direct `cargo`, whose compiler is stable and formatter is nightly. +cargo_stable := env_var_or_default("POINTBREAK_CARGO_STABLE", "cargo +stable") +cargo_nightly := env_var_or_default("POINTBREAK_CARGO_NIGHTLY", "cargo +nightly") + # Host executable suffix: `.exe` on Windows, empty elsewhere. Mirrors the name the # extension packager derives from .github/binary-targets.json, so the path handed to # it in `build-all` actually exists on disk. @@ -25,38 +30,38 @@ default: # Run all tests. [group('core')] test *args: - cargo +stable nextest run --no-tests pass {{ args }} + {{ cargo_stable }} nextest run --no-tests pass {{ args }} # Run all tests (CI mode: no fail-fast, verbose). [group('core')] test-ci *args: - cargo +stable nextest run --profile ci --no-tests pass {{ args }} + {{ cargo_stable }} nextest run --profile ci --no-tests pass {{ args }} # Run a specific test file (e.g. just test-file integration). [group('core')] test-file name *args: - cargo +stable nextest run --test {{ name }} {{ args }} + {{ cargo_stable }} nextest run --test {{ name }} {{ args }} # Run the differential subprocess-vs-gix git-backend parity harness (report-only). [group('core')] git-parity *args: - cargo +stable nextest run --features gix-parity -E 'test(git_backend_parity)' {{ args }} + {{ cargo_stable }} nextest run --features gix-parity -E 'test(git_backend_parity)' {{ args }} # Per-op subprocess-vs-gix microbench behind the read-class flips (gix-parity # feature; separate from the `bench` feature). Prints the measured per-op win. [group('core')] git-bench *args: - cargo +stable nextest run --features gix-parity -E 'test(git_backend_microbench)' --no-capture {{ args }} + {{ cargo_stable }} nextest run --features gix-parity -E 'test(git_backend_microbench)' --no-capture {{ args }} # Build (debug). [group('core')] build *args: - cargo +stable build {{ args }} + {{ cargo_stable }} build {{ args }} # Build an optimized binary without publishing it. [group('core')] release *args: - cargo +stable build --release {{ args }} + {{ cargo_stable }} build --release {{ args }} # Reject a build profile that is not exactly `debug` or `release`, before any # dependency runs. Kept private so it stays out of `just --list`. @@ -70,7 +75,7 @@ _require-build-profile profile: # or `release` (default `release`). [group('core')] build-all profile="release": (_require-build-profile profile) web-install web-build extension-install - cargo +stable build {{ if profile == "release" { "--release" } else { "" } }} + {{ cargo_stable }} build {{ if profile == "release" { "--release" } else { "" } }} POINTBREAK_EXTENSION_PROFILE={{ profile }} POINTBREAK_EXTENSION_BINARY="{{ justfile_directory() }}/target/{{ profile }}/pointbreak{{ bin_ext }}" just extension-package # Self-test Cargo installation and all release archive layouts without publishing. @@ -161,7 +166,7 @@ workflow-lint-assertions: # Run Rust formatting checks and Clippy across all targets and features. [group('quality')] lint: fmt-check - cargo +stable clippy --workspace --all-targets --all-features -- -D warnings + {{ cargo_stable }} clippy --workspace --all-targets --all-features -- -D warnings # Type-check all targets without the full clippy/fmt gate. Used by CI's non-Linux # legs to keep the cfg(windows)/cfg(not(unix))/feature-gated arms compiled while @@ -169,22 +174,22 @@ lint: fmt-check # Type-check all workspace targets and features without the full lint gate. [group('core')] check-types: - cargo +stable check --workspace --all-targets --all-features + {{ cargo_stable }} check --workspace --all-targets --all-features # Run clippy with auto-fix. [group('quality')] fix *args: fmt - cargo +stable clippy --fix --workspace --all-targets --all-features --allow-dirty --allow-staged -- -D warnings {{ args }} + {{ cargo_stable }} clippy --fix --workspace --all-targets --all-features --allow-dirty --allow-staged -- -D warnings {{ args }} # Format code. [group('quality')] fmt *args: - cargo +nightly fmt --all {{ args }} + {{ cargo_nightly }} fmt --all {{ args }} # Check Rust formatting without writing files. [group('quality')] fmt-check: - cargo +nightly fmt --all -- --check + {{ cargo_nightly }} fmt --all -- --check # Format Nix files with the canonical RFC-166 formatter. Requires Nix. [group('nix')] @@ -193,19 +198,44 @@ nix-fmt: set -euo pipefail nix run nixpkgs#nixfmt -- $(git ls-files '*.nix') -# Lint and format-check Nix files: nixfmt, statix, deadnix, and `nix flake check`. -# Requires Nix. Deliberately separate from `just lint`/`check`, which stay -# Rust-only so contributors without Nix (mise/manual) can run the core gate. +# Requires Nix, but builds nothing, so it stays fast. This is the CI-facing Nix +# gate; the build and test outputs are gated by ci-nix.yml, which realises those +# derivations individually. +# Lint and format-check the Nix files themselves (nixfmt, statix, deadnix). [group('nix')] -nix-check: +nix-lint: #!/usr/bin/env bash set -euo pipefail files=$(git ls-files '*.nix') nix run nixpkgs#nixfmt -- --check $files nix run nixpkgs#statix -- check . nix run nixpkgs#deadnix -- --fail . + +# Deliberately separate from `just lint`/`check`, which stay Rust-only so +# contributors without Nix (mise/manual) can run the core gate. CI does not run +# this; ci-nix.yml realises the same derivations in phases instead. +# Run the Nix lint plus `nix flake check`, which BUILDS every check (fmt, clippy, the full test suite). +[group('nix')] +nix-check: nix-lint nix flake check +# EXPERIMENTAL: cross-compile a cargo-nextest archive for a Windows msvc target from +# this Linux/macOS host, to run on a real Windows machine (the archive carries prebuilt +# test binaries; the Windows side needs no Rust toolchain). Run inside the Nix +# windows-cross shell: `nix develop .#windows-cross -c just windows-cross-archive`. +# cargo-xwin downloads the MSVC CRT/SDK on first use. See ci-nix-windows-spike.yml. +[group('nix')] +windows-cross-archive target="x86_64-pc-windows-msvc": + #!/usr/bin/env bash + set -euo pipefail + out="target/nextest/pointbreak-{{ target }}.tar.zst" + mkdir -p "$(dirname "$out")" + # cargo-xwin emits the per-target CC/AR/linker/lib-search env nextest needs to + # build (and link) the Windows test binaries; eval it, then archive. + eval "$(cargo-xwin env --target {{ target }})" + cargo nextest archive --target {{ target }} --archive-file "$out" + echo "wrote $out" + # Install git hooks (commit-msg and pre-push validation via cocogitto). [group('maintenance')] setup-hooks: @@ -239,7 +269,7 @@ commit-check range='origin/main..HEAD': # Run the CLI. [group('core')] run *args: - cargo +stable run --bin pointbreak -- {{ args }} + {{ cargo_stable }} run --bin pointbreak -- {{ args }} # Fold a worktree-local .pointbreak/data store into the Git-common-dir pointbreak store. # Non-destructive + idempotent; refuses an ephemeral/sensitive worktree unless @@ -247,7 +277,7 @@ run *args: # Migrate a worktree-local store into the Git-common-dir store without deleting the source. [group('maintenance')] migrate-store-common-dir repo="." include-ephemeral="false": - cargo +stable run --bin pointbreak -- store migrate --repo {{ repo }} \ + {{ cargo_stable }} run --bin pointbreak -- store migrate --repo {{ repo }} \ {{ if include-ephemeral == "true" { "--include-ephemeral" } else { "" } }} # Run the complete Rust gate: commit check, build, lint, and tests. @@ -258,28 +288,28 @@ check: commit-check build lint test # uses only disposable roots and records raw samples without timing thresholds. [group('quality')] store-foundation-qualification-smoke: - cargo +stable bench --features bench --bench store_foundation -- --qualification-smoke + {{ cargo_stable }} bench --features bench --bench store_foundation -- --qualification-smoke # Run the developer evidence lane with repeated raw performance samples. This # remains environment evidence rather than a default-test timing gate. [group('quality')] store-foundation-qualification: - cargo +stable bench --features bench --bench store_foundation -- --qualification-evidence + {{ cargo_stable }} bench --features bench --bench store_foundation -- --qualification-evidence # Print and validate the public longitudinal workload and capacity contracts. [group('quality')] longitudinal-contract: - cargo +stable bench --locked --features bench --bench store_foundation -- --longitudinal-contract + {{ cargo_stable }} bench --locked --features bench --bench store_foundation -- --longitudinal-contract # Exercise disposable longitudinal construction, pair, preflight, and package mechanics without timing. [group('quality')] longitudinal-smoke: - cargo +stable bench --locked --features bench --bench store_foundation -- --longitudinal-smoke + {{ cargo_stable }} bench --locked --features bench --bench store_foundation -- --longitudinal-smoke # Recursively verify one completed longitudinal raw-evidence package without editing it. [group('quality')] longitudinal-verify-package root: - cargo +stable bench --locked --features bench --bench store_foundation -- \ + {{ cargo_stable }} bench --locked --features bench --bench store_foundation -- \ --longitudinal-verify-package --longitudinal-package-root="{{ root }}" # Install the Visual Studio Code extension toolchain from its committed lockfile. @@ -349,17 +379,17 @@ capture-marketing-review-screenshots url="http://127.0.0.1:7878": # Export the canonical Review example from a source repository through public Pointbreak APIs. [group('review-evidence')] review-example-export source output="examples/review/checkout-refactor": - cargo +stable run --example review_example_pack -- export --repo {{ source }} --output {{ output }} + {{ cargo_stable }} run --example review_example_pack -- export --repo {{ source }} --output {{ output }} # Verify the checked canonical Review example pack without depending on store layout. [group('review-evidence')] review-example-verify pack="examples/review/checkout-refactor": - cargo +stable run --example review_example_pack -- verify --pack {{ pack }} + {{ cargo_stable }} run --example review_example_pack -- verify --pack {{ pack }} # Materialize the canonical Review example into an empty destination repository. [group('review-evidence')] review-example-materialize output pack="examples/review/checkout-refactor": - cargo +stable run --example review_example_pack -- materialize --pack {{ pack }} --output {{ output }} + {{ cargo_stable }} run --example review_example_pack -- materialize --pack {{ pack }} --output {{ output }} # Materialize the Inspector decision-continuity matrix into an empty, isolated repository. [group('review-evidence')] @@ -369,7 +399,7 @@ review-decision-matrix-materialize output: if [ -n "${POINTBREAK_BINARY:-}" ]; then ./scripts/materialize-inspector-decision-matrix.sh "{{ output }}" else - cargo +stable build --bin pointbreak + {{ cargo_stable }} build --bin pointbreak POINTBREAK_BINARY="$PWD/target/debug/pointbreak" \ ./scripts/materialize-inspector-decision-matrix.sh "{{ output }}" fi @@ -382,7 +412,7 @@ review-decision-browser-verify root: if [ -n "${POINTBREAK_BINARY:-}" ]; then ./scripts/verify-inspector-decision-continuity.sh --root "{{ root }}" else - cargo +stable build --bin pointbreak + {{ cargo_stable }} build --bin pointbreak POINTBREAK_BINARY="$PWD/target/debug/pointbreak" \ ./scripts/verify-inspector-decision-continuity.sh --root "{{ root }}" fi diff --git a/README.md b/README.md index 2be892b3..aea87fda 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,8 @@ For contributors and maintainers: - [CONTRIBUTING.md](CONTRIBUTING.md) - setup, hooks, branch names, commits, tests, and PR flow. - [docs/development.md](docs/development.md) - change-to-gate matrix, generated artifacts, and failure interpretation. +- [docs/ci-architecture.md](docs/ci-architecture.md) - why CI is split into the lanes it is, what + was measured, and what would justify changing it. - [scripts/README.md](scripts/README.md) - script ownership, preferred entrypoints, side effects, and expected outcomes. - [docs/releasing.md](docs/releasing.md) - release planning and publish automation. diff --git a/build.rs b/build.rs index 4c517925..c699a2f1 100644 --- a/build.rs +++ b/build.rs @@ -14,17 +14,32 @@ pub(crate) struct DerivedIdentity { pub(crate) fn derive_identity( manifest_dir: &Path, package_version: &str, + build_channel: Option<&str>, ) -> Result { let dot_git = manifest_dir.join(".git"); let metadata = match fs::symlink_metadata(&dot_git) { Ok(metadata) => metadata, Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - return Ok(DerivedIdentity { - source: "package", - commit: None, - describe: format!("package:{package_version}"), - dirty: false, - }); + return match build_channel { + Some("nix-dev") => Ok(DerivedIdentity { + source: "package", + commit: None, + describe: format!("nix-dev:{package_version}"), + dirty: false, + }), + None => Ok(DerivedIdentity { + source: "package", + commit: None, + describe: format!("package:{package_version}"), + dirty: false, + }), + Some(channel) => Err(format!( + "unsupported POINTBREAK_BUILD_CHANNEL value {channel:?}. Git-less builds accept \ + either an unset channel (package:{package_version}) or `nix-dev` \ + (nix-dev:{package_version}). Remove POINTBREAK_BUILD_CHANNEL for a source \ + package, or set POINTBREAK_BUILD_CHANNEL=nix-dev for a Nix development package." + )), + }; } Err(error) => { return Err(format!( @@ -121,9 +136,11 @@ fn run() -> Result<(), String> { .ok_or_else(|| "Cargo did not provide CARGO_MANIFEST_DIR".to_owned())?; let package_version = env::var("CARGO_PKG_VERSION") .map_err(|_| "Cargo did not provide CARGO_PKG_VERSION".to_owned())?; - let identity = derive_identity(&manifest_dir, &package_version)?; + let build_channel = env::var("POINTBREAK_BUILD_CHANNEL").ok(); + let identity = derive_identity(&manifest_dir, &package_version, build_channel.as_deref())?; println!("cargo:rerun-if-changed=build.rs"); + println!("cargo:rerun-if-env-changed=POINTBREAK_BUILD_CHANNEL"); if identity.source == "git" { emit_git_rerun_directives(&manifest_dir)?; } diff --git a/docs/ci-architecture.md b/docs/ci-architecture.md new file mode 100644 index 00000000..fd4c7c15 --- /dev/null +++ b/docs/ci-architecture.md @@ -0,0 +1,227 @@ +# CI architecture + +Why continuous integration is split the way it is, what was measured, and what would justify +changing it again. [docs/development.md](development.md) covers which gate a given change needs; +this file covers why the gates are built the way they are. + +The workflows carry short comments at each decision point that name the rule and point here. If +you are about to change one of them, read the matching section first — several of the current +choices look like oversights until you see the number behind them. + +## The workflows + +| Workflow | Runs | Purpose | +| --- | --- | --- | +| `ci.yml` | every PR, all three platforms | The whole gate. The suite on each platform — Linux as sandboxed derivations, macOS as plain cargo, Windows from a cross-compiled archive — plus installers, skills, workflow lint, the front-end and extension checks, store qualification, and git parity. | +| `nightly.yml` | nightly and on demand | What is too slow to gate: the hermetic check on macOS, and `nix flake check` in full. | +| `nix.yml` | PRs touching `*.nix` | Lints the Nix files themselves. Builds nothing. | +| `cache-gc.yml` | nightly on the default branch | Prunes the hestia cache so the Nix lane stays inside the shared 10GB quota. | + +Every leg compiles with the same flake-pinned toolchain, so the Linux job is not a second opinion +on the lints. What only it establishes is stated in [Why keep a Nix lane](#why-keep-a-nix-lane). + +The Linux leg lives in `ci.yml` alongside the other two rather than in its own workflow. It ran +separately while it was still an experiment, but that left `ci.yml` showing macOS and Windows +tests and no Linux — a gate that reads as though it has a hole in it. + +## Toolchain comes from the flake + +`CONTRIBUTING.md` tells contributors to set up with `nix develop`. When CI resolved its own +toolchain through rustup, the compiler that gated a change was not the compiler contributors +built with. Linux and macOS now run `nix develop .#ci -c just `, so both agree, pinned by +`flake.lock`. + +`.#ci` is a separate, lean shell. The default shell carries interactive extras — cocogitto, which +is built from source in this flake, plus `gh` and `cargo-edit` — that no workflow uses. + +**Windows still uses rustup**, because Nix has no native Windows support. Retiring that is the +open item; see [Windows](#windows). + +## Why keep a Nix lane + +The macOS and Windows legs get their speed from a `rust-cache`-restored `target/` and a mutable +working directory. +That makes a stale-artifact false green *possible* there. In a Nix derivation it is impossible: +no host tools, no network, nothing reused from a cache. That property, on Linux, is the entire +reason the lane exists. + +Linux gates every PR, as `ci.yml`'s `test-linux` job. macOS runs the same check nightly instead, +for two reasons: it measured 22m37s against Linux's 15m46s, and **Nix does not sandbox builds on macOS by default** (`sandbox = false` is the +Darwin default), so the hermeticity argument is weaker there anyway. + +## Decisions, with the numbers + +### Test gates build with `CARGO_PROFILE = "test"` + +Crane defaults every derivation to `release`, so the gates were paying optimized codegen for +throwaway test binaries. Cargo's `test` profile is what `just lint` and `just test` use locally, +and what crane's own checks use. + +| | release | test | +| --- | --- | --- | +| crate + test binaries compile | 2m54s | **25s** | +| suite execution | 253s | **179s** | + +Both axes improved; release was actively hurting. The delivery build keeps its own release +artifacts, so `nix build .#build-all` is still optimized. + +### The clippy gate installs no cargo artifacts + +`cargoClippy` used to install its compiled target directory as the derivation output: a +164–255MB archive that nothing consumed, that changed on every commit, and that cost the build a +632MB → 164MB compression pass. `cargoNextest` already set `doInstallCargoArtifacts = false` for +the same reason. Clippy's output is now 0B. The reusable part is the shared dependency artifacts, +built separately. + +### The PR gate names its checks; the nightly one does not + +`nix flake check` builds every check in one arbitrary-order batch, so it cannot express "cheap +gate first". Naming the checks individually can, which is why the PR job runs +`fmt → clippy → test → build` and stops at the first failure. + +The cost is that the explicit list can drift from the flake's actual `checks`. The nightly job +runs `nix flake check` precisely so a newly added check cannot be silently left ungated. + +### Reading the Nix lane's logs + +`nix build` prints nothing on success, so a green Linux gate showed no evidence the suite had +run — the job log contained no test counts at all. Passing `-L` fixes that by streaming every +compile line, which is what made these runs unreadable in the first place. + +The `suite summary` step splits the difference: it pulls the counts back out of the derivation's +own build log after the fact. Two things to know when reading it. Nextest colourises even inside +the sandbox — it has no TTY, but nothing tells it not to, and the workflow's `CARGO_TERM_COLOR` +does not reach into a derivation — so the escape codes are stripped before matching. And on a +cache hit there is no local log, because nothing was built; the step says so rather than printing +an ambiguous blank. That case is not a gap: the derivation output *is* the proof those exact +inputs passed, just from an earlier run. + +### Store caching + +Three approaches, each of which hit a different GitHub ceiling: + +| Approach | Granularity | Outcome | +| --- | --- | --- | +| `cache-nix-action` | one tarball of the whole `/nix/store`, per platform | 4.87GB (Linux) + 4.46GB (macOS) = **9.33GB before anything else**, against a 10GB storage limit. The platforms evicted each other and Linux ran cold every time. | +| `magic-nix-cache` | one entry per store path | **1788 entries**, whose API calls tripped GitHub's rate limiter. On throttle it logs `Not trying to use it again on this run` and disables itself, so the job silently finishes cold. | +| `Mic92/hestia` (current) | content-defined chunks, a few large entries | **491 paths packed into 37 entries** — about 48x fewer objects than per-path storage, with no throttling observed. | + +Measured on this repository: a cold run costs 22m52s and drains 2.3 GiB in about a minute; +re-running the same commit costs **48s** end to end (fmt 17s, clippy 2s, test 3s, build 8s), +because identical inputs make every derivation output a cache hit. A real change still +recompiles the crate and its test binaries — only the dependency artifacts stay cached — so +expect a normal pull request to land between those two numbers rather than near the low one. + +Hestia needs no account: uploads authenticate with the runner-injected +`ACTIONS_RUNTIME_TOKEN`, so build jobs need only `permissions: contents: read`. It is +pinned by commit SHA, matching how this repository pins its other third-party actions. + +`cache-gc.yml` prunes it nightly. Hestia tracks liveness through *roots* — one per branch +and system, e.g. `main-x86_64-linux` — and collects whatever no root reaches once it falls +out of the push grace period. It only ever considers paths hestia itself pushed, so the +rust-cache and setup-node entries sharing the quota are untouched. GC must run on the +default branch, because a pull request's cache scope is read-only towards it, and it is the +only workflow here holding `actions: write`. + +If quota is still the binding constraint after that, the action takes an +`upstream-cache-filter` input that skips paths already signed by an upstream cache. + +> Two corrections worth keeping, because both cost time here. Magic Nix Cache broke in +> February 2025 and was widely written off, but was revived in June 2025 against the new +> API — it is not dead. And hestia *is* MIT licensed (README and both `Cargo.toml` +> files); GitHub's license detector reports nothing only because there is no `LICENSE` +> file at the repository root. + +### `nix.yml` lints Nix files and nothing else + +It used to run `just nix-check`, whose `nix flake check` builds every check — clippy, the whole +suite, tool drift — with no store cache. A job named "Format and lint" was taking 14–18 minutes +and duplicating the Linux gate from cold. It now runs `just nix-lint` (nixfmt, statix, deadnix) in +about a second. `just nix-check` keeps the fuller behaviour for local use. + +`nix flake check --no-build` is not a cheap substitute: `cleanCargoSource` filters a derivation +output, so evaluation has to build the source derivation first (import-from-derivation). + +## Rejected alternatives + +**A full Nix gate on both platforms, per PR.** Ran fmt, clippy, tests, and the artifact build on +Linux and macOS. It re-checked lints `ci.yml` already covers, on the same code with the same +toolchain, for roughly 1.5× the time — and macOS was the wall-clock long pole at 22m37s. Only the +sandboxed suite was unique, so only that was kept. + +**`Swatinem/rust-cache` on the Nix lane.** Frequently suggested, but it caches `~/.cargo` and +`./target`, and `nix build` runs in a sandbox that never writes the workspace `target/`. It would +cache an empty directory. It is the right tool for `ci.yml`, which does run cargo in the +workspace, and it is used there. + +**`nix-options: "sandbox = false"` on macOS.** Suggested as a macOS speed-up. It is a no-op: +`sandbox = false` is already the Darwin default (`/etc/nix/nix.conf` sets no `sandbox` line and +Nix still reports `false`). On Linux it would be actively harmful, deleting the one property the +Nix lane exists to provide. It remains a legitimate *debugging* lever for a derivation that fails +only under the sandbox. + +**Cachix.** Would work, and is the one option where GitHub's rate limits cannot apply at all, +since it does not use the Actions cache. The footprint is ~3.6GB against a 5GB free +open-source tier, which is workable but not roomy, and it needs an account plus a token +secret. It stays the fallback if the current approach hits either ceiling again, or if the +cache is ever wanted outside CI. + +## Revisit triggers + +- **`ci.yml` produces a false green traced to a stale `target/`.** The strongest argument for + moving more of the gate into derivations. +- **Either GitHub cache ceiling is hit again** — storage (10GB, shared with `ci.yml`'s + rust-cache entries) or the API rate limit. First try hestia's `upstream-cache-filter` and a + GC workflow; if that is not enough, move to a real binary cache (Cachix or FlakeHub), which + is subject to neither. +- **macOS runners get materially faster,** or a macOS-specific sandbox regression appears — then + reconsider gating macOS per PR rather than nightly. +- **Nix gains native Windows support,** or the cross-compile spike graduates — either removes the + last rustup dependency. +- **Test execution starts dominating compile time.** The `test` profile was chosen when compiling + dominated; if that inverts, re-measure `release`. + +## Windows + +`ci.yml`'s `windows-cross-archive` job cross-compiles the suite to `x86_64-pc-windows-msvc` on +Linux with cargo-xwin; three `test-windows` shards then execute the resulting `cargo nextest` +archive on plain Windows runners that build no Rust at all. This was a report-only spike until it +proved both green and competitive on x64, and is now the gate. + +It became possible once the suite was changed to resolve its test binary, fixtures, and cargo at +runtime rather than through compile-time `env!()`, which bakes the build machine's paths in and +which `--workspace-remap` cannot relocate. + +`tests/runtime_path_resolution.rs` guards that convention. Reintroducing a compile-time path is +easy to do by accident and fails nowhere except the Windows shards, with a bare "system cannot +find the path specified" that names no cause — so the guard fails on Linux instead, naming the +file, the line, and the resolver to use. `include_str!`/`include_bytes!` are exempt: they embed +bytes, so no path survives into the binary. + +**Rustup is not gone from Windows.** Three things still need a toolchain there: the +`--all-features` type-check (`test-windows-check`, which compiles the `cfg(windows)` arms behind +`bench` and `gix-parity` that the default-feature archive never sees), plus the +store-foundation-qualification and git-parity legs. Only the test execution was freed. + +The Windows run is now fanned across three shards, the same `slice:N/3` partitioning `ci.yml` +uses, which splits evenly (2922 tests, 974 per shard, reconciling exactly). Before that it was a +single leg and the lane cost 15m20s — cross-compile then run, serialized — against 10m02s for the +sharded rustup legs. + +The shape is better than `ci.yml`'s, not just equal: there, each Windows shard rebuilds the test +binaries, so sharding multiplies compile work. Here the archive is cross-built once on Linux and +all three shards consume it, so sharding buys execution time and nothing else. Whether that is +enough to beat the rustup legs is now a measurement rather than an argument. + +It stays report-only until it is confirmed green on the x86_64 runner across a few runs — the +2924/2924 validation was on ARM64. + +No shard compiles Rust. The one test that did — a `cargo install` packaging check — was a +leftover of the `shore` -> `pointbreak` rename and has been retired; the invariant it still +carried (exactly one installed binary, named `pointbreak`) is asserted from `cargo metadata` +by `package_identity_declares_only_pointbreak_binary`. Retiring it also removed the ~2-3 +minute on-target build that made whichever shard drew it the long pole. + +Nothing now exercises `cargo install` itself, which is the route the README and +[installation guide](installation.md) give users. That path is covered indirectly by the +release workflows building and verifying the published binaries, but not directly. diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 62fc303c..3f7c41c2 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -63,8 +63,12 @@ worktree `.git` file). In that mode, `build.commit` is the full lowercase commit `build.describe` is the result of `git describe --tags --always --dirty`, and `build.dirty` reports tracked index or worktree changes at build time. Invalid or partial manifest-root Git metadata fails the build. When a source package has no manifest-root `.git`, `build.source` is `package`, `build.commit` is `null`, -`build.describe` is `package:`, and `build.dirty` is `false`. A package directory nested beneath -some other checkout does not inherit that parent's Git identity. +`build.describe` is `package:`, and `build.dirty` is `false`. Nix development packages set the +explicit `POINTBREAK_BUILD_CHANNEL=nix-dev` build input instead; they retain the compatible `package` +source and report `build.describe` as `nix-dev:`, with `build.commit=null` and +`build.dirty=false`. The static Nix channel marks an unreleased package build without guessing a future +semantic version or depending on checkout metadata. A package directory nested beneath some other checkout +does not inherit that parent's Git identity. JSON is authoritative for the full identity. A clean exact-tag binary has the exact tag in `build.describe`, its peeled full commit in `build.commit`, and `build.dirty=false`; semantic version alone @@ -120,7 +124,7 @@ tests/CI). **Signing never gates a write** (with one exception, below): any reso an unreadable key home, an unsupported algorithm, a malformed configured key, `POINTBREAK_SIGNING=off`) degrades to an unsigned write at exit 0 with a one-line advisory diagnostic on stderr — it never blocks. The sole exception is `pointbreak endorse` (below), where unsigned is a hard error because the -signature *is* the endorsement's content. See +signature _is_ the endorsement's content. See [signing-ux.md](./signing-ux.md) for the human / agent / CI flows and the `unsigned → untrusted_key → valid` ladder. @@ -161,7 +165,7 @@ revision recorded; its subject is always the captured snapshot, never the live w - `--color ` controls ANSI syntax coloring of the diff body. `auto` (the default) colorizes only when stdout is a TTY, honoring `NO_COLOR` and `CLICOLOR_FORCE` (precedence: `--color` > `NO_COLOR` > `CLICOLOR_FORCE` > isatty); piped or redirected output stays plain. Color is pure - presentation — stripping the ANSI reproduces the plain diff exactly. + > presentation — stripping the ANSI reproduces the plain diff exactly. - `--theme ` picks the truecolor palette: `auto` (the default) detects the terminal background — light or dark — and selects the matching built-in palette; `light` / `dark` force a built-in; any other value names a bundled syntax theme, matched case-insensitively (bat's @@ -495,7 +499,7 @@ when a repo's own test fixtures carry scanner-triggering strings: The two files merge by **union** (committed order first, then novel local entries) — deliberately diverging from the local-replaces-committed rule `store.json`/`delegates.json` use, because this is -a *list*: replace would force copying the whole committed list to add one local entry, union grants +a _list_: replace would force copying the whole committed list to add one local entry, union grants nothing replace couldn't, and the audit counts make any widening visible. Default is empty (scan everything; opt-in only). @@ -515,7 +519,7 @@ ran (absent under `--include-ephemeral`, which skips the scan). Excluded paths t listed — the scan's redacted `file:sha256:*` posture stands. The gate behavior is unchanged: a `block` finding outside the excludes still refuses without `--include-ephemeral`. -**Seeing which files matched (`pointbreak store status --show-paths`).** A finding names only its *kind* +**Seeing which files matched (`pointbreak store status --show-paths`).** A finding names only its _kind_ and a redacted `file:sha256:*` reference, so on its own it does not tell you which file to exclude. `--show-paths` closes that loop: it re-runs the same scan (the same matchers, the same exclude globs) locally and lists the real matched worktree paths grouped by finding kind, so you can author @@ -623,7 +627,7 @@ commands, and `pointbreak history` omit the body text and carry a `bodyContentSt `summaryContentState` / `reasonContentState` field beside the content hash — `suppressed_present` while the bytes are still stored (a compact would reclaim them) or `physically_removed` after the sweep — plus `body_content_suppressed_present` / `body_content_physically_removed` diagnostics. -The field is omitted entirely while content is present, and a body that is missing *without* a +The field is omitted entirely while content is present, and a body that is missing _without_ a recorded removal still fails the read with the `import referenced artifacts` guidance. Command output is the machine-integration surface, under the tiered stability promise described at @@ -965,7 +969,7 @@ signer and the carrier's envelope writer is the **endorser's own actor** (`--act writing identity), never the target's author. - **Unsigned is a hard error.** Unlike every other write — where signing never gates — an endorsement - has no unsigned form, because the signature *is* its content. The signer is resolved first (before the + has no unsigned form, because the signature _is_ its content. The signer is resolved first (before the target); if none resolves (`POINTBREAK_SIGNING=off`, no key, an unreadable key), the command exits non-zero and writes nothing. Signer precedence otherwise follows the **Signing** rules above. - Idempotent: re-endorsing the same target with the same signer is a no-op (`eventsCreated: 0`, diff --git a/examples/support/review_example_pack.rs b/examples/support/review_example_pack.rs index aa120bec..5d1fbfe7 100644 --- a/examples/support/review_example_pack.rs +++ b/examples/support/review_example_pack.rs @@ -14,6 +14,7 @@ use pointbreak::session::{ }; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; +use tempfile::tempdir; pub type PackResult = Result>; @@ -258,7 +259,20 @@ pub fn verify_pack(pack: &Path) -> PackResult<()> { &manifest.documents.revision.sha256, )?; verify_documents(pack, &manifest)?; + // Verify against an empty repository so pack validation does not depend on + // the caller running from a checkout that happens to contain prerequisites. + let bundle_verify_repo = tempdir()?; + let bundle_repo_init = Command::new("git") + .args(["init", "--bare", "--initial-branch=main"]) + .arg(bundle_verify_repo.path()) + .output()?; + require( + bundle_repo_init.status.success(), + "source.bundle verify repository", + )?; let bundle_verify = Command::new("git") + .arg("-C") + .arg(bundle_verify_repo.path()) .args(["bundle", "verify"]) .arg(pack.join(&manifest.source.bundle_path)) .output()?; @@ -406,10 +420,7 @@ fn build_pack(source_repo: &Path, stage: &Path) -> PackResult<()> { producer: ProducerManifest { name: "pointbreak".to_owned(), version: env!("CARGO_PKG_VERSION").to_owned(), - commit: git_output( - Path::new(env!("CARGO_MANIFEST_DIR")), - &["rev-parse", "HEAD"], - )?, + commit: git_output(&manifest_dir(), &["rev-parse", "HEAD"])?, }, record: RecordManifest { revision: REVISION.to_owned(), @@ -596,6 +607,18 @@ fn git_object(repo: &Path, commit: &str) -> PackResult { }) } +/// Absolute path to this crate's manifest directory, resolved at run time. +/// +/// This file is included both by the example binary and, via `#[path]`, by +/// tests/review_example_pack.rs, so it cannot reach either one's helper and carries its own. +/// Prefers the runtime `CARGO_MANIFEST_DIR` that cargo-nextest remaps under +/// `--workspace-remap`, falling back to the compile-time value for in-place runs. +fn manifest_dir() -> std::path::PathBuf { + std::env::var_os("CARGO_MANIFEST_DIR") + .map(std::path::PathBuf::from) + .unwrap_or_else(|| std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))) +} + fn git_output(repo: &Path, args: &[&str]) -> PackResult { let output = Command::new("git") .arg("-C") diff --git a/flake.lock b/flake.lock index fdfbda46..082ed8dc 100644 --- a/flake.lock +++ b/flake.lock @@ -1,5 +1,42 @@ { "nodes": { + "crane": { + "locked": { + "lastModified": 1779041105, + "narHash": "sha256-nnGD2f8OlAZT2i5OfwikJsw+ifWfiA4d6A8BWlgOXV0=", + "owner": "ipetkov", + "repo": "crane", + "rev": "10e6e3cb966f7cfcc789fe5eee7a85f3188ce08b", + "type": "github" + }, + "original": { + "owner": "ipetkov", + "ref": "v0.23.4", + "repo": "crane", + "type": "github" + } + }, + "fenix": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ], + "rust-analyzer-src": "rust-analyzer-src" + }, + "locked": { + "lastModified": 1784795840, + "narHash": "sha256-Z5cDTnG/+k7lNMMyuNH2RDWmSuGw2d3nV9AlVrpGnNE=", + "owner": "nix-community", + "repo": "fenix", + "rev": "85164e3aaadbc35cbbb4a9fef6d358b607da09fc", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "fenix", + "type": "github" + } + }, "nixpkgs": { "locked": { "lastModified": 1784497964, @@ -18,8 +55,27 @@ }, "root": { "inputs": { + "crane": "crane", + "fenix": "fenix", "nixpkgs": "nixpkgs" } + }, + "rust-analyzer-src": { + "flake": false, + "locked": { + "lastModified": 1784667699, + "narHash": "sha256-853teJQOgTTKRfNxcIJ53ziJOGNeg3c74PvQl5l61Jc=", + "owner": "rust-lang", + "repo": "rust-analyzer", + "rev": "1174734d9c6453d13d2b5e3d578512c579ca37f1", + "type": "github" + }, + "original": { + "owner": "rust-lang", + "ref": "nightly", + "repo": "rust-analyzer", + "type": "github" + } } }, "root": "root", diff --git a/flake.nix b/flake.nix index c3d65075..f2be531d 100644 --- a/flake.nix +++ b/flake.nix @@ -3,10 +3,23 @@ inputs = { nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + fenix = { + url = "github:nix-community/fenix"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + # Kept as a parallel packaging experiment until its dependency-artifact + # cache proves a meaningful win over buildRustPackage for Pointbreak. + crane.url = "github:ipetkov/crane/v0.23.4"; }; outputs = - { nixpkgs, ... }: + { + self, + nixpkgs, + fenix, + crane, + ... + }: let # Systems the dev shell is built for: Linux and macOS on x86_64 and arm64. systems = [ @@ -17,6 +30,25 @@ ]; forEachSystem = f: nixpkgs.lib.genAttrs systems (system: f nixpkgs.legacyPackages.${system}); + # Fenix provides a fixed stable compiler for normal work and only the + # nightly formatter required by rustfmt.toml's `unstable_features`. + # Combining components avoids rustup's mutable per-user toolchains. + mkRustToolchains = + pkgs: + let + fenixPkgs = fenix.packages.${pkgs.stdenv.hostPlatform.system}; + in + { + stable = fenixPkgs.stable.toolchain; + dev = fenixPkgs.combine [ + fenixPkgs.stable.cargo + fenixPkgs.stable.rustc + fenixPkgs.stable.clippy + fenixPkgs.stable.rust-src + fenixPkgs.latest.rustfmt + ]; + }; + # cocogitto pinned to 6.5.0 to match CI (.github/workflows/*: cargo binstall # cocogitto@6.5.0) and mise.toml. nixpkgs ships 7.0.0, which this repo is NOT # ready for: cog 7 changes the release tag lifecycle the signed-tag finalizer @@ -53,19 +85,28 @@ pkgs: let cocogitto = mkCocogitto pkgs; + rustToolchains = mkRustToolchains pkgs; + fenixPkgs = fenix.packages.${pkgs.stdenv.hostPlatform.system}; + # Experimental Windows (msvc) cross toolchain: stable host cargo/rustc plus + # the prebuilt std for both shipped Windows targets. Paired with cargo-xwin + # (which supplies the MSVC CRT/SDK), this cross-compiles a cargo-nextest + # archive on Linux/macOS for execution on a real Windows machine. + windowsCrossToolchain = fenixPkgs.combine [ + fenixPkgs.stable.cargo + fenixPkgs.stable.rustc + fenixPkgs.targets."aarch64-pc-windows-msvc".stable.rust-std + fenixPkgs.targets."x86_64-pc-windows-msvc".stable.rust-std + ]; in { default = pkgs.mkShell { # Everything on PATH inside `nix develop`. packages = with pkgs; [ # --- Rust --- - # rustup (not a fixed rustc) because the Justfile/CI use BOTH - # `cargo +stable` (build/test/clippy) and `cargo +nightly` (rustfmt, - # which relies on unstable_features). Pinning a single rustc in Nix - # can't serve both channels; rustup keeps rust-toolchain.toml as the - # source of truth for stable and installs each channel on demand (see - # RUSTUP_AUTO_INSTALL in the shellHook). - rustup + # Stable cargo/rustc/clippy plus nightly rustfmt. The direct Fenix + # cargo binary has no rustup `+toolchain` proxy; the shell selects + # direct commands for the Justfile compatibility variables below. + rustToolchains.dev # --- Dev tooling (mirrors mise.toml [tools]) --- just @@ -86,10 +127,11 @@ ]; shellHook = '' - # Let `cargo +stable` and `cargo +nightly` install their toolchain on - # first use instead of erroring. stable comes from rust-toolchain.toml; - # nightly (needed by `just fmt`) is fetched the first time it's invoked. - export RUSTUP_AUTO_INSTALL=1 + # Outside Nix, Justfile recipes keep using rustup's explicit stable + # and nightly selectors. This shell has a single Fenix toolchain, so + # both recipe classes invoke its direct cargo binary instead. + export POINTBREAK_CARGO_STABLE=cargo + export POINTBREAK_CARGO_NIGHTLY=cargo # Replicate mise's `[env] _.path`: prefer freshly-built binaries. # Guarded so re-sourcing the hook doesn't stack duplicate entries. @@ -105,26 +147,248 @@ && echo "pointbreak: installed cocogitto git hooks" fi - echo "pointbreak dev shell — rustup $(rustup --version 2>/dev/null | awk '{print $2}'), just, nextest, cog, node $(node --version)" + echo "pointbreak dev shell — $(rustc --version), $(rustfmt --version), just, nextest, cog, node $(node --version)" ''; }; + + # The toolchain CI needs and nothing else. `nix develop .#ci -c just ` + # gives the workflow the same pinned compiler, formatter, and nextest a + # contributor gets, without realising the interactive extras: cocogitto is + # built from source here, and gh/cargo-edit have no CI consumer. jq, Node, + # and git are kept because the test suite shells out to all three. + ci = pkgs.mkShell { + packages = [ + rustToolchains.dev + pkgs.cargo-nextest + pkgs.just + pkgs.git + pkgs.jq + pkgs.nodejs_22 + pkgs.pkg-config + ]; + # The Justfile selects rustup's `+stable`/`+nightly` toolchains by default; + # this shell has one Fenix toolchain whose rustfmt is already nightly. + env = { + POINTBREAK_CARGO_STABLE = "cargo"; + POINTBREAK_CARGO_NIGHTLY = "cargo"; + }; + }; + + # Experimental Windows-msvc cross shell. Produces a cargo-nextest archive + # (prebuilt test binaries) that runs on a real Windows machine needing no + # Rust toolchain. cargo-xwin fetches the MSVC CRT/SDK on first use, which + # needs network — so this is an impure `nix develop` workflow, not a + # sandboxed derivation. See `just windows-cross-archive`. + windows-cross = pkgs.mkShell { + packages = [ + windowsCrossToolchain + pkgs.cargo-nextest + pkgs.cargo-xwin + pkgs.llvmPackages.clang-unwrapped # clang-cl for the bundled-C deps + pkgs.lld # lld-link + pkgs.llvm # llvm-lib, llvm-rc + pkgs.just + pkgs.git + ]; + env.XWIN_ACCEPT_LICENSE = "1"; + }; } ); - # `nix flake check` builds every derivation under `checks`. This one realises - # the pinned cocogitto (proving the from-source pin still compiles on a clean - # machine) and asserts the version-critical tools resolve, so a broken pin or - # version drift fails the flake rather than only surfacing in a live shell. - checks = forEachSystem ( + # `nix build .#build-all` is the store-backed counterpart of `just build-all`: + # it builds the Inspector asset, CLI, and host-targeted VSIX without mutating + # the checkout or accessing npm during a sandboxed build. + packages = forEachSystem ( pkgs: let + # Read from Cargo.toml rather than repeated here. A release bumps the manifest, + # and a hardcoded copy would keep labelling artifacts with the previous version: + # bumping Cargo.toml alone used to leave the dependency derivation named + # pointbreak-deps-0.8.0, so the VSIX and binary would ship mislabelled. + inherit (craneLib.crateNameFromCargoToml { cargoToml = ./Cargo.toml; }) version; cocogitto = mkCocogitto pkgs; + rustToolchains = mkRustToolchains pkgs; + craneLib = (crane.mkLib pkgs).overrideToolchain rustToolchains.stable; + # rustfmt.toml enables unstable options, so the format check needs the + # combined dev toolchain (stable cargo, nightly rustfmt) rather than the + # stable-only toolchain the compile/lint derivations build against. + craneLibDev = (crane.mkLib pkgs).overrideToolchain rustToolchains.dev; + + inspector = pkgs.buildNpmPackage { + pname = "pointbreak-inspector"; + inherit version; + src = ./src/cli/inspect/web; + npmDepsHash = "sha256-5naTmTgI9JsRLe2nezLMGjkhtJmjPv/TLFNxBo0xOXU="; + buildPhase = '' + runHook preBuild + npm run build -- --outfile="$out/app.js" + runHook postBuild + ''; + installPhase = "true"; + }; + + # The Rust binary embeds the Inspector asset. Substitute the Nix-built + # bundle into a copied source tree, so its served UI is exactly the + # companion `inspector` package rather than a stale checked-in artifact. + sourceWithInspector = + pkgs.runCommand "pointbreak-source-with-inspector" { nativeBuildInputs = [ pkgs.coreutils ]; } + '' + cp -R ${./.} "$out" + chmod -R u+w "$out" + cp ${inspector}/app.js "$out/src/cli/inspect/assets/app.js" + ''; + + # Crane's dummy source isolates this derivation from ordinary Rust + # source edits, so the delivery package and test check reuse dependency + # and dev-dependency artifacts across Nix builds. + cargoArtifacts = craneLib.buildDepsOnly { + pname = "pointbreak"; + inherit version; + src = craneLib.cleanCargoSource sourceWithInspector; + cargoLock = ./Cargo.lock; + env.POINTBREAK_BUILD_CHANNEL = "nix-dev"; + }; + + # Dependencies for the quality gates, built with Cargo's `test` profile. + # NOT an oversight and NOT to be "fixed" to release: release made the crate + # take 2m54s to compile against 25s here, and the suite 253s against 179s. + # See docs/ci-architecture.md#test-gates-build-with-cargo_profile--test. + # Crane defaults every derivation to `release`, which made the gates pay + # optimized codegen for throwaway test binaries; `just lint`/`just test` + # compile with the dev-inheriting `test` profile, so the gates now match + # what a contributor runs locally. Kept separate from the release + # artifacts above so the delivery build stays optimized. + cargoArtifactsTest = craneLib.buildDepsOnly { + pname = "pointbreak"; + inherit version; + src = craneLib.cleanCargoSource sourceWithInspector; + cargoLock = ./Cargo.lock; + CARGO_PROFILE = "test"; + env.POINTBREAK_BUILD_CHANNEL = "nix-dev"; + }; + + # Build the distributable artifact without coupling ordinary consumers + # to the complete repository test suite. `cliNextest` below is exposed + # through `nix flake check` as the full Git-less quality gate. + cli = craneLib.buildPackage { + pname = "pointbreak"; + inherit version; + src = sourceWithInspector; + cargoLock = ./Cargo.lock; + inherit cargoArtifacts; + doCheck = false; + env.POINTBREAK_BUILD_CHANNEL = "nix-dev"; + nativeBuildInputs = [ + pkgs.git + pkgs.makeWrapper + ]; + postFixup = '' + mkdir -p "$out/libexec" + mv "$out/bin/pointbreak" "$out/libexec/pointbreak" + makeWrapper "$out/libexec/pointbreak" "$out/bin/pointbreak" \ + --prefix PATH : ${pkgs.lib.makeBinPath [ pkgs.git ]} + ''; + meta.mainProgram = "pointbreak"; + }; + + # This complete Git-less integration suite is a flake check rather + # than a dependency of the delivery artifact above. + cliNextest = craneLib.cargoNextest { + pname = "pointbreak"; + inherit version; + src = sourceWithInspector; + cargoLock = ./Cargo.lock; + cargoArtifacts = cargoArtifactsTest; + doInstallCargoArtifacts = false; + CARGO_PROFILE = "test"; + cargoNextestExtraArgs = "--no-tests pass"; + env.POINTBREAK_BUILD_CHANNEL = "nix-dev"; + nativeBuildInputs = [ + pkgs.git + pkgs.jq + pkgs.nodejs_22 + ]; + }; + + # Hermetic clippy gate reusing the shared dependency artifacts. Mirrors + # `just lint`'s clippy invocation so the flake check and the Justfile gate + # stay in lockstep; `-D warnings` makes any lint fail the check. + cliClippy = craneLib.cargoClippy { + pname = "pointbreak"; + inherit version; + src = sourceWithInspector; + cargoLock = ./Cargo.lock; + cargoArtifacts = cargoArtifactsTest; + CARGO_PROFILE = "test"; + # See docs/ci-architecture.md#the-clippy-gate-installs-no-cargo-artifacts. + # Nothing consumes this gate's compiled target directory, and installing + # it cost a ~250MB output that changed on every commit — dead weight in + # both the CI store cache and the build itself, which paid to compress + # it. The shared dependency artifacts above are the reusable part. + doInstallCargoArtifacts = false; + env.POINTBREAK_BUILD_CHANNEL = "nix-dev"; + cargoClippyExtraArgs = "--workspace --all-targets --all-features -- -D warnings"; + # Build scripts run under clippy; build.rs shells out to git. + nativeBuildInputs = [ pkgs.git ]; + }; + + # Format check with the pinned nightly rustfmt. Compiles nothing, so it + # needs neither the dependency artifacts nor git — only the cleaned source. + cliFmt = craneLibDev.cargoFmt { + pname = "pointbreak"; + inherit version; + src = craneLib.cleanCargoSource sourceWithInspector; + }; + + vscode = pkgs.buildNpmPackage { + pname = "pointbreak-vscode"; + inherit version; + src = ./.; + npmRoot = "extensions/vscode"; + # `npmRoot` scopes the build, but the dependency fetcher needs a + # source whose root contains this nested project's lockfile. + npmDeps = pkgs.fetchNpmDeps { + src = ./extensions/vscode; + hash = "sha256-zoXPLpbbDHgiq5lcvaVIjuKujBa6Hfay0sDbhGaKakY="; + }; + # Keep the packaging runtime aligned with the pinned developer Node. + # keytar 7.9.0 does not compile against Nixpkgs' default Node 24. + nodejs = pkgs.nodejs_22; + # VSCE packaging does not use keytar's credential API. Avoid rebuilding + # that optional native addon after the offline npm installation. + npmRebuildFlags = [ "--ignore-scripts" ]; + nativeBuildInputs = [ + cli + pkgs.unzip + ]; + buildPhase = '' + runHook preBuild + cd "$npmRoot" + POINTBREAK_EXTENSION_CLEAN_VERSION=1 \ + POINTBREAK_EXTENSION_PROFILE=release \ + POINTBREAK_EXTENSION_BINARY=${cli}/libexec/pointbreak \ + node scripts/package-local.mjs + runHook postBuild + ''; + installPhase = '' + install -Dm444 ../../target/vsix/*/release/*.vsix "$out/pointbreak.vsix" + ''; + }; in { + default = cli; + inherit cli inspector vscode; + cli-nextest = cliNextest; + cli-clippy = cliClippy; + cli-fmt = cliFmt; + # Exposed as a package (not only a check) so CI can realise it as + # `.#devshell-tools`, which resolves the current system automatically + # instead of hardcoding a system string per runner. devshell-tools = pkgs.runCommand "devshell-tools-check" { nativeBuildInputs = [ + rustToolchains.dev cocogitto pkgs.nodejs_22 pkgs.just @@ -132,16 +396,37 @@ ]; } '' - # rustup is intentionally excluded: it insists on a writable HOME - # to create ~/.rustup, which the build sandbox denies. Its presence - # is already covered by evaluating the devShell. + cargo --version >/dev/null + rustc --version >/dev/null + rustfmt --version | grep -q nightly cog --version | grep -qw 6.5.0 node --version | grep -q '^v22\.' just --version >/dev/null cargo-nextest nextest --version >/dev/null touch "$out" ''; + # Curate the aggregate as a delivery surface. The Inspector bundle is + # embedded in the CLI and `libexec/pointbreak` is only the VSIX input; + # both remain available from their owning package outputs. + build-all = pkgs.runCommand "pointbreak-build-all-${version}" { } '' + mkdir -p "$out/bin" + ln -s ${cli}/bin/pointbreak "$out/bin/pointbreak" + ln -s ${vscode}/pointbreak.vsix "$out/pointbreak.vsix" + ''; } ); + + # `nix flake check` runs the full Rust gate hermetically: the complete + # Git-less Nextest suite, clippy (`-D warnings`), and the nightly rustfmt + # format check — clippy and the tests share the crane dependency artifacts. + # It also realises the pinned cocogitto (proving the from-source pin still + # compiles on a clean machine). This keeps package consumers on the fast + # delivery path while making test, lint, format, or tool drift fail the flake. + checks = forEachSystem (pkgs: { + cli-nextest = self.packages.${pkgs.stdenv.hostPlatform.system}.cli-nextest; + clippy = self.packages.${pkgs.stdenv.hostPlatform.system}.cli-clippy; + fmt = self.packages.${pkgs.stdenv.hostPlatform.system}.cli-fmt; + devshell-tools = self.packages.${pkgs.stdenv.hostPlatform.system}.devshell-tools; + }); }; } diff --git a/src/bench_support.rs b/src/bench_support.rs index 985e12d1..066a77d1 100644 --- a/src/bench_support.rs +++ b/src/bench_support.rs @@ -23,6 +23,22 @@ use crate::session::event::{EventTarget, EventType, ReviewInitializedPayload, Sh pub mod foundation; pub mod longitudinal; +/// Absolute path to this crate's manifest directory, resolved at runtime. +/// +/// Prefers the runtime `CARGO_MANIFEST_DIR` — which cargo-nextest remaps via +/// `--workspace-remap` when the harnesses run from an archive built on another machine — +/// and falls back to the compile-time value for ordinary in-place runs, where the two are +/// identical. Only for paths opened at run time; `include_str!(concat!(env!(..), ..))` +/// embeds its bytes at compile time and needs no runtime path, so it stays as it is. +/// +/// This mirrors `crate::test_fixtures::manifest_dir`, which cannot be shared here because +/// it is `cfg(test)`-only while these harnesses also compile under the `bench` feature. +pub(crate) fn manifest_dir() -> PathBuf { + std::env::var_os("CARGO_MANIFEST_DIR") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(env!("CARGO_MANIFEST_DIR"))) +} + /// On-disk versus logical byte accounting for a store directory. pub struct ByteUsage { /// Sum of file content lengths — the logical bytes the events occupy. diff --git a/src/bench_support/foundation/corpus.rs b/src/bench_support/foundation/corpus.rs index fcc9d2c6..553bda23 100644 --- a/src/bench_support/foundation/corpus.rs +++ b/src/bench_support/foundation/corpus.rs @@ -389,7 +389,7 @@ fn validate_external_root(path: &Path) -> Result(values: &'a [&'a str]) -> BTreeSet<&'a str> { #[cfg(test)] mod tests { - use std::path::Path; + use std::path::PathBuf; use serde_json::{Value, json}; @@ -599,8 +599,8 @@ mod tests { const MANIFEST: &str = include_str!("../../../vendor/lmdb-proof/closure.json"); - fn repository_root() -> &'static Path { - Path::new(env!("CARGO_MANIFEST_DIR")) + fn repository_root() -> PathBuf { + crate::bench_support::manifest_dir() } fn mutated_manifest(path: &[&str], value: Value) -> String { @@ -617,7 +617,7 @@ mod tests { #[test] fn exact_lmdb_proof_closure_is_self_consistent() { - let closure = validate_lmdb_proof_closure_v1(MANIFEST, repository_root()) + let closure = validate_lmdb_proof_closure_v1(MANIFEST, &repository_root()) .expect("exact closure validates"); assert_eq!(closure.target_triples.len(), 8); @@ -630,7 +630,7 @@ mod tests { let manifest = mutated_manifest(&["native", "behaviorAuthority"], json!("registry")); assert_eq!( - validate_lmdb_proof_closure_v1(&manifest, repository_root()).unwrap_err(), + validate_lmdb_proof_closure_v1(&manifest, &repository_root()).unwrap_err(), "native behavior authority must be the immutable upstream Git source" ); } @@ -640,7 +640,7 @@ mod tests { let missing = mutated_manifest(&["native", "patches"], json!([])); assert_eq!( - validate_lmdb_proof_closure_v1(&missing, repository_root()).unwrap_err(), + validate_lmdb_proof_closure_v1(&missing, &repository_root()).unwrap_err(), "native patch inventory is not exact" ); } @@ -664,7 +664,7 @@ mod tests { json!("0000000000000000000000000000000000000000"), ); assert_eq!( - validate_lmdb_proof_closure_v1(&wrong_overlay, repository_root()).unwrap_err(), + validate_lmdb_proof_closure_v1(&wrong_overlay, &repository_root()).unwrap_err(), "wrapper source commit does not match the reviewed pin" ); @@ -673,7 +673,7 @@ mod tests { json!("0000000000000000000000000000000000000000"), ); assert_eq!( - validate_lmdb_proof_closure_v1(&wrong_native, repository_root()).unwrap_err(), + validate_lmdb_proof_closure_v1(&wrong_native, &repository_root()).unwrap_err(), "native source commit does not match the reviewed pin" ); @@ -682,7 +682,7 @@ mod tests { json!("0000000000000000000000000000000000000000000000000000000000000000"), ); assert_eq!( - validate_lmdb_proof_closure_v1(&wrong_materialization, repository_root()).unwrap_err(), + validate_lmdb_proof_closure_v1(&wrong_materialization, &repository_root()).unwrap_err(), "wrapper materialization record is not exact" ); } @@ -694,13 +694,13 @@ mod tests { json!("https://github.com/meilisearch/heed/tree/main"), ); assert_eq!( - validate_lmdb_proof_closure_v1(&mutable, repository_root()).unwrap_err(), + validate_lmdb_proof_closure_v1(&mutable, &repository_root()).unwrap_err(), "wrapper repository is not the recorded upstream source" ); let unexpected = mutated_manifest(&["features", "wrapper"], json!(["use-valgrind"])); assert_eq!( - validate_lmdb_proof_closure_v1(&unexpected, repository_root()).unwrap_err(), + validate_lmdb_proof_closure_v1(&unexpected, &repository_root()).unwrap_err(), "wrapper feature closure is not the minimum plain set" ); } @@ -709,19 +709,19 @@ mod tests { fn license_generated_input_and_dynamic_link_gaps_fail_closed() { let missing_license = mutated_manifest(&["licenses"], json!([])); assert_eq!( - validate_lmdb_proof_closure_v1(&missing_license, repository_root()).unwrap_err(), + validate_lmdb_proof_closure_v1(&missing_license, &repository_root()).unwrap_err(), "source license and notice inventory is incomplete" ); let missing_bindings = mutated_manifest(&["generatedInputs"], json!([])); assert_eq!( - validate_lmdb_proof_closure_v1(&missing_bindings, repository_root()).unwrap_err(), + validate_lmdb_proof_closure_v1(&missing_bindings, &repository_root()).unwrap_err(), "generated binding inventory is incomplete" ); let dynamic = mutated_manifest(&["link", "dynamicHostDependencies"], json!(true)); assert_eq!( - validate_lmdb_proof_closure_v1(&dynamic, repository_root()).unwrap_err(), + validate_lmdb_proof_closure_v1(&dynamic, &repository_root()).unwrap_err(), "dynamic host dependencies are forbidden" ); } @@ -741,13 +741,13 @@ mod tests { ]), ); assert_eq!( - validate_lmdb_proof_closure_v1(&seven_targets, repository_root()).unwrap_err(), + validate_lmdb_proof_closure_v1(&seven_targets, &repository_root()).unwrap_err(), "proof target matrix does not match the release target manifest" ); let included = mutated_manifest(&["package", "excludedFromDefaultPackage"], json!(false)); assert_eq!( - validate_lmdb_proof_closure_v1(&included, repository_root()).unwrap_err(), + validate_lmdb_proof_closure_v1(&included, &repository_root()).unwrap_err(), "proof sources must be excluded from the default Cargo package" ); } diff --git a/src/bench_support/foundation/mod.rs b/src/bench_support/foundation/mod.rs index 97325181..618edb72 100644 --- a/src/bench_support/foundation/mod.rs +++ b/src/bench_support/foundation/mod.rs @@ -144,7 +144,7 @@ mod linux_filesystem_parser_tests { #[cfg(target_os = "linux")] #[test] fn linux_filesystem_probe_reports_an_unambiguous_mount_type() { - let filesystem = qualification_filesystem_name(Path::new(env!("CARGO_MANIFEST_DIR"))); + let filesystem = qualification_filesystem_name(&crate::bench_support::manifest_dir()); assert_ne!(filesystem, "unavailable"); assert_ne!(filesystem, "ext2/ext3"); @@ -285,7 +285,7 @@ mod windows_tests { #[test] fn windows_probe_reports_a_local_filesystem_type() { assert_eq!( - qualification_filesystem_name(Path::new(env!("CARGO_MANIFEST_DIR"))), + qualification_filesystem_name(&crate::bench_support::manifest_dir()), "ntfs" ); } @@ -414,7 +414,7 @@ mod tests { #[test] fn macos_probe_reports_a_filesystem_type() { - let filesystem = qualification_filesystem_name(Path::new(env!("CARGO_MANIFEST_DIR"))); + let filesystem = qualification_filesystem_name(&crate::bench_support::manifest_dir()); assert_ne!(filesystem, "unavailable"); assert_ne!(filesystem, "/"); diff --git a/src/bench_support/foundation/performance.rs b/src/bench_support/foundation/performance.rs index ae1840aa..8e33b3ad 100644 --- a/src/bench_support/foundation/performance.rs +++ b/src/bench_support/foundation/performance.rs @@ -32,7 +32,7 @@ use super::{ modeled_post_foundation_manifest, qualification_cargo_lock_sha256, qualification_filesystem_name, qualification_generated_manifest_v1, qualification_generator_spec_v1, qualification_operation_schedule_v1, - qualification_source_commit, synthetic_legacy_manifest, + synthetic_legacy_manifest, }; use crate::canonical_hash::{canonical_json_bytes, sha256_bytes_hex}; @@ -91,6 +91,19 @@ const QUALIFICATION_LOOSE_BASELINE_WARMUP_ITERATIONS_V1: u32 = 3; const QUALIFICATION_LOOSE_BASELINE_MEASURED_ITERATIONS_V1: u32 = 30; const QUALIFICATION_LOOSE_BASELINE_INDEPENDENT_ROOTS_V1: u32 = 2; +#[cfg(test)] +const PERFORMANCE_TEST_SOURCE_COMMIT: &str = "cccccccccccccccccccccccccccccccccccccccc"; + +#[cfg(test)] +fn expected_qualification_source_commit() -> Result { + Ok(PERFORMANCE_TEST_SOURCE_COMMIT.to_owned()) +} + +#[cfg(not(test))] +fn expected_qualification_source_commit() -> Result { + super::qualification_source_commit() +} + #[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Deserialize, Serialize)] #[serde(rename_all = "snake_case")] pub enum QualificationLooseBaselineOperationV1 { @@ -261,7 +274,7 @@ impl QualificationLooseBaselineEvidenceV1 { pub fn validate(&self) -> Result<(), String> { if self.schema != QUALIFICATION_LOOSE_BASELINE_EVIDENCE_SCHEMA_V1 - || self.source_commit != qualification_source_commit()? + || self.source_commit != expected_qualification_source_commit()? || self.cargo_lock_sha256 != qualification_cargo_lock_sha256() || self.generator_schema != QUALIFICATION_GENERATOR_SCHEMA_V1 || self.public_seed_hex != QUALIFICATION_PUBLIC_SEED_HEX_V1 @@ -861,7 +874,8 @@ impl QualificationLooseBaselineEvidenceV1 { .collect(); let mut evidence = Self { schema: QUALIFICATION_LOOSE_BASELINE_EVIDENCE_SCHEMA_V1.to_owned(), - source_commit: qualification_source_commit().expect("test build source commit"), + source_commit: expected_qualification_source_commit() + .expect("test build source commit"), cargo_lock_sha256: qualification_cargo_lock_sha256(), generator_schema: QUALIFICATION_GENERATOR_SCHEMA_V1.to_owned(), public_seed_hex: QUALIFICATION_PUBLIC_SEED_HEX_V1.to_owned(), @@ -2743,14 +2757,14 @@ pub fn qualification_lmdb_prospective_execution_v1() if env!("POINTBREAK_BUILD_SOURCE") != "git" || env!("POINTBREAK_BUILD_DIRTY") == "true" { return Err("LMDB prospective runner requires a clean Git build".to_owned()); } - let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")); - let source_commit = qualification_source_commit()?; + let manifest_dir = crate::bench_support::manifest_dir(); + let source_commit = super::qualification_source_commit()?; let live_commit = - git_identity_stdout_v1(manifest_dir, &["rev-parse", "--verify", "HEAD^{commit}"])?; + git_identity_stdout_v1(&manifest_dir, &["rev-parse", "--verify", "HEAD^{commit}"])?; let source_tree = - git_identity_stdout_v1(manifest_dir, &["rev-parse", "--verify", "HEAD^{tree}"])?; + git_identity_stdout_v1(&manifest_dir, &["rev-parse", "--verify", "HEAD^{tree}"])?; let status = git_identity_stdout_v1( - manifest_dir, + &manifest_dir, &["status", "--porcelain=v1", "--untracked-files=no"], )?; if live_commit != source_commit || !status.is_empty() { @@ -3811,7 +3825,7 @@ impl QualificationPerformanceEvidenceV2 { { return Err("performance evidence uses a different contract".to_owned()); } - if self.source_commit != qualification_source_commit()? { + if self.source_commit != expected_qualification_source_commit()? { return Err("performance evidence source commit is stale".to_owned()); } if self.cargo_lock_sha256 != qualification_cargo_lock_sha256() { @@ -4270,7 +4284,7 @@ fn validate_loose_baseline_evidence_configuration_v1( .root .parent() .is_none_or(|parent| !parent.is_dir()) - || configuration.source_commit != qualification_source_commit()? + || configuration.source_commit != expected_qualification_source_commit()? || configuration.cargo_lock_sha256 != qualification_cargo_lock_sha256() || !configuration.quiesced_host || env!("POINTBREAK_BUILD_DIRTY") == "true" @@ -6422,7 +6436,7 @@ pub fn validate_diagnostic_configuration( { return Err("performance diagnostics root must be a fresh path".to_owned()); } - if configuration.source_commit != qualification_source_commit()? { + if configuration.source_commit != expected_qualification_source_commit()? { return Err("performance diagnostics source commit is stale".to_owned()); } if configuration.cargo_lock_sha256 != qualification_cargo_lock_sha256() { @@ -6546,7 +6560,7 @@ fn validate_campaign_configuration( .root .parent() .is_none_or(|parent| !parent.is_dir()) - || configuration.source_commit != qualification_source_commit()? + || configuration.source_commit != expected_qualification_source_commit()? || configuration.cargo_lock_sha256 != qualification_cargo_lock_sha256() || !configuration.quiesced_host || env!("POINTBREAK_BUILD_DIRTY") == "true" @@ -8270,8 +8284,7 @@ mod tests { let mut configuration = QualificationPerformanceDiagnosticConfigurationV1 { executable: std::env::current_exe().expect("test executable"), root: root.clone(), - source_commit: crate::bench_support::foundation::qualification_source_commit() - .expect("build commit"), + source_commit: expected_qualification_source_commit().expect("build commit"), cargo_lock_sha256: crate::bench_support::foundation::qualification_cargo_lock_sha256(), warmup_samples: 0, measured_samples: 1, @@ -8287,8 +8300,7 @@ mod tests { assert!(validate_diagnostic_configuration(&configuration).is_err()); assert!(!root.exists()); - configuration.source_commit = - crate::bench_support::foundation::qualification_source_commit().expect("build commit"); + configuration.source_commit = expected_qualification_source_commit().expect("build commit"); std::fs::create_dir(&root).expect("pre-existing root"); assert!(validate_diagnostic_configuration(&configuration).is_err()); } @@ -8476,8 +8488,7 @@ mod tests { let configuration = QualificationPerformanceDiagnosticConfigurationV1 { executable: std::env::current_exe().expect("test executable"), root: parent.path().join("diagnostics"), - source_commit: crate::bench_support::foundation::qualification_source_commit() - .expect("build commit"), + source_commit: expected_qualification_source_commit().expect("build commit"), cargo_lock_sha256: crate::bench_support::foundation::qualification_cargo_lock_sha256(), warmup_samples: 1, measured_samples: 2, @@ -8515,8 +8526,7 @@ mod tests { baseline_allocated: u64, ) -> QualificationPerformanceEvidenceV2 { let contract = QualificationPerformanceContractV2::frozen(); - let source_commit = crate::bench_support::foundation::qualification_source_commit() - .expect("build source commit"); + let source_commit = expected_qualification_source_commit().expect("build source commit"); let cargo_lock_sha256 = crate::bench_support::foundation::qualification_cargo_lock_sha256(); let mut runs = Vec::new(); diff --git a/src/bench_support/longitudinal/evidence.rs b/src/bench_support/longitudinal/evidence.rs index 0efcd8f3..d1b29945 100644 --- a/src/bench_support/longitudinal/evidence.rs +++ b/src/bench_support/longitudinal/evidence.rs @@ -1662,15 +1662,15 @@ mod tests { #[test] fn longitudinal_evidence_rejects_existing_and_relative_roots() { - let source = Path::new(env!("CARGO_MANIFEST_DIR")); + let source = crate::bench_support::manifest_dir(); assert_eq!( - validate_fresh_local_root(source, source) + validate_fresh_local_root(&source, &source) .unwrap_err() .to_string(), LongitudinalEvidenceError::UnsafeRoot.to_string() ); assert_eq!( - validate_fresh_local_root(Path::new("relative"), source) + validate_fresh_local_root(Path::new("relative"), &source) .unwrap_err() .to_string(), LongitudinalEvidenceError::UnsafeRoot.to_string() diff --git a/src/crypto/ed25519.rs b/src/crypto/ed25519.rs index 4751fa67..27730416 100644 --- a/src/crypto/ed25519.rs +++ b/src/crypto/ed25519.rs @@ -279,7 +279,7 @@ mod tests { } fn fixture_path(name: &str) -> std::path::PathBuf { - std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + crate::test_fixtures::manifest_dir() .join("tests/fixtures/event_signatures") .join(name) } diff --git a/src/documents/version.rs b/src/documents/version.rs index 49638fe5..f9255df9 100644 --- a/src/documents/version.rs +++ b/src/documents/version.rs @@ -162,7 +162,7 @@ mod tests { #[test] fn registry_is_cli_documents_plus_the_exact_promoted_inspect_set() { - let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")); + let manifest_dir = crate::test_fixtures::manifest_dir(); let mut emitted = BTreeSet::from([VERSION_SCHEMA.to_owned()]); collect_schema_literals(&manifest_dir.join("src/cli"), &mut emitted); collect_schema_literals(&manifest_dir.join("src/documents"), &mut emitted); diff --git a/src/model/id_prefix.rs b/src/model/id_prefix.rs index 64efdb96..d397af06 100644 --- a/src/model/id_prefix.rs +++ b/src/model/id_prefix.rs @@ -377,8 +377,8 @@ mod tests { #[test] fn inspector_ref_prefixes_match_the_registry() { - let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) - .join("src/cli/inspect/web/src/classNames.ts"); + let path = + crate::test_fixtures::manifest_dir().join("src/cli/inspect/web/src/classNames.ts"); if !path.exists() { // The published crate excludes src/cli/inspect/web/** (Cargo.toml // `exclude`); the drift guard only means something in the repo. diff --git a/src/session/adapter/claude_code/parse.rs b/src/session/adapter/claude_code/parse.rs index c1c41046..b1a60a3c 100644 --- a/src/session/adapter/claude_code/parse.rs +++ b/src/session/adapter/claude_code/parse.rs @@ -448,7 +448,7 @@ mod tests { use crate::model::JournalId; fn fixture_path() -> std::path::PathBuf { - std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + crate::test_fixtures::manifest_dir() .join("tests/fixtures/claude_code_session/a0ce57f0-485d-45b7-98fc-f0f13f467d72.jsonl") } diff --git a/src/session/adapter/claude_code/translate.rs b/src/session/adapter/claude_code/translate.rs index 9d0e0aff..6c90b616 100644 --- a/src/session/adapter/claude_code/translate.rs +++ b/src/session/adapter/claude_code/translate.rs @@ -275,7 +275,7 @@ mod tests { const FIRST_USER_PROMPT: &str = "Can we update the README.md to use `boardwalk::transitions!` like the drivers/boardwalk-mock-led/src/lib.rs?"; fn fixture_path() -> std::path::PathBuf { - std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + crate::test_fixtures::manifest_dir() .join("tests/fixtures/claude_code_session/a0ce57f0-485d-45b7-98fc-f0f13f467d72.jsonl") } diff --git a/src/session/adapter/claude_code/write.rs b/src/session/adapter/claude_code/write.rs index f3ce5073..708e9dbb 100644 --- a/src/session/adapter/claude_code/write.rs +++ b/src/session/adapter/claude_code/write.rs @@ -250,7 +250,7 @@ mod tests { } fn fixture_path() -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")) + crate::test_fixtures::manifest_dir() .join("tests/fixtures/claude_code_session/a0ce57f0-485d-45b7-98fc-f0f13f467d72.jsonl") } diff --git a/src/session/event/tbs.rs b/src/session/event/tbs.rs index 41fe8f97..c324ffac 100644 --- a/src/session/event/tbs.rs +++ b/src/session/event/tbs.rs @@ -125,7 +125,7 @@ mod tests { fn fixture_bytes(name: &str) -> Vec { let mut bytes = std::fs::read( - std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + crate::test_fixtures::manifest_dir() .join("tests/fixtures/event_signatures") .join(name), ) diff --git a/src/session/signing/mod.rs b/src/session/signing/mod.rs index d762e699..567ccead 100644 --- a/src/session/signing/mod.rs +++ b/src/session/signing/mod.rs @@ -130,7 +130,7 @@ mod tests { #[test] fn checked_in_allowed_signers_file_authorizes_friendly_actor() { let trust = TrustSet::from_allowed_signers_file( - std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + crate::test_fixtures::manifest_dir() .join("tests/fixtures/event_signatures/.shore/allowed-signers.json"), ) .unwrap(); diff --git a/src/session/workflow/history/query.rs b/src/session/workflow/history/query.rs index 6586ec0f..8ed770d8 100644 --- a/src/session/workflow/history/query.rs +++ b/src/session/workflow/history/query.rs @@ -106,7 +106,7 @@ pub fn apply_history_query( query: &HistoryQuery, page: &HistoryPage, ) -> QueriedHistory { - debug_assert!( + assert!( !(matches!(query.order, HistoryOrder::Desc) && page.after.is_some()), "history cursor windowing requires ascending order" ); diff --git a/src/test_fixtures.rs b/src/test_fixtures.rs index 906dbb61..0a4364e0 100644 --- a/src/test_fixtures.rs +++ b/src/test_fixtures.rs @@ -1,8 +1,21 @@ -use std::path::Path; +use std::path::PathBuf; + +/// Absolute path to this crate's manifest directory, resolved at runtime. +/// +/// Prefers the runtime `CARGO_MANIFEST_DIR` — which cargo-nextest remaps via +/// `--workspace-remap` when the tests run from an archive built on another machine — and +/// falls back to the compile-time value for ordinary in-place runs (where they are +/// identical). Shared by the crate's `#[cfg(test)]` fixture lookups so a cross-compiled +/// (e.g. Windows) archive still finds fixtures relative to the remapped workspace root. +pub(crate) fn manifest_dir() -> PathBuf { + std::env::var_os("CARGO_MANIFEST_DIR") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(env!("CARGO_MANIFEST_DIR"))) +} pub(crate) fn naming_cutover_bytes(relative: &str) -> Vec { std::fs::read( - Path::new(env!("CARGO_MANIFEST_DIR")) + manifest_dir() .join("tests/fixtures/naming-cutover") .join(relative), ) diff --git a/tests/agent_skill_validation_evidence.rs b/tests/agent_skill_validation_evidence.rs index c978e58a..4af7be13 100644 --- a/tests/agent_skill_validation_evidence.rs +++ b/tests/agent_skill_validation_evidence.rs @@ -98,7 +98,7 @@ fn agent_skills_note_human_use_ssh_path() { #[test] fn shipped_primary_skill_set_is_exactly_the_three_workflow_roles() { - let skills_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("skills"); + let skills_dir = env::manifest_dir().join("skills"); let mut shipped: Vec = std::fs::read_dir(&skills_dir) .expect("read skills directory") .map(|entry| entry.expect("read skills directory entry")) @@ -130,9 +130,9 @@ fn supported_install_command_pins_exactly_the_three_product_skills() { // distribution directory (exactly the three roles) or under the // repository's own development tooling. A skill anywhere else would leak // into installer discovery unreviewed. - let repo_root = Path::new(env!("CARGO_MANIFEST_DIR")); + let repo_root = env::manifest_dir(); let mut skill_dirs = Vec::new(); - collect_skill_dirs(repo_root, repo_root, &mut skill_dirs); + collect_skill_dirs(&repo_root, &repo_root, &mut skill_dirs); for dir in skill_dirs { assert!( dir.starts_with(".claude/skills/") @@ -349,7 +349,7 @@ fn agent_authoring_routes_roles_through_the_canonical_journey() { } fn assert_order(relative_path: &str, needles: &[&str]) { - let path = Path::new(env!("CARGO_MANIFEST_DIR")).join(relative_path); + let path = env::manifest_dir().join(relative_path); let contents = std::fs::read_to_string(&path) .unwrap_or_else(|error| panic!("read {relative_path}: {error}")); @@ -366,7 +366,7 @@ fn assert_order(relative_path: &str, needles: &[&str]) { } fn assert_not_contains(relative_path: &str, needle: &str) { - let path = Path::new(env!("CARGO_MANIFEST_DIR")).join(relative_path); + let path = env::manifest_dir().join(relative_path); let contents = std::fs::read_to_string(&path) .unwrap_or_else(|error| panic!("read {relative_path}: {error}")); @@ -377,7 +377,7 @@ fn assert_not_contains(relative_path: &str, needle: &str) { } fn assert_contains(relative_path: &str, needle: &str) { - let path = Path::new(env!("CARGO_MANIFEST_DIR")).join(relative_path); + let path = env::manifest_dir().join(relative_path); let contents = std::fs::read_to_string(&path) .unwrap_or_else(|error| panic!("read {relative_path}: {error}")); @@ -386,3 +386,8 @@ fn assert_contains(relative_path: &str, needle: &str) { "{relative_path} should contain {needle:?}" ); } + +// Runtime-resolved binary/manifest paths for cross-machine (e.g. Windows) archive runs. +#[path = "support/env.rs"] +#[allow(dead_code)] +mod env; diff --git a/tests/build_provenance.rs b/tests/build_provenance.rs index 45ca6da1..4f164146 100644 --- a/tests/build_provenance.rs +++ b/tests/build_provenance.rs @@ -41,7 +41,7 @@ fn exact_tag_is_clean_git_identity_with_full_commit() { let repo = GitFixture::new(); run_git(repo.path(), &["tag", "v1.2.3"]); - let identity = build_script::derive_identity(repo.path(), "1.2.3").unwrap(); + let identity = build_script::derive_identity(repo.path(), "1.2.3", None).unwrap(); let head = repo.head(); assert_eq!(identity.source, "git"); @@ -70,7 +70,7 @@ fn post_tag_linked_worktree_reports_distance_hash_and_full_head() { ], ); - let identity = build_script::derive_identity(&worktree, "1.2.3").unwrap(); + let identity = build_script::derive_identity(&worktree, "1.2.3", None).unwrap(); let head = repo.head(); assert!(worktree.join(".git").is_file()); @@ -90,12 +90,12 @@ fn tracked_and_index_changes_cannot_claim_a_clean_tag() { run_git(repo.path(), &["tag", "v1.2.3"]); fs::write(repo.path().join("tracked.txt"), "dirty\n").expect("dirty tracked file"); - let worktree_dirty = build_script::derive_identity(repo.path(), "1.2.3").unwrap(); + let worktree_dirty = build_script::derive_identity(repo.path(), "1.2.3", None).unwrap(); assert!(worktree_dirty.dirty); assert_eq!(worktree_dirty.describe, "v1.2.3-dirty"); run_git(repo.path(), &["add", "tracked.txt"]); - let index_dirty = build_script::derive_identity(repo.path(), "1.2.3").unwrap(); + let index_dirty = build_script::derive_identity(repo.path(), "1.2.3", None).unwrap(); assert!(index_dirty.dirty); assert_eq!(index_dirty.describe, "v1.2.3-dirty"); } @@ -106,7 +106,7 @@ fn package_root_does_not_inherit_parent_checkout_metadata() { let package_root = parent.path().join("target/package/pointbreak-1.2.3"); fs::create_dir_all(&package_root).expect("create package root"); - let identity = build_script::derive_identity(&package_root, "1.2.3").unwrap(); + let identity = build_script::derive_identity(&package_root, "1.2.3", None).unwrap(); assert_eq!(identity.source, "package"); assert_eq!(identity.commit, None); @@ -114,12 +114,37 @@ fn package_root_does_not_inherit_parent_checkout_metadata() { assert!(!identity.dirty); } +#[test] +fn nix_dev_channel_marks_a_gitless_build_without_changing_the_package_version() { + let root = tempfile::tempdir().expect("create nix fixture"); + + let identity = build_script::derive_identity(root.path(), "1.2.3", Some("nix-dev")).unwrap(); + + assert_eq!(identity.source, "package"); + assert_eq!(identity.commit, None); + assert_eq!(identity.describe, "nix-dev:1.2.3"); + assert!(!identity.dirty); +} + +#[test] +fn unknown_gitless_build_channel_is_rejected() { + let root = tempfile::tempdir().expect("create package fixture"); + + let error = + build_script::derive_identity(root.path(), "1.2.3", Some("unsupported")).unwrap_err(); + + assert_eq!( + error, + "unsupported POINTBREAK_BUILD_CHANNEL value \"unsupported\". Git-less builds accept either an unset channel (package:1.2.3) or `nix-dev` (nix-dev:1.2.3). Remove POINTBREAK_BUILD_CHANNEL for a source package, or set POINTBREAK_BUILD_CHANNEL=nix-dev for a Nix development package." + ); +} + #[test] fn manifest_root_git_metadata_is_fail_closed_when_malformed() { let root = tempfile::tempdir().expect("create malformed fixture"); fs::create_dir(root.path().join(".git")).expect("create partial git metadata"); - let error = build_script::derive_identity(root.path(), "1.2.3").unwrap_err(); + let error = build_script::derive_identity(root.path(), "1.2.3", None).unwrap_err(); assert!(error.contains("Git metadata"), "unexpected error: {error}"); } diff --git a/tests/cli_environment.rs b/tests/cli_environment.rs index c6505f86..273d377a 100644 --- a/tests/cli_environment.rs +++ b/tests/cli_environment.rs @@ -24,7 +24,7 @@ const PRODUCT_SELECTORS: [&str; 16] = [ ]; fn command(args: &[&str]) -> Command { - let mut command = Command::new(env!("CARGO_BIN_EXE_pointbreak")); + let mut command = Command::new(support::pointbreak_bin()); command.args(args); for selector in PRODUCT_SELECTORS { command.env_remove(selector); diff --git a/tests/cli_home.rs b/tests/cli_home.rs index 97d3859c..4ebd8f35 100644 --- a/tests/cli_home.rs +++ b/tests/cli_home.rs @@ -4,7 +4,7 @@ use std::path::PathBuf; use std::process::{Command, Output}; fn command(args: &[&str]) -> Command { - let mut command = Command::new(env!("CARGO_BIN_EXE_pointbreak")); + let mut command = Command::new(support::pointbreak_bin()); command.args(args); for selector in pointbreak::environment::RUNTIME_VARIABLES { command.env_remove(selector); diff --git a/tests/cli_identity_whoami.rs b/tests/cli_identity_whoami.rs index 8fb270fe..92cebc3f 100644 --- a/tests/cli_identity_whoami.rs +++ b/tests/cli_identity_whoami.rs @@ -5,7 +5,7 @@ use std::process::{Command, Output}; use support::git_repo::GitRepo; fn whoami_command(repo: &GitRepo) -> Command { - let mut command = Command::new(env!("CARGO_BIN_EXE_pointbreak")); + let mut command = Command::new(support::pointbreak_bin()); command .args(["identity", "whoami", "--repo"]) .arg(repo.path()) diff --git a/tests/cli_inspect_auth.rs b/tests/cli_inspect_auth.rs index 3d174f08..d17ad310 100644 --- a/tests/cli_inspect_auth.rs +++ b/tests/cli_inspect_auth.rs @@ -11,7 +11,7 @@ use support::git_repo::GitRepo; use support::inspect::{InspectOutput, InspectSurface, Inspector, representative_store, urlencode}; fn inspect_output(repo: &std::path::Path, extra: &[&str]) -> std::process::Output { - Command::new(env!("CARGO_BIN_EXE_pointbreak")) + Command::new(support::pointbreak_bin()) .args([ "inspect", "--repo", @@ -145,7 +145,7 @@ fn inspect_rejects_non_loopback_before_bind_in_every_combination() { fn api_only_rejects_open_independently_of_output_format() { let repo = GitRepo::new(); for format in [&[][..], &["--format", "json"][..]] { - let output = Command::new(env!("CARGO_BIN_EXE_pointbreak")) + let output = Command::new(support::pointbreak_bin()) .args([ "inspect", "--repo", diff --git a/tests/cli_inspect_endpoints.rs b/tests/cli_inspect_endpoints.rs index 2ba56227..77b21b9d 100644 --- a/tests/cli_inspect_endpoints.rs +++ b/tests/cli_inspect_endpoints.rs @@ -765,7 +765,7 @@ fn design_system_docs_state_the_component_state_contract() { #[test] fn design_system_bake_is_gated_by_a_local_live_token_audit() { - let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); + let root = support::manifest_dir(); let audit_path = root.join("src/cli/inspect/design-system/contrast-check.mjs"); let audit = std::fs::read_to_string(&audit_path) .unwrap_or_else(|error| panic!("{} must exist: {error}", audit_path.display())); @@ -803,7 +803,7 @@ fn design_system_bake_is_gated_by_a_local_live_token_audit() { #[test] fn design_system_gallery_is_claude_design_sync_ready() { - let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); + let root = support::manifest_dir(); let design_system = root.join("src/cli/inspect/design-system"); let styles = std::fs::read_to_string(design_system.join("styles.css")) .expect("design-system stylesheet is readable"); @@ -877,7 +877,7 @@ fn design_system_gallery_is_claude_design_sync_ready() { #[test] fn design_system_brand_assets_are_locked_and_verified_offline() { - let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); + let root = support::manifest_dir(); let design_system = root.join("src/cli/inspect/design-system"); let lock_path = design_system.join("pointbreak-brand.lock.json"); let lock_source = std::fs::read_to_string(&lock_path) @@ -1067,7 +1067,7 @@ fn design_system_gallery_covers_live_shell_and_overlay_states() { #[test] fn design_system_gallery_covers_the_shipped_attention_lens() { - let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); + let root = support::manifest_dir(); let body = std::fs::read_to_string( root.join("src/cli/inspect/design-system/_bodies/data-attention.body.html"), ) @@ -1106,7 +1106,7 @@ fn design_system_gallery_covers_the_shipped_attention_lens() { #[test] fn design_system_promotes_selected_review_treatments_and_soft_operational_dark() { - let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); + let root = support::manifest_dir(); let tokens = std::fs::read_to_string(root.join("src/cli/inspect/assets/tokens.css")) .expect("live Review tokens exist"); for declaration in [ @@ -1185,7 +1185,7 @@ fn design_system_promotes_selected_review_treatments_and_soft_operational_dark() #[test] fn design_system_final_state_has_no_temporary_visual_system() { - let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); + let root = support::manifest_dir(); assert!( !root.join("src/cli/inspect/design-system/variants").exists(), "the promoted visual system must not leave a temporary variant directory" @@ -1231,7 +1231,7 @@ fn design_system_final_state_has_no_temporary_visual_system() { #[test] fn design_system_soft_operational_dark_study_stays_gallery_only() { - let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); + let root = support::manifest_dir(); let study = root.join("src/cli/inspect/design-system/studies/soft-operational-dark"); for required in ["README.md", "tokens.css", "audit.mjs", "bake.sh"] { assert!( diff --git a/tests/cli_inspect_wire_parity.rs b/tests/cli_inspect_wire_parity.rs index 59cf9a85..a91a4932 100644 --- a/tests/cli_inspect_wire_parity.rs +++ b/tests/cli_inspect_wire_parity.rs @@ -39,10 +39,7 @@ use support::inspect::{Inspector, representative_store, urlencode}; use support::pointbreak; fn fixtures_dir() -> PathBuf { - PathBuf::from(concat!( - env!("CARGO_MANIFEST_DIR"), - "/src/cli/inspect/web/test/fixtures" - )) + support::manifest_dir().join("src/cli/inspect/web/test/fixtures") } /// A content-addressed opaque id of the form `:sha256:`. diff --git a/tests/cli_review_assessment.rs b/tests/cli_review_assessment.rs index ac3766ed..7f90c7fc 100644 --- a/tests/cli_review_assessment.rs +++ b/tests/cli_review_assessment.rs @@ -1096,7 +1096,7 @@ where I: IntoIterator, S: AsRef, { - let mut child = Command::new(env!("CARGO_BIN_EXE_pointbreak")) + let mut child = Command::new(support::pointbreak_bin()) .args(args) .env_remove("POINTBREAK_LOG") .env_remove("RUST_LOG") diff --git a/tests/cli_review_capture.rs b/tests/cli_review_capture.rs index eb4cb9bb..f69b4742 100644 --- a/tests/cli_review_capture.rs +++ b/tests/cli_review_capture.rs @@ -1130,7 +1130,7 @@ where I: IntoIterator, S: AsRef, { - Command::new(env!("CARGO_BIN_EXE_pointbreak")) + Command::new(env::pointbreak_bin()) .args(args) .env_remove("POINTBREAK_LOG") .env_remove("RUST_LOG") @@ -1503,3 +1503,8 @@ fn review_capture_path_composes_with_base_and_target() { assert_eq!(json["revision"]["base"]["kind"], "git_commit"); assert_eq!(json["revision"]["target"]["kind"], "git_commit"); } + +// Runtime-resolved binary/manifest paths for cross-machine (e.g. Windows) archive runs. +#[path = "support/env.rs"] +#[allow(dead_code)] +mod env; diff --git a/tests/cli_review_input_request.rs b/tests/cli_review_input_request.rs index 49e200cf..d797f657 100644 --- a/tests/cli_review_input_request.rs +++ b/tests/cli_review_input_request.rs @@ -924,7 +924,7 @@ where I: IntoIterator, S: AsRef, { - let mut child = Command::new(env!("CARGO_BIN_EXE_pointbreak")) + let mut child = Command::new(support::pointbreak_bin()) .args(args) .env_remove("POINTBREAK_LOG") .env_remove("RUST_LOG") diff --git a/tests/cli_review_observation.rs b/tests/cli_review_observation.rs index 63075a42..2e2ac521 100644 --- a/tests/cli_review_observation.rs +++ b/tests/cli_review_observation.rs @@ -982,7 +982,7 @@ where I: IntoIterator, S: AsRef, { - let mut child = Command::new(env!("CARGO_BIN_EXE_pointbreak")) + let mut child = Command::new(support::pointbreak_bin()) .args(args) .env_remove("POINTBREAK_LOG") .env_remove("RUST_LOG") diff --git a/tests/cli_store_paths.rs b/tests/cli_store_paths.rs index aac3df2b..54208100 100644 --- a/tests/cli_store_paths.rs +++ b/tests/cli_store_paths.rs @@ -5,7 +5,7 @@ use std::process::{Command, Output}; use support::git_repo::GitRepo; fn command(repo: &GitRepo, home: &std::path::Path, format: &str) -> Command { - let mut command = Command::new(env!("CARGO_BIN_EXE_pointbreak")); + let mut command = Command::new(support::pointbreak_bin()); command .args(["store", "paths", "--repo"]) .arg(repo.path()) diff --git a/tests/cli_tracing.rs b/tests/cli_tracing.rs index e6d56462..9985b5cc 100644 --- a/tests/cli_tracing.rs +++ b/tests/cli_tracing.rs @@ -180,7 +180,7 @@ where I: IntoIterator, S: AsRef, { - let mut command = Command::new(env!("CARGO_BIN_EXE_pointbreak")); + let mut command = Command::new(support::pointbreak_bin()); command .args(args) // Isolate byte-asserting tracing tests from an ambient output-lane selector; diff --git a/tests/cli_watch.rs b/tests/cli_watch.rs index b063f391..01130485 100644 --- a/tests/cli_watch.rs +++ b/tests/cli_watch.rs @@ -30,7 +30,7 @@ impl Watcher { } fn spawn_with_args(repo: &Path, poll_ms: u64, extra_args: &[&str]) -> Self { - let mut command = Command::new(env!("CARGO_BIN_EXE_pointbreak")); + let mut command = Command::new(support::pointbreak_bin()); command.args([ "history", "--repo", diff --git a/tests/docs_package_identity.rs b/tests/docs_package_identity.rs index af783397..ed30c8cf 100644 --- a/tests/docs_package_identity.rs +++ b/tests/docs_package_identity.rs @@ -1,4 +1,10 @@ -use std::path::Path; +//! Durable package- and product-identity contracts for the public surfaces. +//! +//! The `shore` -> `pointbreak` cutover guards that used to live here (a legacy-word +//! scanner over every doc, skill, and script, plus the 0.7.0 hard-cutover narrative) +//! were retired once that rename landed. What remains are the invariants that stay +//! true release to release: the crate and command identity, the canonical +//! organization repository, and the packaging metadata. #[test] fn readme_teaches_the_pointbreak_package_and_command() { @@ -6,67 +12,6 @@ fn readme_teaches_the_pointbreak_package_and_command() { assert!(readme.contains("cargo install pointbreak")); assert!(readme.contains("provides the `pointbreak` command")); - assert!(readme.contains("0.7.0")); - assert!(!readme.contains("cargo install shore\n")); - assert!(!readme.contains("cargo install shore ")); -} - -#[test] -fn installation_documents_the_one_release_hard_cutover() { - let installation = std::fs::read_to_string("docs/installation.md").expect("read installation"); - - for required in [ - "Release `0.7.0` is a one-release hard cutover", - "Stop every process that can write Review state", - "Move owner-controlled state and config offline", - "POINTBREAK_HOME", - "/.pointbreak/", - "/pointbreak/", - "/pointbreak.link.json", - "pointbreak store paths --repo --format json", - "verify readback", - "Rollback is the inverse filesystem move", - "no runtime fallback, compatibility alias, automatic migration, migration CLI", - ] { - assert!( - installation.contains(required), - "installation guide is missing hard-cutover guidance: {required:?}" - ); - } - - assert!(!installation.contains("pointbreak store migrate-paths")); - assert!(!installation.contains("pointbreak review")); -} - -#[test] -fn retired_documentation_host_is_never_presented_as_live() { - for path in LIVING_OPERATIONAL_SOURCES { - let contents = - std::fs::read_to_string(path).unwrap_or_else(|error| panic!("read {path}: {error}")); - if contents.contains("docs.withpointbreak.com") { - assert!( - contents.contains("archived") || contents.contains("retired"), - "{path} presents docs.withpointbreak.com without an archived/retired label" - ); - } - } -} - -#[test] -fn living_sources_teach_only_the_pointbreak_operational_contract() { - for path in LIVING_OPERATIONAL_SOURCES { - let contents = - std::fs::read_to_string(path).unwrap_or_else(|error| panic!("read {path}: {error}")); - - for (index, line) in contents.lines().enumerate() { - let line_number = index + 1; - for (needle, purpose) in FORBIDDEN_LIVING_PATTERNS { - if line.contains(needle) && classify_retained_reference(path, line).is_none() { - panic!("{path}:{line_number} presents {purpose}: {line:?}"); - } - } - } - } } #[test] @@ -96,254 +41,6 @@ fn generic_store_guidance_does_not_present_a_literal_path_as_universal() { } } -#[test] -fn legacy_product_word_detection_is_case_insensitive_and_word_bounded() { - assert!(contains_legacy_reference("matched by Shore at scan time")); - assert!(contains_legacy_reference("run SHORE_CONFIG=/tmp/config")); - assert!(!contains_legacy_reference("shoreline fixture")); -} - -#[test] -fn every_retained_public_legacy_reference_has_a_narrow_classification() { - let mut paths = vec![ - Path::new("CONTRIBUTING.md").to_path_buf(), - Path::new("README.md").to_path_buf(), - Path::new("Justfile").to_path_buf(), - ]; - if Path::new("CHANGELOG.md").exists() { - paths.push(Path::new("CHANGELOG.md").to_path_buf()); - } - for root in ["docs", "skills", "scripts", "benches"] { - collect_files(Path::new(root), &mut paths); - } - for root in [ - "tests/fixtures/event_signatures", - "tests/fixtures/legacy_stores", - "tests/fixtures/naming-cutover", - "tests/fixtures/packages", - "tests/fixtures/review_documents", - ] { - collect_files(Path::new(root), &mut paths); - } - paths.extend( - [ - "tests/agent_skill_validation_evidence.rs", - "tests/docs_open_source_readiness.rs", - "tests/docs_package_identity.rs", - ] - .into_iter() - .map(Path::new) - .map(Path::to_path_buf), - ); - paths.sort(); - - for path in paths { - let audit_path = public_audit_path(&path); - let contents = std::fs::read_to_string(&path) - .unwrap_or_else(|error| panic!("read {audit_path}: {error}")); - for (index, line) in contents.lines().enumerate() { - if contains_legacy_reference(line) - && classify_retained_reference(&audit_path, line).is_none() - { - panic!( - "{}:{} has an unclassified legacy reference: {:?}", - audit_path, - index + 1, - line - ); - } - } - } -} - -const LIVING_OPERATIONAL_SOURCES: &[&str] = &[ - "CONTRIBUTING.md", - "README.md", - "docs/agent-authoring.md", - "docs/assessment-model.md", - "docs/benchmarking.md", - "docs/cli-reference.md", - "docs/getting-started.md", - "docs/id-prefixes.md", - "docs/input-request-model.md", - "docs/installation.md", - "docs/library-api.md", - "docs/manual-testing.md", - "docs/releasing.md", - "docs/review-workflow.md", - "docs/signing-ux.md", - "docs/storage-model.md", - "docs/substrate-language.md", - "docs/substrate-thesis-summary.md", - "Justfile", - "benches/store_backend.rs", - "scripts/capture-inspector-screenshots.sh", - "scripts/worktree-to-fixture.sh", - "skills/README.md", - "skills/pointbreak-author/SKILL.md", - "skills/pointbreak-author-response/SKILL.md", - "skills/pointbreak-reviewer/SKILL.md", -]; - -const FORBIDDEN_LIVING_PATTERNS: &[(&str, &str)] = &[ - ("shore ", "a legacy executable command"), - ( - "SHORE_", - "a legacy environment variable as current guidance", - ), - (".shore", "a legacy path as current placement"), - ("pointbreak review", "the rejected review command prefix"), - ("cargo install shore", "the retired package/install name"), - ("cargo binstall shore", "the retired package/install name"), - ("store migrate-paths", "a migration CLI that does not exist"), - ("automatically migrates", "automatic migration behavior"), - ("automatically migrate", "automatic migration behavior"), -]; - -fn collect_files(root: &Path, paths: &mut Vec) { - for entry in - std::fs::read_dir(root).unwrap_or_else(|error| panic!("read {}: {error}", root.display())) - { - let entry = entry.unwrap_or_else(|error| panic!("read directory entry: {error}")); - let file_type = entry - .file_type() - .unwrap_or_else(|error| panic!("read {} file type: {error}", entry.path().display())); - if file_type.is_dir() { - collect_files(&entry.path(), paths); - } else if file_type.is_file() { - paths.push(entry.path()); - } - } -} - -fn public_audit_path(path: &Path) -> String { - path.to_string_lossy().replace('\\', "/") -} - -fn contains_legacy_reference(line: &str) -> bool { - line.contains(".shore") - || line.contains("SHORE_") - || contains_ascii_word(&line.to_ascii_lowercase(), "shore") -} - -#[test] -fn public_audit_paths_are_platform_independent() { - let path = public_audit_path(Path::new(r"docs\adr\adr-0001-example.md")); - - assert_eq!(path, "docs/adr/adr-0001-example.md"); - assert_eq!( - classify_retained_reference(&path, "frozen shore.note-body identifier"), - Some("accepted ADR history") - ); -} - -fn contains_ascii_word(haystack: &str, needle: &str) -> bool { - haystack.match_indices(needle).any(|(start, _)| { - let end = start + needle.len(); - let before = haystack[..start].chars().next_back(); - let after = haystack[end..].chars().next(); - !before.is_some_and(is_word_character) && !after.is_some_and(is_word_character) - }) -} - -fn is_word_character(character: char) -> bool { - character.is_ascii_alphanumeric() || character == '_' -} - -fn classify_retained_reference(path: &str, line: &str) -> Option<&'static str> { - if path.starts_with("docs/adr/") { - return Some("accepted ADR history"); - } - if path == "CHANGELOG.md" { - return Some("published changelog history"); - } - if [ - "tests/fixtures/event_signatures/", - "tests/fixtures/legacy_stores/", - "tests/fixtures/naming-cutover/", - "tests/fixtures/packages/", - "tests/fixtures/review_documents/", - ] - .iter() - .any(|prefix| path.starts_with(prefix)) - { - return Some("frozen fixture or captured machine document"); - } - if [ - "tests/agent_skill_validation_evidence.rs", - "tests/docs_open_source_readiness.rs", - "tests/docs_package_identity.rs", - ] - .contains(&path) - { - return Some("test intentionally quoting rejected or historical strings"); - } - - match path { - "README.md" if line.contains("assets/shore-inspector-") => { - Some("checked-in screenshot basename") - } - "docs/installation.md" - if line.starts_with(" |") - && [ - "/.shore/", - "/shore/", - "/shore.link.json", - "$XDG_DATA_HOME/shore", - "$HOME/.shore", - "%APPDATA%\\shore", - ] - .iter() - .any(|old_path| line.contains(old_path)) => - { - Some("explicit pre-0.7.0 location in the cutover table") - } - "docs/assessment-model.md" - | "docs/cli-reference.md" - | "docs/input-request-model.md" - | "docs/library-api.md" - if line.contains("shore.") => - { - Some("frozen persisted protocol identifier") - } - "docs/storage-model.md" if line.contains("shore.") => { - Some("frozen persisted protocol identifier") - } - "docs/storage-model.md" if line.contains(".shore-write") => { - Some("frozen atomic-write temporary filename") - } - "Justfile" if line.contains("shore(\\.exe)?|--bin shore") => { - Some("negative release-surface assertion") - } - "scripts/capture-inspector-screenshots.sh" - if line.contains("shore-inspector-") || line.contains("shore-inspect-") => - { - Some("checked-in screenshot basename or inspector preference key") - } - "scripts/install-selftest.sh" - if line.contains("neighbor=\"${install_dir}/shore\"") - || line.contains("grep -i 'shore'") => - { - Some("negative installer assertion and untouched-neighbor sentinel") - } - "scripts/install-selftest.ps1" - if line.contains("$neighbor = Join-Path $installDir \"shore.exe\"") - || line.contains("-match \"(?i)shore\"") => - { - Some("negative installer assertion and untouched-neighbor sentinel") - } - "scripts/package-release-selftest.sh" - if line.contains("payload_dir/shore") - || line.contains("-C \"$payload_dir\" shore") - || line.contains("shore.exe") - || line.contains("ln -s shore") => - { - Some("intentionally invalid archive or alias fixture") - } - _ => None, - } -} - #[test] fn skills_distribution_uses_the_canonical_repository_route() { let skills_readme = @@ -430,29 +127,9 @@ fn vscode_metadata_keeps_its_identity_and_uses_canonical_support_urls() { assert!(package.contains("https://github.com/withpointbreak/pointbreak#readme")); } -#[test] -fn readme_drops_branded_hunk_origin_references() { - let readme = std::fs::read_to_string("README.md").expect("read README"); - - for stale in [ - "modem-dev/hunk", - "kevinswiber/hunk", - "docs/hunk-feedback.md", - "Hunk is the practical inspiration", - "real Hunk review session", - "hunk fork", - ] { - assert!( - !readme.contains(stale), - "README still contains stale Hunk reference: {stale}" - ); - } - assert!(!Path::new("docs/hunk-feedback.md").exists()); -} - #[test] fn just_run_targets_the_pointbreak_binary() { let justfile = std::fs::read_to_string("Justfile").expect("read Justfile"); - assert!(justfile.contains("cargo +stable run --bin pointbreak --")); + assert!(justfile.contains("{{ cargo_stable }} run --bin pointbreak -- {{ args }}")); } diff --git a/tests/event_signature_vectors.rs b/tests/event_signature_vectors.rs index 92e55b1b..5696fc1c 100644 --- a/tests/event_signature_vectors.rs +++ b/tests/event_signature_vectors.rs @@ -9,7 +9,7 @@ //! -E 'test(regenerate_event_signature_fixtures)' --run-ignored all //! ``` -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use pointbreak::crypto::SignerId; use pointbreak::model::{EventId, JournalId}; @@ -29,7 +29,7 @@ mod support; use support::event_signature_fixtures::build_all_fixtures; fn fixture_dir() -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/event_signatures") + support::manifest_dir().join("tests/fixtures/event_signatures") } fn fixture_path(name: &str) -> PathBuf { @@ -54,7 +54,7 @@ fn fixture_event(name: &str) -> ShoreEvent { } fn naming_cutover_fixture_dir() -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/naming-cutover") + support::manifest_dir().join("tests/fixtures/naming-cutover") } fn sha256_hex(bytes: &[u8]) -> String { diff --git a/tests/github_actions.rs b/tests/github_actions.rs index 26812ed9..581f63bb 100644 --- a/tests/github_actions.rs +++ b/tests/github_actions.rs @@ -1,20 +1,46 @@ #[test] fn ci_workflow_runs_project_lint_and_tests() { let ci = std::fs::read_to_string(".github/workflows/ci.yml").expect("read CI workflow"); + let nightly = + std::fs::read_to_string(".github/workflows/nightly.yml").expect("read nightly workflow"); assert!(ci.contains("name: CI")); assert!(ci.contains("branches: [main]")); assert!(ci.contains("pull_request:")); assert!(ci.contains("actions/checkout@v6")); - assert!(ci.contains("dtolnay/rust-toolchain@stable")); - assert!(ci.contains("dtolnay/rust-toolchain@nightly")); - assert!(ci.contains("taiki-e/install-action@just")); - assert!(ci.contains("taiki-e/install-action@nextest")); assert!(ci.contains("ubuntu-latest")); assert!(ci.contains("macos-latest")); assert!(ci.contains("windows-latest")); - assert!(ci.contains("run: just lint")); - assert!(ci.contains("run: just test-ci")); + + // The Rust gate is split by platform, and all three legs live in this one workflow so + // the checks list shows the whole matrix. Linux runs hermetically as derivations; + // macOS runs cargo directly for an incremental target/; Windows executes a + // cross-compiled archive. + assert!(ci.contains("nix build .#cli-fmt")); + assert!(ci.contains("nix build .#cli-clippy")); + assert!(ci.contains("nix build .#cli-nextest")); + assert!(ci.contains("just test-ci")); + assert!(ci.contains("just windows-cross-archive x86_64-pc-windows-msvc")); + assert!(ci.contains("--archive-file")); + assert!(ci.contains("--partition")); + + // Linux and macOS take the compiler and formatter from the flake, so CI and a + // contributor's `nix develop` cannot drift apart. + assert!(ci.contains("DeterminateSystems/nix-installer-action")); + assert!(ci.contains("nix develop .#ci -c")); + + // Windows keeps rustup: Nix has no native Windows support, and the all-features + // type-check plus the qualification and git-parity legs still need a toolchain + // there. The sharded test legs no longer do — they run prebuilt binaries. + assert!(ci.contains("dtolnay/rust-toolchain@stable")); + assert!(ci.contains("taiki-e/install-action@just")); + assert!(ci.contains("taiki-e/install-action@nextest")); + + // Nightly carries what is too slow to gate, and nothing else: `nix flake check` in + // full, so a check added to the flake cannot be left ungated by the explicit phase + // list above. It must not run per pull request — a job that only skips is noise. + assert!(nightly.contains("nix flake check")); + assert!(!nightly.contains("pull_request")); } #[test] diff --git a/tests/inspector_capture_assets.rs b/tests/inspector_capture_assets.rs index 6043260a..c9c36c2d 100644 --- a/tests/inspector_capture_assets.rs +++ b/tests/inspector_capture_assets.rs @@ -1,7 +1,7 @@ use std::fs; #[cfg(unix)] use std::os::unix::fs::PermissionsExt; -use std::path::Path; +use std::path::{Path, PathBuf}; #[cfg(unix)] use std::process::Command; @@ -10,8 +10,8 @@ use sha2::{Digest, Sha256}; #[cfg(unix)] use tempfile::tempdir; -fn repo_root() -> &'static Path { - Path::new(env!("CARGO_MANIFEST_DIR")) +fn repo_root() -> PathBuf { + env::manifest_dir() } fn sha256(bytes: &[u8]) -> String { @@ -215,3 +215,8 @@ fn make_executable(path: &Path) { permissions.set_mode(0o755); fs::set_permissions(path, permissions).unwrap(); } + +// Runtime-resolved binary/manifest paths for cross-machine (e.g. Windows) archive runs. +#[path = "support/env.rs"] +#[allow(dead_code)] +mod env; diff --git a/tests/legacy_store_tombstone.rs b/tests/legacy_store_tombstone.rs index a1b92f62..e0ffe84c 100644 --- a/tests/legacy_store_tombstone.rs +++ b/tests/legacy_store_tombstone.rs @@ -16,7 +16,7 @@ const FIXTURE_STORE: &str = "tests/fixtures/legacy_stores/review_note_imported/s /// A fresh repo whose canonical store contains the checked-in legacy event bytes. fn repo_with_legacy_store() -> support::git_repo::GitRepo { let repo = dump_repo(); - let source = Path::new(env!("CARGO_MANIFEST_DIR")).join(FIXTURE_STORE); + let source = support::manifest_dir().join(FIXTURE_STORE); let target = repo.path().join(".git/pointbreak"); copy_dir(&source, &target); repo diff --git a/tests/no_review_unit_identifier.rs b/tests/no_review_unit_identifier.rs index 2bf983ed..9a06fdf6 100644 --- a/tests/no_review_unit_identifier.rs +++ b/tests/no_review_unit_identifier.rs @@ -76,7 +76,12 @@ fn visit_rust_sources(dir: &Path, allowed: &[&str]) { #[test] fn no_review_unit_identifier_remains_in_source() { - let src = Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + let src = env::manifest_dir().join("src"); let allowed = allowed_legacy_wire_literals(); visit_rust_sources(&src, &allowed); } + +// Runtime-resolved binary/manifest paths for cross-machine (e.g. Windows) archive runs. +#[path = "support/env.rs"] +#[allow(dead_code)] +mod env; diff --git a/tests/package_identity.rs b/tests/package_identity.rs index 27c9fcd4..ef3b0268 100644 --- a/tests/package_identity.rs +++ b/tests/package_identity.rs @@ -19,7 +19,7 @@ fn package_and_library_identity_are_pointbreak() { #[test] fn package_identity_declares_only_pointbreak_binary() { - let output = Command::new(env!("CARGO")) + let output = Command::new(env::cargo_bin()) .args(["metadata", "--no-deps", "--format-version", "1"]) .output() .expect("run cargo metadata"); @@ -49,7 +49,7 @@ fn package_identity_declares_only_pointbreak_binary() { assert_eq!(binary_targets, ["pointbreak"]); assert_eq!( - Path::new(env!("CARGO_BIN_EXE_pointbreak")).file_name(), + env::pointbreak_bin().file_name(), Some(OsStr::new(POINTBREAK_EXECUTABLE_BASENAME)) ); @@ -64,38 +64,11 @@ fn package_identity_declares_only_pointbreak_binary() { ); } -#[test] -fn cargo_install_exposes_only_pointbreak_executable() { - let install_root = tempfile::tempdir().expect("create cargo install root"); - let output = Command::new(env!("CARGO")) - .args(["install", "--path", env!("CARGO_MANIFEST_DIR"), "--root"]) - .arg(install_root.path()) - .arg("--debug") - .env("CARGO_TARGET_DIR", install_root.path().join("target")) - .output() - .expect("run cargo install"); - assert!( - output.status.success(), - "cargo install stderr:\n{}", - String::from_utf8_lossy(&output.stderr) - ); - - let mut installed = fs::read_dir(install_root.path().join("bin")) - .expect("read installed bin directory") - .map(|entry| entry.expect("installed bin entry").file_name()) - .collect::>(); - installed.sort(); - - assert_eq!(installed, [OsStr::new(POINTBREAK_EXECUTABLE_BASENAME)]); - assert!(!install_root.path().join("bin").join("shore").exists()); - assert!(!install_root.path().join("bin").join("shore.exe").exists()); -} - #[test] fn cli_help_uses_pointbreak_and_keeps_the_flat_command_tree() { let temp_dir = tempfile::tempdir().expect("create temp command dir"); let command_path = temp_dir.path().join("renamed-command.exe"); - fs::copy(env!("CARGO_BIN_EXE_pointbreak"), &command_path) + fs::copy(env::pointbreak_bin(), &command_path) .expect("copy pointbreak binary under an arbitrary filename"); ensure_executable(&command_path).expect("make copied binary executable"); @@ -137,8 +110,8 @@ fn cli_help_uses_pointbreak_and_keeps_the_flat_command_tree() { #[test] fn cli_version_uses_pointbreak_and_preserves_the_version_document() { - let command = env!("CARGO_BIN_EXE_pointbreak"); - let document = Command::new(command) + let command = env::pointbreak_bin(); + let document = Command::new(&command) .args(["version", "--format", "json"]) .output() .expect("run pointbreak version JSON"); @@ -163,7 +136,7 @@ fn cli_version_uses_pointbreak_and_preserves_the_version_document() { .expect("build describe"); assert!(document["build"]["dirty"].is_boolean()); - let version = Command::new(command) + let version = Command::new(&command) .arg("--version") .output() .expect("run pointbreak --version"); @@ -173,7 +146,7 @@ fn cli_version_uses_pointbreak_and_preserves_the_version_document() { format!("pointbreak {} ({describe})\n", env!("CARGO_PKG_VERSION")) ); - let text = Command::new(command) + let text = Command::new(&command) .args(["version", "--format", "text"]) .output() .expect("run pointbreak version text"); @@ -201,3 +174,8 @@ fn ensure_executable(path: &Path) -> io::Result<()> { fn ensure_executable(_path: &Path) -> io::Result<()> { Ok(()) } + +// Runtime-resolved binary/manifest paths for cross-machine (e.g. Windows) archive runs. +#[path = "support/env.rs"] +#[allow(dead_code)] +mod env; diff --git a/tests/producer_identity.rs b/tests/producer_identity.rs index 798a0860..bdc9f448 100644 --- a/tests/producer_identity.rs +++ b/tests/producer_identity.rs @@ -2,7 +2,7 @@ mod support; use std::collections::BTreeMap; use std::fs; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use pointbreak::session::event::ShoreEvent; use pointbreak::session::{ @@ -17,7 +17,7 @@ const HISTORICAL_EVENT_RECORD_HASH: &str = "sha256:cea1dd4ffbd3952266fb35b5a72fd369c74caa6b246ac446bcdc40f0920309a4"; fn historical_fixture_path() -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")) + support::manifest_dir() .join("tests/fixtures/event_signatures") .join(HISTORICAL_EVENT) } diff --git a/tests/review_document_contract.rs b/tests/review_document_contract.rs index 01792d64..0876dd4b 100644 --- a/tests/review_document_contract.rs +++ b/tests/review_document_contract.rs @@ -18,14 +18,14 @@ mod support; use std::fs; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use serde_json::Value; use support::git_repo::GitRepo; use support::pointbreak; fn snapshot_dir() -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/review_documents") + support::manifest_dir().join("tests/fixtures/review_documents") } /// Canonicalized absolute path of the fixture repo, as it appears in command diff --git a/tests/review_example_pack.rs b/tests/review_example_pack.rs index 1c23b5d4..08997841 100644 --- a/tests/review_example_pack.rs +++ b/tests/review_example_pack.rs @@ -15,7 +15,7 @@ use tempfile::tempdir; mod pack_support; fn pack_root() -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("examples/review/checkout-refactor") + env::manifest_dir().join("examples/review/checkout-refactor") } #[test] @@ -40,7 +40,7 @@ fn current_exporter_and_materialize_hint_use_pointbreak() { #[test] fn synthetic_decision_matrix_materializer_uses_only_isolated_pointbreak_surfaces() { - let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let root = env::manifest_dir(); let script_path = root.join("scripts/materialize-inspector-decision-matrix.sh"); assert!( script_path.is_file(), @@ -70,7 +70,7 @@ fn synthetic_decision_matrix_materializer_uses_only_isolated_pointbreak_surfaces #[test] fn inspector_decision_continuity_browser_gate_uses_isolated_pointbreak_surfaces() { - let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let root = env::manifest_dir(); let script_path = root.join("scripts/verify-inspector-decision-continuity.sh"); assert!( script_path.is_file(), @@ -534,3 +534,8 @@ fn write_json(path: &std::path::Path, value: &Value) { fn sha256(bytes: &[u8]) -> String { format!("{:x}", Sha256::digest(bytes)) } + +// Runtime-resolved binary/manifest paths for cross-machine (e.g. Windows) archive runs. +#[path = "support/env.rs"] +#[allow(dead_code)] +mod env; diff --git a/tests/runtime_path_resolution.rs b/tests/runtime_path_resolution.rs new file mode 100644 index 00000000..39a4e93d --- /dev/null +++ b/tests/runtime_path_resolution.rs @@ -0,0 +1,122 @@ +//! Guards the convention the cross-compiled Windows lane depends on. +//! +//! Windows tests are compiled on Linux and executed from a `cargo nextest` archive on a +//! runner with no Rust toolchain. `cargo-nextest` relocates that archive with +//! `--workspace-remap`, which rewrites the paths it hands the tests *at run time*. Anything +//! that captured a path at *compile* time still points at the Linux build machine, and the +//! test fails on Windows with a bare "system cannot find the path specified". +//! +//! That failure names no cause and reproduces on no other platform, so this guard exists to +//! turn it into a message that does. See docs/ci-architecture.md#windows. + +use std::fmt::Write as _; +use std::path::{Path, PathBuf}; + +#[path = "support/env.rs"] +#[allow(dead_code)] +mod env; + +/// Macros that capture a build-machine path at compile time. +const COMPILE_TIME_PATHS: &[&str] = &[ + r#"env!("CARGO_MANIFEST_DIR")"#, + r#"env!("CARGO_BIN_EXE_pointbreak")"#, + r#"env!("CARGO")"#, + r#"env!("OUT_DIR")"#, +]; + +/// The runtime resolvers themselves, each of which reads the environment first and keeps a +/// compile-time value only as the same-machine fallback. These are the intended home for +/// the macros above; everything else should call into one of them. +const RESOLVER_FILES: &[&str] = &[ + "tests/support/env.rs", + "tests/support/git_repo.rs", + "src/test_fixtures.rs", + "src/bench_support.rs", + "examples/support/review_example_pack.rs", + // This guard, which necessarily spells the macros out to search for them. + "tests/runtime_path_resolution.rs", +]; + +#[test] +fn tests_resolve_build_machine_paths_at_runtime() { + let root = env::manifest_dir(); + let mut sources = Vec::new(); + for dir in ["src", "tests", "benches", "examples"] { + collect_rust_sources(&root.join(dir), &mut sources); + } + assert!( + sources.len() > 100, + "source walk found only {} files; the guard is not looking where it thinks it is", + sources.len() + ); + + let mut violations = String::new(); + for path in sources { + let relative = relative_to(&root, &path); + if RESOLVER_FILES.contains(&relative.as_str()) { + continue; + } + let source = std::fs::read_to_string(&path) + .unwrap_or_else(|error| panic!("read {relative}: {error}")); + + for needle in COMPILE_TIME_PATHS { + for (offset, _) in source.match_indices(needle) { + if embeds_contents_at_compile_time(&source, offset) { + continue; + } + let line = source[..offset].lines().count(); + let _ = writeln!(violations, " {relative}:{line} {needle}"); + } + } + } + + assert!( + violations.is_empty(), + "These capture a build-machine path at compile time:\n\n{violations}\n\ + The Windows suite is cross-compiled on Linux and executed from a relocated nextest \n\ + archive, so a compile-time path points at a machine the test is not running on. It \n\ + passes locally and on Linux and macOS, then fails only on the Windows shards with \n\ + 'The system cannot find the path specified'.\n\n\ + Resolve at run time instead — `support::pointbreak_bin()`, `support::manifest_dir()`, \n\ + or `support::cargo_bin()` from tests/support/env.rs, `crate::test_fixtures::manifest_dir()` \n\ + inside the library's own tests, or `crate::bench_support::manifest_dir()` in the \n\ + benchmark harnesses. Each prefers the variable cargo-nextest rewrites and keeps the \n\ + compile-time value only as a same-machine fallback.\n\n\ + `include_str!`/`include_bytes!` are exempt and need no change: they embed the file's \n\ + bytes into the binary, so no path survives to be resolved.\n\n\ + Background: docs/ci-architecture.md#windows" + ); +} + +/// True when the macro at `offset` sits inside an `include_str!`/`include_bytes!`, which +/// embeds bytes at compile time and so carries no path into the built binary. +/// +/// Scoped to the enclosing statement — scanning back to the previous `;` — so an unrelated +/// `include_str!` earlier in the same block cannot excuse a later runtime path. +fn embeds_contents_at_compile_time(source: &str, offset: usize) -> bool { + let statement_start = source[..offset].rfind(';').map_or(0, |index| index + 1); + let statement = &source[statement_start..offset]; + statement.contains("include_str!") || statement.contains("include_bytes!") +} + +fn collect_rust_sources(dir: &Path, found: &mut Vec) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for entry in entries { + let entry = entry.expect("read directory entry"); + let path = entry.path(); + if entry.file_type().expect("stat entry").is_dir() { + collect_rust_sources(&path, found); + } else if path.extension().is_some_and(|extension| extension == "rs") { + found.push(path); + } + } +} + +fn relative_to(root: &Path, path: &Path) -> String { + path.strip_prefix(root) + .unwrap_or(path) + .to_string_lossy() + .replace('\\', "/") +} diff --git a/tests/support/env.rs b/tests/support/env.rs new file mode 100644 index 00000000..0ef45662 --- /dev/null +++ b/tests/support/env.rs @@ -0,0 +1,40 @@ +//! Runtime resolution of the values Cargo bakes in via `env!` at compile time. +//! +//! `env!("CARGO_BIN_EXE_pointbreak")` and `env!("CARGO_MANIFEST_DIR")` embed the build +//! machine's paths, which do not exist when a cargo-nextest archive is built on one +//! machine and run on another (e.g. cross-compiled to Windows and executed there). At +//! runtime nextest sets `CARGO_BIN_EXE_pointbreak` to the extracted binary and remaps +//! `CARGO_MANIFEST_DIR` via `--workspace-remap`, so prefer those and fall back to the +//! compile-time value for ordinary in-place runs (where the two are identical). +//! +//! Shared by both `mod support;` consumers (via `support::{pointbreak_bin, manifest_dir}`) +//! and standalone integration tests that include only this file with +//! `#[path = "support/env.rs"] mod env;`. + +use std::path::PathBuf; + +/// Absolute path to the built `pointbreak` binary under test. +#[allow(dead_code)] +pub fn pointbreak_bin() -> PathBuf { + std::env::var_os("CARGO_BIN_EXE_pointbreak") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(env!("CARGO_BIN_EXE_pointbreak"))) +} + +/// Absolute path to this crate's manifest directory, the root for fixture lookups. +#[allow(dead_code)] +pub fn manifest_dir() -> PathBuf { + std::env::var_os("CARGO_MANIFEST_DIR") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(env!("CARGO_MANIFEST_DIR"))) +} + +/// The `cargo` binary for tests that shell out to Cargo (metadata, install). +/// +/// Prefers the runtime `CARGO` that Cargo and cargo-nextest set in a test's environment, +/// falling back to a bare `cargo` resolved on `PATH` — deliberately not the compile-time +/// `env!("CARGO")`, whose baked build-machine path does not exist on a cross-machine run. +#[allow(dead_code)] +pub fn cargo_bin() -> std::ffi::OsString { + std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into()) +} diff --git a/tests/support/git_repo.rs b/tests/support/git_repo.rs index 27c0c638..f7a80550 100644 --- a/tests/support/git_repo.rs +++ b/tests/support/git_repo.rs @@ -107,9 +107,21 @@ impl Default for GitRepo { /// content files a fresh `git init` writes (`HEAD`, `config`, `info/exclude`); /// the always-empty scaffold directories git cannot track are recreated here so a /// later `add`/`commit` on real git has the structure it expects. +// Resolved locally rather than via the `support` parent: several integration tests +// pull this file in standalone with `#[path = "support/git_repo.rs"] mod git_repo;`, +// where `super` is that test's crate root, not `support`. Prefers the runtime +// `CARGO_MANIFEST_DIR` (cargo-nextest remaps it under `--workspace-remap`) so the +// skeleton resolves when the suite runs from an archive on another machine, falling +// back to the compile-time value for ordinary in-place runs. +fn manifest_dir() -> std::path::PathBuf { + std::env::var_os("CARGO_MANIFEST_DIR") + .map(std::path::PathBuf::from) + .unwrap_or_else(|| std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))) +} + fn copy_git_skeleton(git_dir: impl AsRef) { let git_dir = git_dir.as_ref(); - let skeleton = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/support/assets/git-skeleton"); + let skeleton = manifest_dir().join("tests/support/assets/git-skeleton"); copy_dir_recursive(&skeleton, git_dir); for scaffold in ["objects/info", "objects/pack", "refs/heads", "refs/tags"] { fs::create_dir_all(git_dir.join(scaffold)).expect("create git skeleton directory"); diff --git a/tests/support/inspect.rs b/tests/support/inspect.rs index b807b605..e8047fb1 100644 --- a/tests/support/inspect.rs +++ b/tests/support/inspect.rs @@ -104,7 +104,7 @@ impl Inspector { } fn spawn_with(repo: &Path, surface: InspectSurface, output: InspectOutput) -> Self { - let mut command = Command::new(env!("CARGO_BIN_EXE_pointbreak")); + let mut command = Command::new(super::pointbreak_bin()); command.args([ "inspect", "--repo", @@ -512,12 +512,11 @@ fn decision_matrix_shell() -> Command { pub fn decision_continuity_matrix() -> DecisionContinuityMatrix { let root = tempfile::tempdir().expect("decision matrix root"); let repo = root.path().join("repository"); - let script = PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("scripts/materialize-inspector-decision-matrix.sh"); + let script = super::manifest_dir().join("scripts/materialize-inspector-decision-matrix.sh"); let output = decision_matrix_shell() .arg(&script) .arg(&repo) - .env("POINTBREAK_BINARY", env!("CARGO_BIN_EXE_pointbreak")) + .env("POINTBREAK_BINARY", super::pointbreak_bin()) .env_remove("POINTBREAK_HOME") .env_remove("POINTBREAK_FORMAT") .env_remove("POINTBREAK_SIGNING_KEY") diff --git a/tests/support/mod.rs b/tests/support/mod.rs index 26ef9189..028e81ba 100644 --- a/tests/support/mod.rs +++ b/tests/support/mod.rs @@ -2,6 +2,8 @@ use std::ffi::OsStr; use std::path::Path; use std::process::{Command, Output}; +#[allow(dead_code)] +pub mod env; #[allow(dead_code)] pub mod event_signature_fixtures; #[allow(dead_code)] @@ -11,13 +13,17 @@ pub mod inspect; #[allow(dead_code)] pub mod snapshots; +// Runtime-resolved `pointbreak` binary and manifest dir; see `env`. Re-exported so +// `mod support;` consumers keep calling `support::{pointbreak_bin, manifest_dir}`. +pub use env::{manifest_dir, pointbreak_bin}; + #[allow(dead_code)] pub fn pointbreak(args: I) -> Output where I: IntoIterator, S: AsRef, { - Command::new(env!("CARGO_BIN_EXE_pointbreak")) + Command::new(pointbreak_bin()) .args(args) .env_remove("POINTBREAK_LOG") .env_remove("RUST_LOG") @@ -44,7 +50,7 @@ where I: IntoIterator, S: AsRef, { - let mut command = Command::new(env!("CARGO_BIN_EXE_pointbreak")); + let mut command = Command::new(pointbreak_bin()); command .args(args) .env_remove("POINTBREAK_LOG")