diff --git a/.anvil.lock b/.anvil.lock index 1a40a729..23b72e2e 100644 --- a/.anvil.lock +++ b/.anvil.lock @@ -1,7 +1,7 @@ version = 1 tool = "anvil" tool_version = "0.5.0" -catalog_checksum = "sha256:1388eee0dd1074adb96e6a944c65f2542ec4d6da90d17ad401ee453270359d3b" +catalog_checksum = "sha256:8a37e7a016007072b986b6e1ac500abfd13c1318ab4b6360c94cb37cf89f3ed4" [[file]] path = ".anvil/container/Dockerfile.dockerignore" @@ -69,7 +69,7 @@ checksum = "sha256:681c8952690496c7d29c7bf83a5ab99c193088b05a5dc7ea3b3b0ed240090 [[file]] path = "justfiles/anvil/checks/cargo-sort.just" -checksum = "sha256:2652ae52153eebe3fdb1dfd553aa65cab84d11054d5314470d55774a92a742b3" +checksum = "sha256:98c9f90e1b6b3beba5d818bf9a4023068eb61e9f071ffb6f9cd009a7374e9a30" [[file]] path = "justfiles/anvil/checks/clippy.just" @@ -105,7 +105,7 @@ checksum = "sha256:d003f1d82898706c136f42c3156d5ecc8f288c18f125c964b9dca1d24fa93 [[file]] path = "justfiles/anvil/checks/fmt.just" -checksum = "sha256:967b68ef3ed3e0dcac2a1ea22ef92c66a7db802bda718f6e5ce39f529ed8e492" +checksum = "sha256:618907282b9b7d1e5c094d0b29c1c7231465078b617fa233219fe8c4870437cb" [[file]] path = "justfiles/anvil/checks/license-headers.just" @@ -117,7 +117,7 @@ checksum = "sha256:689f7d5b1eaea32672f8d0b9fff0994cc03bc56fb8ef282a4286f930fc503 [[file]] path = "justfiles/anvil/checks/loom.just" -checksum = "sha256:4bf509bcca3507aafd9848ce19ea3691ab2cd77de05b344e042f3ba2c300e35b" +checksum = "sha256:a919429e9693c230ba8c9a194c41127708acf2cb1bfd196049a1386e7bb7b4e4" [[file]] path = "justfiles/anvil/checks/miri-race-coverage.just" @@ -153,7 +153,7 @@ checksum = "sha256:6d4b4c3e4a59e825a3f613e1011272c711d8d6cbdef315e5f6aadf653c6ae [[file]] path = "justfiles/anvil/checks/semver-check.just" -checksum = "sha256:021ea0f7185b183aeb5a53810e4a227964710593410e4a1942b4b2dea3fe4547" +checksum = "sha256:52b31bec3c5d271b25f46d9b1dd6aca4b84e394fd8cf46bfa5386a3df7ed3999" [[file]] path = "justfiles/anvil/checks/spellcheck.just" @@ -205,7 +205,7 @@ checksum = "sha256:04679222579a090769403f5aae7c9ffabbf5bd559ddb1f17b5a0655152172 [[file]] path = "justfiles/anvil/helpers.just" -checksum = "sha256:1208b9a51b94ef9bf60a898d23c35c97175279c661a90e774ec3960989b4c5c9" +checksum = "sha256:a7e3962f3b81b8943cdfc43a7d113da35bcaaa58f21269d1c6114aafb62beafe" [[file]] path = "justfiles/anvil/impact.just" @@ -221,11 +221,11 @@ checksum = "sha256:00453a12cbb34811ee6a2c083dade5f6198575e3b0610f49e4743366326cd [[file]] path = "justfiles/anvil/tools.just" -checksum = "sha256:a1e44ca16f172b487afa3997f102512733d3b65a4418cf894cbd749a3abc17dc" +checksum = "sha256:c1f5181d14a734cde8ccc32bbf5acece485791573b7a95cfff61b63d0f1fcb97" [[file]] path = "justfiles/anvil/versions.just" -checksum = "sha256:983e6732348188becb8f22a4af5237a13ac2579d566a9c4ff4bab6e724336077" +checksum = "sha256:b48c7a29f694acdc4ff02a15bfdae5a286c1d0b6272fffad3acefbd8b1f2dcaa" [[region]] host = ".anvil/container/Dockerfile" diff --git a/.github/workflows/regenerate-check.yml b/.github/workflows/regenerate-check.yml index f1e21836..82af4818 100644 --- a/.github/workflows/regenerate-check.yml +++ b/.github/workflows/regenerate-check.yml @@ -6,7 +6,7 @@ # # 1. Builds cargo-anvil from the PR branch. # 2. Runs `cargo anvil --dry-run` against the repo root. -# 3. Fails iff the binary would write or propose anything. +# 3. Fails iff the binary would change generated content or `.anvil.lock`. # # This is the primary dogfooding mechanism described in # crates/cargo-anvil/docs/verification.md. @@ -17,10 +17,7 @@ name: regenerate-check on: - pull_request: - paths: - - "crates/cargo-anvil/**" - - ".github/workflows/regenerate-check.yml" + pull_request: {} merge_group: {} workflow_dispatch: {} diff --git a/Cargo.toml b/Cargo.toml index 2d11ae6e..6d98ab1d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,8 +27,8 @@ anyhow = { version = "1.0.104", default-features = false } assert_cmd = { version = "2.2.2", default-features = false } async-once-cell = { version = "0.5", default-features = false } blake3 = { version = "1.8.7", default-features = false } -bolero = { version = "0.13.4", default-features = false } -# bolero-libfuzzer 0.13.0 calls bolero_engine::any without enabling the feature itself. +# bolero-libfuzzer 0.13.0 calls bolero_engine::any; bolero/std enables that API for its host build. +bolero = { version = "0.13.4", default-features = false, features = ["std"] } bolero-engine = { version = "0.13.4", default-features = false } bytes = { version = "1.12.1", default-features = false } camino = { version = "1.2.5", default-features = false } diff --git a/crates/cargo-anvil/docs/design/checks.md b/crates/cargo-anvil/docs/design/checks.md index 3496e885..c82343d4 100644 --- a/crates/cargo-anvil/docs/design/checks.md +++ b/crates/cargo-anvil/docs/design/checks.md @@ -157,9 +157,9 @@ that provided the strongest version of the check. | Check | Invocation | Source | |--------------------------------|-----------------------------------------------------------|--------| -| `fmt` | `cargo + fmt --all --check` | all | +| `fmt` | `cargo each --workspace --keep-going -- cargo + fmt --manifest-path {manifest} --check`. `cargo-each` resolves workspace membership and invokes rustfmt once per manifest, keeping child commands bounded on every platform while reporting every failing member. Unlike `cargo fmt --all`, local path dependencies outside the workspace are not included. | all | | `clippy` | `cargo clippy --workspace --all-targets --all-features --locked -- -D warnings` | all | -| `cargo-sort` | `cargo sort --workspace --grouped --check --check-format` | oxidizer-github | +| `cargo-sort` | `cargo sort --workspace --grouped --check --check-format`. Since cargo-sort 2.1.2, formatting-only differences are warnings unless `--check-format` is set; Anvil keeps it load-bearing so dependency ordering and Cargo manifest formatting are both enforced. `--grouped` preserves intentional blank-line-separated dependency groups. | oxidizer-github | | `license-headers` | `cargo heather --workspace` | oxidizer (`heather`), oxidizer-github | | `ensure-no-cyclic-deps` | `cargo ensure-no-cyclic-deps --workspace` | oxidizer-github (sibling crate in `ox-tools-gh`) | | `ensure-no-default-features` | `cargo ensure-no-default-features --workspace` | oxidizer-github | @@ -170,7 +170,7 @@ that provided the strongest version of the check. | `deny` | `cargo deny check` | all | | `audit` | `cargo audit` | oxidizer | | `udeps` | `cargo + udeps --workspace --all-features` run **twice** — once with default targets (lib + bins) and once with `--all-targets`. cargo-udeps only analyzes the targets it's told to, and each run catches a variant the other masks: the default-targets run surfaces a dep in `[dependencies]` referenced only by tests/benches/examples (it should be a dev-dep; `--all-targets` would see it as "used"), while the `--all-targets` run surfaces unused `[dev-dependencies]` (never compiled by the default-targets run). Together they cover unused deps, unused dev-deps, and deps that should be dev-deps. | oxidizer, oxidizer-github | -| `semver-check` | `cargo semver-checks --baseline-rev ` per affected library crate. The PR target is the baseline. Exit 100 is a completed check with deny-level findings; exit 101 or another nonzero status means the comparison was inconclusive. Both outcomes write `target/anvil/comments/semver.md` and remain advisory, matching the repository's native `semver` job (`continue-on-error: true`). Proven rename and bin→lib transitions with no comparable baseline, and dependencies proven to be yanked only in the checked-out baseline tree, are skipped without a comment. Anvil preflight failures such as invalid current-workspace metadata or an unavailable baseline ref still fail because the recipe cannot establish what to compare. | oxidizer-github | +| `semver-check` | `cargo semver-checks --baseline-rev ` per affected publishable library crate. Crates with `publish = false` and bin-only crates have no registry consumer contract and are skipped. The PR target is the baseline. Exit 100 is a completed check with deny-level findings; exit 101 or another nonzero status means the comparison was inconclusive. Both outcomes write `target/anvil/comments/semver.md` and remain advisory, matching the repository's native `semver` job (`continue-on-error: true`). Proven rename and bin→lib transitions with no comparable baseline, and dependencies proven to be yanked only in the checked-out baseline tree, are skipped without a comment. Anvil preflight failures such as invalid current-workspace metadata or an unavailable baseline ref still fail because the recipe cannot establish what to compare. | oxidizer-github | | `external-types` | `cargo + check-external-types --manifest-path` per library crate (per-manifest because the tool has no `--workspace`/`--package`; bin-only crates have no public API surface and are skipped). Setup installs the catalog version but validation accepts newer installed tools. The selected nightly is tested with the catalog version; an incompatible newer tool fails closed with a tool/nightly compatibility diagnostic rather than silently selecting a different schema. | oxidizer-github | ### `pr-slow` @@ -198,8 +198,8 @@ This is the same set of checks that used to live in the standalone `pr-test` gro |-----------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------| | `miri` | `cargo + miri test --all-features --tests` over the impact-affected packages. Uses libtest (one process per test binary), **not** `cargo miri nextest run`: under miri, nextest's process-per-test model pays miri's expensive std-initialization re-interpretation for *every* test and roughly doubles wall-time on a large suite (the dominant cost on the PR critical path). `--tests` runs lib/bin unit tests and integration tests (the same target set nextest ran) while excluding doctests, which miri can't run; it is used in preference to `--lib --tests` because `--lib` errors with "no library targets found" on a bin-only affected package under impact scoping. Slow tests opt out per-test with `#[cfg_attr(miri, ignore)]` -- anvil doesn't pass exotic `MIRIFLAGS`; the per-test opt-out is the canonical mechanism. libtest exits 0 when a binary's tests are all skipped, so no `--no-tests=pass` workaround is needed. The recipe reads its scope from the `target/anvil/impact/` cache via `_anvil-impact-include`; because it depends on `anvil-impact`, a clean direct or PR invocation is impact-scoped (unaffected packages are skipped). It runs the full workspace only when scoping is off — the scheduled/full tiers set `ANVIL_IMPACT=off`, and a dirty local tree widens for safety. | oxidizer, oxidizer-github | | `careful` | `cargo + careful test --all-features --locked` over the impact-affected packages. cargo-careful uses a debug-instrumented std in a stable cache path. Because Cargo fingerprints the sysroot path rather than its contents, the recipe records the actual `rustc -vV` and SHA-256 of the resolved `cargo-careful` executable in `target/anvil/careful-sysroot.id`; either changing triggers `cargo clean`. The executable hash is used because cargo-careful rejects version-only invocations. This remains correct when validation accepts a newer installed cargo-careful. | oxidizer-github | -| `loom` | For each `[[test]]` target that declares `required-features = ["loom"]`, `cargo test -p --release --all-features --locked --test -- --test-threads=1` with `RUSTFLAGS="--cfg loom"`. [`loom`](https://crates.io/crates/loom) is a permutation-based concurrency model checker that explores thread interleavings. Targets are detected **structurally** from `cargo metadata` (a test target whose `kind` contains `test` and whose `required-features` contains `loom`) -- not via a filename/cfg/comment heuristic -- and only those targets run, so loom never touches a crate's ordinary tests. The `loom` feature selects the target (`required-features`); `--cfg loom` activates loom (source swaps std↔loom atomics on `#[cfg(loom)]`, and `[target.'cfg(loom)'.dependencies] loom` links only under the cfg) -- both are required. Scoped per-package with `-p` (never `--workspace`) so the global cfg never leaks into deps reachable only through other members. **Fail-loud**: a crate that declares loom support (a `loom` feature or a `cfg(loom)` dependency) but exposes no such test target errors out rather than silently no-opping. When no crate ships a loom target the recipe skips (exit 0). | oxidizer-github | -| `bolero` | Uses the catalog nightly and release profile consistently to discover targets one package at a time, then runs each affected libfuzzer target for 60 seconds on Linux. Explicitly selecting `release` avoids cargo-bolero's implicit, adopter-defined `fuzz` profile and matches target execution. Per-package discovery is required because `cargo-bolero list` accepts only one `--package`; local whole-workspace runs enumerate workspace members before discovery. A successful empty discovery is a no-op; metadata, discovery, or parsing failure fails the check. Non-Linux hosts skip because cargo-bolero's native dependencies are unsupported there, while harnesses still run as ordinary tests. | oxidizer-github | +| `loom` | For each `[[test]]` target that declares `required-features = ["loom"]`, `cargo test -p --release --all-features --locked --test -- --test-threads=1` with `RUSTFLAGS="--cfg loom"`. [`loom`](https://crates.io/crates/loom) is a permutation-based concurrency model checker that explores thread interleavings. Anvil does not impose a global exploration bound: each model owns its topology and must remain tractable under exhaustive exploration, avoiding redundant symmetric participants that multiply equivalent schedules. Targets are detected **structurally** from `cargo metadata` (a test target whose `kind` contains `test` and whose `required-features` contains `loom`) -- not via a filename/cfg/comment heuristic -- and only those targets run, so loom never touches a crate's ordinary tests. The `loom` feature selects the target (`required-features`); `--cfg loom` activates loom (source swaps std↔loom atomics on `#[cfg(loom)]`, and `[target.'cfg(loom)'.dependencies] loom` links only under the cfg) -- both are required. Scoped per-package with `-p` (never `--workspace`) so the global cfg never leaks into deps reachable only through other members. **Fail-loud**: a crate that declares loom support (a `loom` feature or a `cfg(loom)` dependency) but exposes no such test target errors out rather than silently no-opping. When no crate ships a loom target the recipe skips (exit 0). | oxidizer-github | +| `bolero` | Uses the catalog nightly and release profile consistently to discover targets one package at a time, then runs each affected libfuzzer target for 60 seconds on Linux. Explicitly selecting `release` avoids cargo-bolero's implicit, adopter-defined `fuzz` profile and matches target execution. Adopters that disable `bolero`'s default features must enable its `std` feature; that feature supplies the compile-time `bolero-engine/any` support required by the libfuzzer adapter. Per-package discovery is required because `cargo-bolero list` accepts only one `--package`; local whole-workspace runs enumerate workspace members before discovery. A successful empty discovery is a no-op; metadata, discovery, or parsing failure fails the check. Non-Linux hosts skip because cargo-bolero's native dependencies are unsupported there, while harnesses still run as ordinary tests. | oxidizer-github | #### `pr-mutants` (mutation testing) @@ -345,7 +345,7 @@ Each catalog check is tagged with one of four buckets: | Bucket | `$include` tier | Behavior when a tier value is present | Behavior when unscoped (`ANVIL_IMPACT=off` / no cache) | |-----------|-------------------------------|-----------------------------------------------------------------------------|--------------------------------------| -| modified | `_anvil-impact-include modified` | If `--skip`: exit 0. Otherwise run unconditionally (tool is workspace-wide). | Run unconditionally. | +| modified | `_anvil-impact-include modified` | If `--skip`: exit 0. Otherwise run the check's complete selected scope without package splicing. | Run the complete selected scope. | | affected | `_anvil-impact-include affected` | If `--skip`: exit 0. Otherwise splice the value into the cargo invocation. | Default to `--workspace`. | | required | `_anvil-impact-include required` | If `--skip`: exit 0. Otherwise splice the value into the cargo invocation. | Default to `--workspace`. | | unscoped | *(none)* | Always run. | Always run. | diff --git a/crates/cargo-anvil/docs/design/local.md b/crates/cargo-anvil/docs/design/local.md index e22da4a2..9c9fe6d0 100644 --- a/crates/cargo-anvil/docs/design/local.md +++ b/crates/cargo-anvil/docs/design/local.md @@ -488,7 +488,7 @@ Each check requests one cargo-delta **category** — the selector it passes to | Category | What recipes do with it | |------------|------------------------------------------------------------------------------------------------| -| `modified` | `--skip` → recipe exits 0. Otherwise: run unconditionally (modified-category tools are workspace-wide). | +| `modified` | `--skip` → recipe exits 0. Otherwise: run the check's complete selected scope without package splicing. | | `affected` | `--skip` → recipe exits 0. Otherwise: splice the value into the cargo invocation, defaulting to `--workspace` when empty. | | `required` | Same semantics as affected, but consumed by recipes that need the transitive dep graph in scope (doc-build, cargo-hack, udeps). | @@ -513,6 +513,18 @@ identifier ''` to stderr and exits non-zero rather than guessing (and risk silently under-scoped tier that skips a check). Failing hard surfaces the mapping gap so it gets fixed, instead of masking it behind a silently full-workspace run. +A typical modified-tier recipe checks every workspace member while keeping each +rustfmt child command bounded. Unlike `cargo fmt --all`, this intentionally does +not discover non-member local path dependencies: + +```just +anvil-fmt: + $include = (& "{{ just_executable() }}" _anvil-impact-include modified) + if ($include -eq '--skip') { exit 0 } + cargo each --workspace --keep-going '--' \ + cargo '+{{ rust_nightly }}' fmt --manifest-path '{manifest}' --check +``` + The mapping from check to bucket is fixed in the catalog (see [checks.md §5](./checks.md#5-impact-scoping-check--include-mapping)). Unscoped checks — `pr-title`, `deny`, `audit`, `aprz`, `mutants-full`, and the repo-level-input checks diff --git a/crates/cargo-anvil/docs/design/updates.md b/crates/cargo-anvil/docs/design/updates.md index 0b071d1d..7b87b4f1 100644 --- a/crates/cargo-anvil/docs/design/updates.md +++ b/crates/cargo-anvil/docs/design/updates.md @@ -413,11 +413,11 @@ work — provided they hadn't customized it. ### Exit codes -- `--dry-run` exit code 0: the run would change nothing on disk and every artifact was - safely inspected. +- `--dry-run` exit code 0: the run would change nothing on disk, including + `.anvil.lock`, and every artifact was safely inspected. - `--dry-run` exit code 1: the run would change something on disk, including writing a - `.anvil-proposed` sibling, or Anvil refused to manage an artifact it could not safely - inspect. + `.anvil-proposed` sibling or refreshing manifest checksums and inventory, or Anvil + refused to manage an artifact it could not safely inspect. The same partitioning is printed at the end of every non-`--dry-run` `update`. diff --git a/crates/cargo-anvil/docs/verification.md b/crates/cargo-anvil/docs/verification.md index 829ade0f..e2823bc0 100644 --- a/crates/cargo-anvil/docs/verification.md +++ b/crates/cargo-anvil/docs/verification.md @@ -150,12 +150,11 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: dtolnay/rust-toolchain@stable - run: cargo build --locked -p cargo-anvil - - name: Regenerate emitted files - run: ./target/debug/cargo-anvil anvil - name: Assert no drift run: | - if ! git diff --exit-code; then - echo "::error::cargo-anvil changed files. Run 'cargo anvil' locally and commit the diff." + if ! ./target/debug/cargo-anvil anvil --dry-run; then + echo "::error::cargo-anvil would change generated files or .anvil.lock." \ + "Run 'cargo anvil' locally and commit the diff." exit 1 fi @@ -164,10 +163,10 @@ jobs: uses: ./.github/workflows/anvil-pr-impl.yml ``` -The `regenerate-check` job runs first. If a PR changes the catalog or emitter without -also committing the regenerated output, this fails with an actionable message. After -that, the standard `anvil-pr-impl.yml` reusable workflow runs every group, exactly as -in any consumer repo. +The `regenerate-check` job runs on every PR. If a change leaves either emitted +content or `.anvil.lock` out of date, this fails with an actionable message. +After that, the standard `anvil-pr-impl.yml` reusable workflow runs every group, +exactly as in any consumer repo. The wrapper workflow above is the **one** hand-written workflow in ox-tools — it bootstraps the dogfood loop. Every other cloud workflows artifact is regenerated. diff --git a/crates/cargo-anvil/src/anvil/artifacts/justfile.rs b/crates/cargo-anvil/src/anvil/artifacts/justfile.rs index 8546d786..e6a23e06 100644 --- a/crates/cargo-anvil/src/anvil/artifacts/justfile.rs +++ b/crates/cargo-anvil/src/anvil/artifacts/justfile.rs @@ -689,6 +689,7 @@ mod tests { "cargo_nextest_version", "cargo_llvm_cov_version", "cargo_deny_version", + "cargo_each_version", "cargo_mutants_version", ] { assert!(VERSIONS_JUST.contains(needle), "versions.just missing variable '{needle}'"); diff --git a/crates/cargo-anvil/src/checksum.rs b/crates/cargo-anvil/src/checksum.rs index 90f280d3..bab396a1 100644 --- a/crates/cargo-anvil/src/checksum.rs +++ b/crates/cargo-anvil/src/checksum.rs @@ -50,7 +50,8 @@ const HEX: &[u8; 16] = b"0123456789abcdef"; /// Bare CR bytes are left alone — they're vanishingly rare in /// modern source trees, and treating them specially would risk /// false-equating distinct content. -fn normalize_line_endings(data: &[u8]) -> Vec { +#[must_use] +pub(crate) fn normalize_line_endings(data: &[u8]) -> Vec { // Pre-allocate optimistically; the output is the same length as // the input minus one byte per CRLF. let mut out = Vec::with_capacity(data.len()); diff --git a/crates/cargo-anvil/src/plan.rs b/crates/cargo-anvil/src/plan.rs index 9d749c55..afb9bacb 100644 --- a/crates/cargo-anvil/src/plan.rs +++ b/crates/cargo-anvil/src/plan.rs @@ -239,6 +239,7 @@ pub struct Plan { items: Vec, notes: Vec, refusals: Vec, + manifest_update_required: bool, } impl Plan { @@ -285,10 +286,15 @@ impl Plan { &mut self.items } + /// Record whether applying this plan would change `.anvil.lock`. + pub(crate) fn set_manifest_update_required(&mut self, required: bool) { + self.manifest_update_required = required; + } + /// Whether the plan would change anything on disk if applied. #[must_use] pub fn has_changes(&self) -> bool { - self.items.iter().any(|i| i.decision.writes()) + self.manifest_update_required || self.items.iter().any(|i| i.decision.writes()) } /// Exit code for `--dry-run`: 0 if everything is in sync and every @@ -351,6 +357,10 @@ impl Plan { write_section(&mut out, "No longer managed (left in place; ownership transferred)", &orphans_kept); write_section(&mut out, "Will leave alone (silent)", &leave_alones); + if self.manifest_update_required { + let _ = writeln!(out, "Manifest update required: 1 item(s)"); + let _ = writeln!(out, " - .anvil.lock"); + } if !in_syncs.is_empty() { let _ = writeln!(out, "Unchanged: {} item(s)", in_syncs.len()); } @@ -380,8 +390,8 @@ impl Plan { /// subsequent runs see the divergence as resolved /// (`LeaveAlone`) until the template moves again — see /// [`updates.md §5`](../../docs/design/updates.md). - /// - `InSync`, `LeaveAlone` items preserve their existing - /// manifest entries from `previous_manifest`. + /// - `InSync` items refresh their manifest checksum; `LeaveAlone` + /// items preserve their existing manifest entries. /// - Stale entries — items present in `previous_manifest` but not /// in this plan (e.g. a removed workspace member, a backend the /// user disabled) — are purged from the returned manifest. @@ -397,127 +407,72 @@ impl Plan { /// items must carry a spliced host. These invariants are enforced /// by the `PlanItem::*` constructors, so violations only happen if /// callers build `PlanItem` directly with inconsistent fields. + pub fn apply(&self, repo_root: &Path, previous_manifest: &Manifest) -> Result { + self.apply_files(repo_root)?; + Ok(self.projected_manifest(previous_manifest)) + } + + /// Apply only the owned-file and managed-region changes. + /// + /// Manifest projection is deliberately separate so dry-run and real + /// application calculate the same next `.anvil.lock`. #[expect( - clippy::too_many_lines, clippy::expect_used, - reason = "single dispatch site covering every (target × decision) pair; the expects encode constructor-enforced invariants" + reason = "the expects encode invariants enforced by the PlanItem constructors" )] - pub fn apply(&self, repo_root: &Path, previous_manifest: &Manifest) -> Result { - let mut next = Manifest { - tool: previous_manifest.tool.clone(), - tool_version: previous_manifest.tool_version.clone(), - catalog_checksum: previous_manifest.catalog_checksum.clone(), - files: previous_manifest.files.clone(), - regions: previous_manifest.regions.clone(), - }; - + pub(crate) fn apply_files(&self, repo_root: &Path) -> Result<(), AppError> { for item in &self.items { match (&item.target, item.decision) { (Target::File { path }, Decision::Write) => { let content = item.rendered.as_ref().expect("Write decision must carry rendered content"); - let abs = contained_path(repo_root, path)?; - write_file(&abs, content)?; - if let Some(checksum) = &item.rendered_checksum { - next.files.insert(path.clone(), checksum.clone()); - } + write_file(&contained_path(repo_root, path)?, content)?; } (Target::File { path }, Decision::Propose) => { let content = item.rendered.as_ref().expect("Propose decision must carry rendered content"); - let abs = contained_path(repo_root, &format!("{path}.anvil-proposed"))?; - write_file(&abs, content)?; - if let Some(checksum) = &item.rendered_checksum { - // Bump L to the new T so subsequent runs see the - // divergence as resolved (LeaveAlone). The user's - // .anvil-proposed sibling stays on disk for - // review; deleting or accepting it is the user's - // job. - next.files.insert(path.clone(), checksum.clone()); - } + write_file(&contained_path(repo_root, &format!("{path}.anvil-proposed"))?, content)?; } - (Target::Region { host, id }, Decision::Write) => { + (Target::Region { host, .. }, Decision::Write) => { let spliced = item.spliced_host.as_ref().expect("region Write must carry spliced host"); - let abs = contained_path(repo_root, host)?; - write_file(&abs, spliced)?; - if let Some(checksum) = &item.rendered_checksum { - next.regions.insert( - RegionKey { - host: host.clone(), - id: id.clone(), - }, - checksum.clone(), - ); - } + write_file(&contained_path(repo_root, host)?, spliced)?; } - (Target::Region { host, id }, Decision::Propose) => { + (Target::Region { host, .. }, Decision::Propose) => { let spliced = item.spliced_host.as_ref().expect("region Propose must carry spliced host"); - let abs = contained_path(repo_root, &format!("{host}.anvil-proposed"))?; - write_file(&abs, spliced)?; - if let Some(checksum) = &item.rendered_checksum { - // Same rationale as the File/Propose branch: bump - // L = T so subsequent runs see LeaveAlone until - // the template moves again. - next.regions.insert( - RegionKey { - host: host.clone(), - id: id.clone(), - }, - checksum.clone(), - ); - } + write_file(&contained_path(repo_root, &format!("{host}.anvil-proposed"))?, spliced)?; } (Target::File { path }, Decision::Remove) => { - // Untouched orphan file: delete and drop the - // manifest entry. The path is resolved to its on-disk - // casing first, because the manifest key is whatever - // casing was recorded and the file may since have been - // renamed in case only; deleting the unresolved path - // would leave the file behind with no lock entry. - // If the file is already missing (race / external - // delete), absorb the error so the result is idempotent. - let abs = contained_path(repo_root, &resolve_existing_case_insensitive(repo_root, path))?; + let actual_path = resolve_existing_case_insensitive(repo_root, path); + let abs = contained_path(repo_root, &actual_path)?; if let Err(e) = std::fs::remove_file(&abs) && e.kind() != std::io::ErrorKind::NotFound { - return Err::(e).into_app_err_with(|| format!("failed to remove {}", abs.display())); + return Err::<(), _>(e).into_app_err_with(|| format!("failed to remove {}", abs.display())); } - next.files.remove(path); } - (Target::Region { host, id }, Decision::Remove) => { - // Untouched orphan region: splice the markers + body - // out of the host file and drop the manifest entry. - // The host is resolved to its on-disk casing for the - // write, for the reason the File arm above gives; the - // manifest key stays as recorded so the entry is purged. + (Target::Region { host, .. }, Decision::Remove) => { let spliced = item.spliced_host.as_ref().expect("region Remove must carry spliced host"); - let abs = contained_path(repo_root, &resolve_existing_case_insensitive(repo_root, host))?; - write_file(&abs, spliced)?; - next.regions.remove(&RegionKey { - host: host.clone(), - id: id.clone(), - }); - } - (Target::File { path }, Decision::OrphanedKept) => { - // Customized orphan: leave the file in place, - // transfer ownership by dropping the manifest entry. - next.files.remove(path); - } - (Target::Region { host, id }, Decision::OrphanedKept) => { - // Customized orphan region: leave the host file - // and the in-region content in place, transfer - // ownership by dropping the manifest entry. - next.regions.remove(&RegionKey { - host: host.clone(), - id: id.clone(), - }); + let actual_host = resolve_existing_case_insensitive(repo_root, host); + write_file(&contained_path(repo_root, &actual_host)?, spliced)?; } - (_, Decision::InSync) => { - // Disk content matches the current template. No - // file write needed. We DO refresh the manifest L - // to the current template checksum if the plan - // item carries one — this self-heals stale-L - // values left over from older binary versions - // whose hash function differed (e.g. before - // line-ending normalization). + (_, Decision::InSync | Decision::LeaveAlone | Decision::OrphanedKept) => {} + } + } + Ok(()) + } + + /// Calculate the manifest produced by this plan without touching disk. + #[must_use] + pub(crate) fn projected_manifest(&self, previous_manifest: &Manifest) -> Manifest { + let mut next = Manifest { + tool: previous_manifest.tool.clone(), + tool_version: previous_manifest.tool_version.clone(), + catalog_checksum: previous_manifest.catalog_checksum.clone(), + files: previous_manifest.files.clone(), + regions: previous_manifest.regions.clone(), + }; + + for item in &self.items { + match item.decision { + Decision::Write | Decision::Propose | Decision::InSync => { if let Some(checksum) = &item.rendered_checksum { match &item.target { Target::File { path } => { @@ -535,13 +490,22 @@ impl Plan { } } } - (_, Decision::LeaveAlone) => { - // No-op; manifest entry already preserved. - } + Decision::Remove | Decision::OrphanedKept => match &item.target { + Target::File { path } => { + next.files.remove(path); + } + Target::Region { host, id } => { + next.regions.remove(&RegionKey { + host: host.clone(), + id: id.clone(), + }); + } + }, + Decision::LeaveAlone => {} } } - Ok(next) + next } } @@ -828,6 +792,16 @@ mod tests { assert!(!plan.has_changes()); } + #[test] + fn manifest_only_update_is_reported_as_a_change() { + let mut plan = Plan::default(); + plan.set_manifest_update_required(true); + + assert!(plan.has_changes()); + assert_eq!(plan.dry_run_exit_code(), 1); + assert!(plan.summary(None).contains(".anvil.lock")); + } + #[test] fn summary_categorizes_items() { let mut plan = Plan::default(); diff --git a/crates/cargo-anvil/src/run.rs b/crates/cargo-anvil/src/run.rs index 228305fc..0f1dcc6e 100644 --- a/crates/cargo-anvil/src/run.rs +++ b/crates/cargo-anvil/src/run.rs @@ -17,7 +17,7 @@ use crate::anvil::artifacts::region::DELTA_REGION_ID; use crate::backend::{self, Backend}; use crate::catalog::Catalog; use crate::catalog::artifact::{Artifact, ComposedHost, HostSelector, RegionSpec}; -use crate::checksum::checksum_str; +use crate::checksum::{checksum_str, normalize_line_endings}; use crate::cli::Cli; use crate::decision::{Decision, RemovalDecision, decide_removal}; use crate::emit::{ManagedRegionRequest, plan_managed_region, plan_owned_file}; @@ -99,19 +99,24 @@ pub fn run_update(catalog: &Catalog, args: &Cli, start_dir: &Path) -> Result` not found in ..."). # - crates where the branch baseline lacks a lib target the # current source has (bin -> bin+lib transition). -# We pre-filter to library-bearing crates from cargo metadata, skip +# We pre-filter to publishable library-bearing crates from cargo metadata, skip # packages whose current manifest path is absent from the baseline, # then run cargo-semver-checks per-package and tolerate the remaining # "no-comparable-baseline" failure modes. @@ -37,8 +38,8 @@ # # TODO(anvil-runner): even after a `cargo ox-run` helper absorbs the # skip/splat preamble, this recipe stays multi-step: cargo-metadata -# filter to library crates (cargo-semver-checks --workspace fails on -# bin-only workspaces), intersect the affected set ($include) with that +# filter to publishable library crates (cargo-semver-checks --workspace +# fails on bin-only workspaces), intersect the affected set ($include) with that # set, verify the PR branch baseline is present, skip newly-added # manifest paths, then invoke per-crate against that baseline with # special handling for renamed crates ("package `` not found in @@ -56,14 +57,14 @@ anvil-semver-check: anvil-semver-check-validate-prereqs anvil-impact exit 0 } $pkg = @(if ($include) { -split $include } else { '--workspace' }) - # Build the candidate package list. Keep each package's metadata so + # Build the publishable candidate package list. Keep each package's metadata so # its manifest path can also be checked in the baseline. Always # iterate per-package over library crates only -- cargo-semver-checks # --workspace would fail on workspaces that contain bin-only crates # (reported as a missing lib target), and we want the same tolerance for # new / renamed / bin->lib-transition crates regardless of whether # we got here via impact-scoping (cloud workflows) or full-workspace - # fallback (local). + # fallback (local). Crates with `publish = false` have no registry consumer contract. $libPkgs = @{} $metadataJson = & cargo metadata --no-deps --format-version 1 if ($LASTEXITCODE -ne 0) { @@ -77,7 +78,8 @@ anvil-semver-check: anvil-semver-check-validate-prereqs anvil-impact exit 1 } foreach ($p in $meta.packages) { - if ($p.targets | Where-Object { $_.kind -contains 'lib' }) { + if (($p.targets | Where-Object { $_.kind -contains 'lib' }) -and + -not ($p.publish -is [array] -and $p.publish.Count -eq 0)) { $libPkgs[$p.name] = $p } } @@ -96,7 +98,7 @@ anvil-semver-check: anvil-semver-check-validate-prereqs anvil-impact } } if ($packages.Count -eq 0) { - Write-Host 'anvil-semver-check: no affected library crates; skipping' + Write-Host 'anvil-semver-check: no affected publishable library crates; skipping' Remove-Item -LiteralPath $commentFile -ErrorAction SilentlyContinue exit 0 } diff --git a/crates/cargo-anvil/templates/justfiles/anvil/helpers.just b/crates/cargo-anvil/templates/justfiles/anvil/helpers.just index 5be4b411..cda16c0d 100644 --- a/crates/cargo-anvil/templates/justfiles/anvil/helpers.just +++ b/crates/cargo-anvil/templates/justfiles/anvil/helpers.just @@ -10,10 +10,11 @@ # Each check belongs to one of four buckets, which determines how it scopes # its work to the packages impacted by a change: # -# - "modified": only run when at least one package's source files changed -# in the diff. The check's underlying tool is workspace-wide or -# directory-scoped (cargo fmt --all, cargo heather), so -# it doesn't take --package; it short-circuits on the "--skip" sentinel. +# - "modified": only run when at least one package's source files +# changed in the diff. The tier is only a skip gate: once admitted, +# the check runs its complete selected scope rather than splicing +# package arguments. That scope may be workspace members (cargo each) +# or the whole workspace (cargo heather, cargo sort). # # - "affected": run on the affected set (modified ∪ reverse-deps within the # workspace). The tool takes --package; the recipe splices the resolved @@ -55,8 +56,8 @@ # # This keeps the per-recipe boilerplate to a single capture, a one-line skip # guard, and the cargo invocation. Modified-tier recipes never splice -# `$include` into cargo (their tools are workspace-wide); they only check the -# skip sentinel. +# `$include` into their command; they only check the skip sentinel and then run +# the check's complete selected scope. # # Every recipe whose body uses multi-line conditionals or array-splat # splicing is annotated with [script("pwsh", "-NoProfile")]. pwsh is preinstalled on diff --git a/crates/cargo-anvil/templates/justfiles/anvil/tools.just b/crates/cargo-anvil/templates/justfiles/anvil/tools.just index cb6aab14..d7062aa7 100644 --- a/crates/cargo-anvil/templates/justfiles/anvil/tools.just +++ b/crates/cargo-anvil/templates/justfiles/anvil/tools.just @@ -631,6 +631,14 @@ anvil-tool-cargo-doc2readme-install installer="install": (_install-tool "cargo-d [group("anvil-setup")] anvil-tool-cargo-doc2readme-validate-prereqs: (_check-tool "cargo-doc2readme" cargo_doc2readme_version) +# Install the pinned `cargo-each` tool. +[group("anvil-setup")] +anvil-tool-cargo-each-install installer="install": (_install-tool "cargo-each" cargo_each_version installer) + +# Validate that the pinned `cargo-each` tool is available. +[group("anvil-setup")] +anvil-tool-cargo-each-validate-prereqs: (_check-tool "cargo-each" cargo_each_version) + # Install the pinned `cargo-ensure-no-cyclic-deps` tool. [group("anvil-setup")] anvil-tool-cargo-ensure-no-cyclic-deps-install installer="install": (_install-tool "cargo-ensure-no-cyclic-deps" cargo_ensure_no_cyclic_deps_version installer) diff --git a/crates/cargo-anvil/templates/justfiles/anvil/versions.just b/crates/cargo-anvil/templates/justfiles/anvil/versions.just index 0a4aeaa1..6dbdfa72 100644 --- a/crates/cargo-anvil/templates/justfiles/anvil/versions.just +++ b/crates/cargo-anvil/templates/justfiles/anvil/versions.just @@ -58,6 +58,7 @@ cargo_coverage_gate_version := "0.2.0" cargo_delta_version := "0.3.1" cargo_deny_version := "0.20.2" cargo_doc2readme_version := "0.7.3" +cargo_each_version := "0.1.0" cargo_ensure_no_cyclic_deps_version := "0.2.0" cargo_ensure_no_default_features_version := "1.1.0" cargo_hack_version := "0.6.45" diff --git a/crates/cargo-anvil/tests/cli.rs b/crates/cargo-anvil/tests/cli.rs index 5f528dca..0b76ae6c 100644 --- a/crates/cargo-anvil/tests/cli.rs +++ b/crates/cargo-anvil/tests/cli.rs @@ -131,6 +131,27 @@ fn apply_writes_files_then_dry_run_is_clean() { anvil(ws.path(), &["--no-backends", "--dry-run"]).assert().success(); } +#[test] +fn dry_run_fails_when_only_the_lockfile_is_stale() { + let ws = workspace(); + anvil(ws.path(), &["--no-backends"]).assert().success(); + let lock_path = ws.path().join(".anvil.lock"); + let current = std::fs::read_to_string(&lock_path).unwrap(); + let stale = current.replace( + current.lines().find(|line| line.starts_with("catalog_checksum = ")).unwrap(), + "catalog_checksum = \"sha256:stale\"", + ); + std::fs::write(&lock_path, &stale).unwrap(); + + anvil(ws.path(), &["--no-backends", "--dry-run"]) + .assert() + .failure() + .code(1) + .stdout(predicates::str::contains(".anvil.lock")); + + assert_eq!(std::fs::read_to_string(lock_path).unwrap(), stale); +} + #[test] fn dry_run_refusal_exits_1_without_writing() { let ws = workspace(); diff --git a/crates/cargo-anvil/tests/recipe_contracts.rs b/crates/cargo-anvil/tests/recipe_contracts.rs index fb4d8349..4ef58f71 100644 --- a/crates/cargo-anvil/tests/recipe_contracts.rs +++ b/crates/cargo-anvil/tests/recipe_contracts.rs @@ -8,6 +8,7 @@ reason = "panic-on-failure idioms are appropriate in tests" )] +use std::collections::HashSet; use std::ffi::{OsStr, OsString}; use std::fmt::Write as _; use std::fs; @@ -18,21 +19,56 @@ use tempfile::TempDir; const HELPERS: &str = include_str!("../templates/justfiles/anvil/helpers.just"); const IMPACT: &str = include_str!("../templates/justfiles/anvil/impact.just"); -#[cfg(target_os = "linux")] const BOLERO: &str = include_str!("../templates/justfiles/anvil/checks/bolero.just"); +const FMT: &str = include_str!("../templates/justfiles/anvil/checks/fmt.just"); const LLVM_COV: &str = include_str!("../templates/justfiles/anvil/checks/llvm-cov.just"); +const LOOM: &str = include_str!("../templates/justfiles/anvil/checks/loom.just"); const SEMVER: &str = include_str!("../templates/justfiles/anvil/checks/semver-check.just"); const EXTERNAL_TYPES: &str = include_str!("../templates/justfiles/anvil/checks/external-types.just"); const TOOLS: &str = include_str!("../templates/justfiles/anvil/tools.just"); const APRZ: &str = include_str!("../templates/justfiles/anvil/checks/aprz.just"); const MUTANTS_DIFF: &str = include_str!("../templates/justfiles/anvil/checks/mutants-diff.just"); const VERSIONS: &str = include_str!("../templates/justfiles/anvil/versions.just"); +const REGENERATE_WORKFLOW: &str = include_str!("../../../.github/workflows/regenerate-check.yml"); const CONTAINER: &str = include_str!("../templates/justfiles/anvil/container.just"); + +#[test] +fn regeneration_check_runs_on_every_pull_request() { + assert!(REGENERATE_WORKFLOW.contains("pull_request: {}")); + assert!( + !REGENERATE_WORKFLOW.contains("\n paths:"), + "the dogfood drift gate must not be limited by changed paths" + ); +} + +#[test] +fn loom_does_not_globally_limit_exploration() { + assert!( + !LOOM.contains("LOOM_MAX_PREEMPTIONS"), + "loom models must own their exploration scope rather than inheriting a global preemption cap" + ); +} + +#[test] +fn bolero_uses_its_supported_release_profile_option() { + assert!( + BOLERO.contains("bolero test --profile release"), + "bolero execution must select the release profile with cargo-bolero's supported option" + ); + assert!( + !BOLERO.contains("bolero test --release"), + "cargo-bolero 0.13.4 does not accept Cargo's --release shorthand" + ); +} + const FAKE_CARGO_PS1: &str = r#" $joined = $args -join ' ' if ($env:FAKE_CARGO_LOG) { Add-Content -LiteralPath $env:FAKE_CARGO_LOG -Value $joined } +if ($args -contains 'each') { + exit [int]$env:FAKE_EACH_EXIT +} if ($args -contains 'metadata') { if ($env:FAKE_METADATA_EXIT) { exit [int]$env:FAKE_METADATA_EXIT } if ($env:FAKE_METADATA_INVALID) { @@ -54,6 +90,11 @@ if ($args -contains 'metadata') { id = "$packageName 0.1.0" manifest_path = $manifestPath targets = @([pscustomobject]@{ name = $libName; kind = @('lib') }) + publish = if ($env:FAKE_PUBLISH_FALSE) { + Write-Output -NoEnumerate @() + } else { + $null + } metadata = [pscustomobject]@{ 'coverage-gate' = [pscustomobject]@{ 'min-lines-percent' = 0 } } @@ -71,6 +112,23 @@ if ($args -contains 'metadata') { id = "$($env:FAKE_SECOND_PACKAGE_NAME) 0.1.0" manifest_path = [System.IO.Path]::Combine($root, 'nested', $secondDirLeaf, 'Cargo.toml') targets = @([pscustomobject]@{ name = $env:FAKE_SECOND_PACKAGE_NAME; kind = @('lib') }) + publish = $null + metadata = [pscustomobject]@{} + } + } + if ($env:FAKE_THIRD_PACKAGE_NAME) { + $packages += [pscustomobject]@{ + name = $env:FAKE_THIRD_PACKAGE_NAME + version = '0.1.0' + id = "$($env:FAKE_THIRD_PACKAGE_NAME) 0.1.0" + manifest_path = [System.IO.Path]::Combine( + $root, + 'nested', + $env:FAKE_THIRD_PACKAGE_NAME, + 'Cargo.toml' + ) + targets = @([pscustomobject]@{ name = $env:FAKE_THIRD_PACKAGE_NAME; kind = @('lib') }) + publish = @('private-registry') metadata = [pscustomobject]@{} } } @@ -145,6 +203,8 @@ fn fixture(imports: &[(&str, &str)], dependency_recipes: &[&str]) -> TempDir { // already defines it and Just rejects duplicate definitions. if !imports.iter().any(|(name, _)| *name == "versions.just") { justfile.push_str("rust_nightly := \"nightly-test\"\n\n"); + justfile.push_str("rust_nightly_external_types := \"nightly-test\"\n\n"); + justfile.push_str("cargo_check_external_types_version := \"0.0.0-test\"\n\n"); } for (name, contents) in imports { write(&tmp.path().join(name), contents); @@ -693,9 +753,9 @@ fn public_api_checks_fail_when_metadata_discovery_fails() { "anvil-external-types", &[ "anvil-tool-cargo-check-external-types-validate-prereqs", - "anvil-toolchain-external-types-validate-prereqs", + "anvil-toolchain-nightly-external-types-validate-prereqs", "anvil-tool-cargo-check-external-types-install installer", - "anvil-toolchain-external-types-install", + "anvil-toolchain-nightly-external-types-install", "anvil-impact", ][..], ), @@ -710,6 +770,147 @@ fn public_api_checks_fail_when_metadata_discovery_fails() { } } +#[test] +fn fmt_delegates_workspace_iteration_to_cargo_each() { + if !tools_available() { + return; + } + let tmp = fixture( + &[("fmt.just", FMT), ("impact.just", IMPACT)], + &[ + "anvil-component-nightly-rustfmt-validate-prereqs", + "anvil-component-nightly-rustfmt-install", + "anvil-tool-cargo-each-validate-prereqs", + "anvil-tool-cargo-each-install installer", + "anvil-impact", + ], + ); + let log = tmp.path().join("cargo.log"); + let output = run_just(tmp.path(), &["anvil-fmt"], &[("FAKE_CARGO_LOG", log.as_os_str())]); + assert!( + output.status.success(), + "per-package formatting failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let commands = std::fs::read_to_string(&log).unwrap(); + assert!( + commands.contains("each --workspace --keep-going -- cargo +nightly-test fmt --manifest-path {manifest} --check"), + "unexpected cargo invocation: {commands}" + ); + assert!(!commands.contains("fmt --all")); +} + +#[test] +fn fmt_propagates_cargo_each_failure() { + if !tools_available() { + return; + } + let tmp = fixture( + &[("fmt.just", FMT), ("impact.just", IMPACT)], + &[ + "anvil-component-nightly-rustfmt-validate-prereqs", + "anvil-component-nightly-rustfmt-install", + "anvil-tool-cargo-each-validate-prereqs", + "anvil-tool-cargo-each-install installer", + "anvil-impact", + ], + ); + let output = run_just(tmp.path(), &["anvil-fmt"], &[("FAKE_EACH_EXIT", OsStr::new("23"))]); + assert_failed(&output, "anvil-fmt cargo-each failure"); +} + +#[test] +fn external_types_checks_every_library_including_non_publishable_ones() { + if !tools_available() { + return; + } + let tmp = fixture( + &[("external-types.just", EXTERNAL_TYPES), ("impact.just", IMPACT)], + &[ + "anvil-tool-cargo-check-external-types-validate-prereqs", + "anvil-toolchain-nightly-external-types-validate-prereqs", + "anvil-tool-cargo-check-external-types-install installer", + "anvil-toolchain-nightly-external-types-install", + "anvil-impact", + ], + ); + seed_include(tmp.path(), "affected", "--workspace"); + let log = tmp.path().join("cargo.log"); + let output = run_just( + tmp.path(), + &["anvil-external-types"], + &[ + ("FAKE_CARGO_LOG", log.as_os_str()), + ("FAKE_PUBLISH_FALSE", OsStr::new("1")), + ("FAKE_SECOND_PACKAGE_NAME", OsStr::new("public-default")), + ("FAKE_SECOND_PACKAGE_DIR_LEAF", OsStr::new("public-default")), + ("FAKE_THIRD_PACKAGE_NAME", OsStr::new("public-registry")), + ], + ); + assert!( + output.status.success(), + "library selection failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let commands = std::fs::read_to_string(log).unwrap(); + assert!(commands.contains("metadata --no-deps --format-version 1")); + let expected_manifests = [ + tmp.path().join("Cargo.toml").to_string_lossy().into_owned(), + tmp.path() + .join("nested") + .join("public-default") + .join("Cargo.toml") + .to_string_lossy() + .into_owned(), + tmp.path() + .join("nested") + .join("public-registry") + .join("Cargo.toml") + .to_string_lossy() + .into_owned(), + ]; + assert_eq!( + commands + .lines() + .filter_map(|command| { command.strip_prefix("+nightly-test check-external-types --manifest-path ") }) + .map(str::to_owned) + .collect::>(), + expected_manifests.into_iter().collect() + ); +} + +#[test] +fn semver_skips_non_publishable_libraries() { + if !tools_available() { + return; + } + let tmp = fixture( + &[("helpers.just", HELPERS), ("semver.just", SEMVER), ("impact.just", IMPACT)], + &[ + "anvil-tool-cargo-semver-checks-validate-prereqs", + "anvil-tool-cargo-semver-checks-install installer", + "anvil-impact", + ], + ); + seed_include(tmp.path(), "affected", "--package fixture@0.1.0"); + let log = tmp.path().join("cargo.log"); + let output = run_just( + tmp.path(), + &["anvil-semver-check"], + &[("FAKE_CARGO_LOG", log.as_os_str()), ("FAKE_PUBLISH_FALSE", OsStr::new("1"))], + ); + assert!( + output.status.success(), + "non-publishable semver filtering failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!(!std::fs::read_to_string(log).unwrap().contains("semver-checks")); + assert!(String::from_utf8_lossy(&output.stdout).contains("no affected publishable library crates")); +} + #[test] fn all_coverage_opted_out_packages_run_both_test_configurations() { if !tools_available() { diff --git a/crates/cargo-anvil/tests/snapshots/snapshots__ado_backend.snap b/crates/cargo-anvil/tests/snapshots/snapshots__ado_backend.snap index 597aa901..ba24404d 100644 --- a/crates/cargo-anvil/tests/snapshots/snapshots__ado_backend.snap +++ b/crates/cargo-anvil/tests/snapshots/snapshots__ado_backend.snap @@ -1976,16 +1976,12 @@ anvil-cargo-hack-validate-prereqs: anvil-tool-cargo-hack-validate-prereqs # Modified tier. # -# `--check-format` makes the formatting diff fail the check, not just the -# sort order. cargo-sort 2.1.2+ downgraded formatting-only diffs to -# warnings by default (PR #129); without this flag cargo-sort 2.1.2+ -# would pass on formatting drift. The flag preserves the intended -# check behavior across the supported range. +# cargo-sort 2.1.2 and newer report formatting-only differences as warnings +# unless `--check-format` is set. Keep it enabled so dependency ordering and +# Cargo manifest style are both enforced. # # `--grouped` keeps the blank lines between groups of key/value pairs -# (e.g. blank-line-separated dependency blocks) intact, matching -# oxidizer-github's convention; without it cargo-sort would flag those -# blank lines as a formatting diff and fail the check. +# (e.g. blank-line-separated dependency blocks) intact. # Check that Cargo.toml dependency tables are sorted. [script("pwsh", "-NoProfile")] @@ -2332,6 +2328,10 @@ anvil-external-types-validate-prereqs: anvil-toolchain-nightly-external-types-va # format_code_in_doc_comments). Floating nightly would mean # format-drift breaking cloud workflows on rustup updates — the same trap we # explicitly avoid for udeps/miri/careful/external-types. +# +# Iterate workspace members rather than every local path dependency that +# `cargo fmt --all` discovers. `--keep-going` reports formatting failures from +# every member while retaining a bounded rustfmt command line per invocation. # Check Rust source formatting. [script("pwsh", "-NoProfile")] @@ -2339,7 +2339,7 @@ anvil-fmt: anvil-fmt-validate-prereqs anvil-impact $ErrorActionPreference = 'Stop' $include = (& "{{ just_executable() }}" _anvil-impact-include modified) if ($include -eq '--skip') { Write-Host 'anvil-fmt: no modified packages; skipping'; exit 0 } - cargo '+{{ rust_nightly }}' fmt --all --check + cargo each --workspace --keep-going '--' cargo '+{{ rust_nightly }}' fmt --manifest-path '{manifest}' --check if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } # Per-check setup + validate-prereqs @@ -2359,11 +2359,11 @@ anvil-fmt: anvil-fmt-validate-prereqs anvil-impact # Install prerequisites for the `anvil-fmt` recipe. [group("anvil-setup")] -anvil-fmt-setup installer="install": anvil-component-nightly-rustfmt-install +anvil-fmt-setup installer="install": anvil-component-nightly-rustfmt-install (anvil-tool-cargo-each-install installer) # Validate prerequisites for the `anvil-fmt` recipe. [group("anvil-setup")] -anvil-fmt-validate-prereqs: anvil-component-nightly-rustfmt-validate-prereqs +anvil-fmt-validate-prereqs: anvil-component-nightly-rustfmt-validate-prereqs anvil-tool-cargo-each-validate-prereqs === justfiles/anvil/checks/license-headers.just === # Copyright (c) Microsoft Corporation. @@ -2613,6 +2613,8 @@ anvil-llvm-cov-validate-prereqs: anvil-component-nightly-llvm-tools-validate-pre # only through *other* members (e.g. concurrent-queue), and run # single-threaded (loom drives its own scheduling). --release per the # loom docs: permutation exploration is wall-clock dominated by the SUT. +# Anvil does not impose a global exploration bound: each model owns its +# topology and must remain tractable under exhaustive exploration. # # Fail-loud guard: a crate that *declares* loom support -- a `loom` # feature or a `cfg(loom)`-gated dependency -- but exposes no @@ -3201,13 +3203,14 @@ anvil-readme-check-validate-prereqs: anvil-tool-cargo-doc2readme-validate-prereq # # Several real-world conditions aren't actually SemVer violations: # - bin-only crates have no API to compare (reported as a missing lib target). +# - `publish = false` crates have no registry consumer contract. # - crates newly added since the PR base have no manifest at their # current workspace-relative path in that baseline. # - crates renamed since the PR base have no matching package in that # baseline ("package `` not found in ..."). # - crates where the branch baseline lacks a lib target the # current source has (bin -> bin+lib transition). -# We pre-filter to library-bearing crates from cargo metadata, skip +# We pre-filter to publishable library-bearing crates from cargo metadata, skip # packages whose current manifest path is absent from the baseline, # then run cargo-semver-checks per-package and tolerate the remaining # "no-comparable-baseline" failure modes. @@ -3227,8 +3230,8 @@ anvil-readme-check-validate-prereqs: anvil-tool-cargo-doc2readme-validate-prereq # # TODO(anvil-runner): even after a `cargo ox-run` helper absorbs the # skip/splat preamble, this recipe stays multi-step: cargo-metadata -# filter to library crates (cargo-semver-checks --workspace fails on -# bin-only workspaces), intersect the affected set ($include) with that +# filter to publishable library crates (cargo-semver-checks --workspace +# fails on bin-only workspaces), intersect the affected set ($include) with that # set, verify the PR branch baseline is present, skip newly-added # manifest paths, then invoke per-crate against that baseline with # special handling for renamed crates ("package `` not found in @@ -3246,14 +3249,14 @@ anvil-semver-check: anvil-semver-check-validate-prereqs anvil-impact exit 0 } $pkg = @(if ($include) { -split $include } else { '--workspace' }) - # Build the candidate package list. Keep each package's metadata so + # Build the publishable candidate package list. Keep each package's metadata so # its manifest path can also be checked in the baseline. Always # iterate per-package over library crates only -- cargo-semver-checks # --workspace would fail on workspaces that contain bin-only crates # (reported as a missing lib target), and we want the same tolerance for # new / renamed / bin->lib-transition crates regardless of whether # we got here via impact-scoping (cloud workflows) or full-workspace - # fallback (local). + # fallback (local). Crates with `publish = false` have no registry consumer contract. $libPkgs = @{} $metadataJson = & cargo metadata --no-deps --format-version 1 if ($LASTEXITCODE -ne 0) { @@ -3267,7 +3270,8 @@ anvil-semver-check: anvil-semver-check-validate-prereqs anvil-impact exit 1 } foreach ($p in $meta.packages) { - if ($p.targets | Where-Object { $_.kind -contains 'lib' }) { + if (($p.targets | Where-Object { $_.kind -contains 'lib' }) -and + -not ($p.publish -is [array] -and $p.publish.Count -eq 0)) { $libPkgs[$p.name] = $p } } @@ -3286,7 +3290,7 @@ anvil-semver-check: anvil-semver-check-validate-prereqs anvil-impact } } if ($packages.Count -eq 0) { - Write-Host 'anvil-semver-check: no affected library crates; skipping' + Write-Host 'anvil-semver-check: no affected publishable library crates; skipping' Remove-Item -LiteralPath $commentFile -ErrorAction SilentlyContinue exit 0 } @@ -5086,10 +5090,11 @@ anvil-scheduled-test-validate-prereqs: \ # Each check belongs to one of four buckets, which determines how it scopes # its work to the packages impacted by a change: # -# - "modified": only run when at least one package's source files changed -# in the diff. The check's underlying tool is workspace-wide or -# directory-scoped (cargo fmt --all, cargo heather), so -# it doesn't take --package; it short-circuits on the "--skip" sentinel. +# - "modified": only run when at least one package's source files +# changed in the diff. The tier is only a skip gate: once admitted, +# the check runs its complete selected scope rather than splicing +# package arguments. That scope may be workspace members (cargo each) +# or the whole workspace (cargo heather, cargo sort). # # - "affected": run on the affected set (modified ∪ reverse-deps within the # workspace). The tool takes --package; the recipe splices the resolved @@ -5131,8 +5136,8 @@ anvil-scheduled-test-validate-prereqs: \ # # This keeps the per-recipe boilerplate to a single capture, a one-line skip # guard, and the cargo invocation. Modified-tier recipes never splice -# `$include` into cargo (their tools are workspace-wide); they only check the -# skip sentinel. +# `$include` into their command; they only check the skip sentinel and then run +# the check's complete selected scope. # # Every recipe whose body uses multi-line conditionals or array-splat # splicing is annotated with [script("pwsh", "-NoProfile")]. pwsh is preinstalled on @@ -6698,6 +6703,14 @@ anvil-tool-cargo-doc2readme-install installer="install": (_install-tool "cargo-d [group("anvil-setup")] anvil-tool-cargo-doc2readme-validate-prereqs: (_check-tool "cargo-doc2readme" cargo_doc2readme_version) +# Install the pinned `cargo-each` tool. +[group("anvil-setup")] +anvil-tool-cargo-each-install installer="install": (_install-tool "cargo-each" cargo_each_version installer) + +# Validate that the pinned `cargo-each` tool is available. +[group("anvil-setup")] +anvil-tool-cargo-each-validate-prereqs: (_check-tool "cargo-each" cargo_each_version) + # Install the pinned `cargo-ensure-no-cyclic-deps` tool. [group("anvil-setup")] anvil-tool-cargo-ensure-no-cyclic-deps-install installer="install": (_install-tool "cargo-ensure-no-cyclic-deps" cargo_ensure_no_cyclic_deps_version installer) @@ -6868,6 +6881,7 @@ cargo_coverage_gate_version := "0.2.0" cargo_delta_version := "0.3.1" cargo_deny_version := "0.20.2" cargo_doc2readme_version := "0.7.3" +cargo_each_version := "0.1.0" cargo_ensure_no_cyclic_deps_version := "0.2.0" cargo_ensure_no_default_features_version := "1.1.0" cargo_hack_version := "0.6.45" diff --git a/crates/cargo-anvil/tests/snapshots/snapshots__github_backend.snap b/crates/cargo-anvil/tests/snapshots/snapshots__github_backend.snap index dd5965ea..50856f4c 100644 --- a/crates/cargo-anvil/tests/snapshots/snapshots__github_backend.snap +++ b/crates/cargo-anvil/tests/snapshots/snapshots__github_backend.snap @@ -1855,16 +1855,12 @@ anvil-cargo-hack-validate-prereqs: anvil-tool-cargo-hack-validate-prereqs # Modified tier. # -# `--check-format` makes the formatting diff fail the check, not just the -# sort order. cargo-sort 2.1.2+ downgraded formatting-only diffs to -# warnings by default (PR #129); without this flag cargo-sort 2.1.2+ -# would pass on formatting drift. The flag preserves the intended -# check behavior across the supported range. +# cargo-sort 2.1.2 and newer report formatting-only differences as warnings +# unless `--check-format` is set. Keep it enabled so dependency ordering and +# Cargo manifest style are both enforced. # # `--grouped` keeps the blank lines between groups of key/value pairs -# (e.g. blank-line-separated dependency blocks) intact, matching -# oxidizer-github's convention; without it cargo-sort would flag those -# blank lines as a formatting diff and fail the check. +# (e.g. blank-line-separated dependency blocks) intact. # Check that Cargo.toml dependency tables are sorted. [script("pwsh", "-NoProfile")] @@ -2211,6 +2207,10 @@ anvil-external-types-validate-prereqs: anvil-toolchain-nightly-external-types-va # format_code_in_doc_comments). Floating nightly would mean # format-drift breaking cloud workflows on rustup updates — the same trap we # explicitly avoid for udeps/miri/careful/external-types. +# +# Iterate workspace members rather than every local path dependency that +# `cargo fmt --all` discovers. `--keep-going` reports formatting failures from +# every member while retaining a bounded rustfmt command line per invocation. # Check Rust source formatting. [script("pwsh", "-NoProfile")] @@ -2218,7 +2218,7 @@ anvil-fmt: anvil-fmt-validate-prereqs anvil-impact $ErrorActionPreference = 'Stop' $include = (& "{{ just_executable() }}" _anvil-impact-include modified) if ($include -eq '--skip') { Write-Host 'anvil-fmt: no modified packages; skipping'; exit 0 } - cargo '+{{ rust_nightly }}' fmt --all --check + cargo each --workspace --keep-going '--' cargo '+{{ rust_nightly }}' fmt --manifest-path '{manifest}' --check if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } # Per-check setup + validate-prereqs @@ -2238,11 +2238,11 @@ anvil-fmt: anvil-fmt-validate-prereqs anvil-impact # Install prerequisites for the `anvil-fmt` recipe. [group("anvil-setup")] -anvil-fmt-setup installer="install": anvil-component-nightly-rustfmt-install +anvil-fmt-setup installer="install": anvil-component-nightly-rustfmt-install (anvil-tool-cargo-each-install installer) # Validate prerequisites for the `anvil-fmt` recipe. [group("anvil-setup")] -anvil-fmt-validate-prereqs: anvil-component-nightly-rustfmt-validate-prereqs +anvil-fmt-validate-prereqs: anvil-component-nightly-rustfmt-validate-prereqs anvil-tool-cargo-each-validate-prereqs === justfiles/anvil/checks/license-headers.just === # Copyright (c) Microsoft Corporation. @@ -2492,6 +2492,8 @@ anvil-llvm-cov-validate-prereqs: anvil-component-nightly-llvm-tools-validate-pre # only through *other* members (e.g. concurrent-queue), and run # single-threaded (loom drives its own scheduling). --release per the # loom docs: permutation exploration is wall-clock dominated by the SUT. +# Anvil does not impose a global exploration bound: each model owns its +# topology and must remain tractable under exhaustive exploration. # # Fail-loud guard: a crate that *declares* loom support -- a `loom` # feature or a `cfg(loom)`-gated dependency -- but exposes no @@ -3080,13 +3082,14 @@ anvil-readme-check-validate-prereqs: anvil-tool-cargo-doc2readme-validate-prereq # # Several real-world conditions aren't actually SemVer violations: # - bin-only crates have no API to compare (reported as a missing lib target). +# - `publish = false` crates have no registry consumer contract. # - crates newly added since the PR base have no manifest at their # current workspace-relative path in that baseline. # - crates renamed since the PR base have no matching package in that # baseline ("package `` not found in ..."). # - crates where the branch baseline lacks a lib target the # current source has (bin -> bin+lib transition). -# We pre-filter to library-bearing crates from cargo metadata, skip +# We pre-filter to publishable library-bearing crates from cargo metadata, skip # packages whose current manifest path is absent from the baseline, # then run cargo-semver-checks per-package and tolerate the remaining # "no-comparable-baseline" failure modes. @@ -3106,8 +3109,8 @@ anvil-readme-check-validate-prereqs: anvil-tool-cargo-doc2readme-validate-prereq # # TODO(anvil-runner): even after a `cargo ox-run` helper absorbs the # skip/splat preamble, this recipe stays multi-step: cargo-metadata -# filter to library crates (cargo-semver-checks --workspace fails on -# bin-only workspaces), intersect the affected set ($include) with that +# filter to publishable library crates (cargo-semver-checks --workspace +# fails on bin-only workspaces), intersect the affected set ($include) with that # set, verify the PR branch baseline is present, skip newly-added # manifest paths, then invoke per-crate against that baseline with # special handling for renamed crates ("package `` not found in @@ -3125,14 +3128,14 @@ anvil-semver-check: anvil-semver-check-validate-prereqs anvil-impact exit 0 } $pkg = @(if ($include) { -split $include } else { '--workspace' }) - # Build the candidate package list. Keep each package's metadata so + # Build the publishable candidate package list. Keep each package's metadata so # its manifest path can also be checked in the baseline. Always # iterate per-package over library crates only -- cargo-semver-checks # --workspace would fail on workspaces that contain bin-only crates # (reported as a missing lib target), and we want the same tolerance for # new / renamed / bin->lib-transition crates regardless of whether # we got here via impact-scoping (cloud workflows) or full-workspace - # fallback (local). + # fallback (local). Crates with `publish = false` have no registry consumer contract. $libPkgs = @{} $metadataJson = & cargo metadata --no-deps --format-version 1 if ($LASTEXITCODE -ne 0) { @@ -3146,7 +3149,8 @@ anvil-semver-check: anvil-semver-check-validate-prereqs anvil-impact exit 1 } foreach ($p in $meta.packages) { - if ($p.targets | Where-Object { $_.kind -contains 'lib' }) { + if (($p.targets | Where-Object { $_.kind -contains 'lib' }) -and + -not ($p.publish -is [array] -and $p.publish.Count -eq 0)) { $libPkgs[$p.name] = $p } } @@ -3165,7 +3169,7 @@ anvil-semver-check: anvil-semver-check-validate-prereqs anvil-impact } } if ($packages.Count -eq 0) { - Write-Host 'anvil-semver-check: no affected library crates; skipping' + Write-Host 'anvil-semver-check: no affected publishable library crates; skipping' Remove-Item -LiteralPath $commentFile -ErrorAction SilentlyContinue exit 0 } @@ -4965,10 +4969,11 @@ anvil-scheduled-test-validate-prereqs: \ # Each check belongs to one of four buckets, which determines how it scopes # its work to the packages impacted by a change: # -# - "modified": only run when at least one package's source files changed -# in the diff. The check's underlying tool is workspace-wide or -# directory-scoped (cargo fmt --all, cargo heather), so -# it doesn't take --package; it short-circuits on the "--skip" sentinel. +# - "modified": only run when at least one package's source files +# changed in the diff. The tier is only a skip gate: once admitted, +# the check runs its complete selected scope rather than splicing +# package arguments. That scope may be workspace members (cargo each) +# or the whole workspace (cargo heather, cargo sort). # # - "affected": run on the affected set (modified ∪ reverse-deps within the # workspace). The tool takes --package; the recipe splices the resolved @@ -5010,8 +5015,8 @@ anvil-scheduled-test-validate-prereqs: \ # # This keeps the per-recipe boilerplate to a single capture, a one-line skip # guard, and the cargo invocation. Modified-tier recipes never splice -# `$include` into cargo (their tools are workspace-wide); they only check the -# skip sentinel. +# `$include` into their command; they only check the skip sentinel and then run +# the check's complete selected scope. # # Every recipe whose body uses multi-line conditionals or array-splat # splicing is annotated with [script("pwsh", "-NoProfile")]. pwsh is preinstalled on @@ -6577,6 +6582,14 @@ anvil-tool-cargo-doc2readme-install installer="install": (_install-tool "cargo-d [group("anvil-setup")] anvil-tool-cargo-doc2readme-validate-prereqs: (_check-tool "cargo-doc2readme" cargo_doc2readme_version) +# Install the pinned `cargo-each` tool. +[group("anvil-setup")] +anvil-tool-cargo-each-install installer="install": (_install-tool "cargo-each" cargo_each_version installer) + +# Validate that the pinned `cargo-each` tool is available. +[group("anvil-setup")] +anvil-tool-cargo-each-validate-prereqs: (_check-tool "cargo-each" cargo_each_version) + # Install the pinned `cargo-ensure-no-cyclic-deps` tool. [group("anvil-setup")] anvil-tool-cargo-ensure-no-cyclic-deps-install installer="install": (_install-tool "cargo-ensure-no-cyclic-deps" cargo_ensure_no_cyclic_deps_version installer) @@ -6747,6 +6760,7 @@ cargo_coverage_gate_version := "0.2.0" cargo_delta_version := "0.3.1" cargo_deny_version := "0.20.2" cargo_doc2readme_version := "0.7.3" +cargo_each_version := "0.1.0" cargo_ensure_no_cyclic_deps_version := "0.2.0" cargo_ensure_no_default_features_version := "1.1.0" cargo_hack_version := "0.6.45" diff --git a/crates/cargo-anvil/tests/snapshots/snapshots__local_only.snap b/crates/cargo-anvil/tests/snapshots/snapshots__local_only.snap index 5e7f6d13..865b74d1 100644 --- a/crates/cargo-anvil/tests/snapshots/snapshots__local_only.snap +++ b/crates/cargo-anvil/tests/snapshots/snapshots__local_only.snap @@ -728,16 +728,12 @@ anvil-cargo-hack-validate-prereqs: anvil-tool-cargo-hack-validate-prereqs # Modified tier. # -# `--check-format` makes the formatting diff fail the check, not just the -# sort order. cargo-sort 2.1.2+ downgraded formatting-only diffs to -# warnings by default (PR #129); without this flag cargo-sort 2.1.2+ -# would pass on formatting drift. The flag preserves the intended -# check behavior across the supported range. +# cargo-sort 2.1.2 and newer report formatting-only differences as warnings +# unless `--check-format` is set. Keep it enabled so dependency ordering and +# Cargo manifest style are both enforced. # # `--grouped` keeps the blank lines between groups of key/value pairs -# (e.g. blank-line-separated dependency blocks) intact, matching -# oxidizer-github's convention; without it cargo-sort would flag those -# blank lines as a formatting diff and fail the check. +# (e.g. blank-line-separated dependency blocks) intact. # Check that Cargo.toml dependency tables are sorted. [script("pwsh", "-NoProfile")] @@ -1084,6 +1080,10 @@ anvil-external-types-validate-prereqs: anvil-toolchain-nightly-external-types-va # format_code_in_doc_comments). Floating nightly would mean # format-drift breaking cloud workflows on rustup updates — the same trap we # explicitly avoid for udeps/miri/careful/external-types. +# +# Iterate workspace members rather than every local path dependency that +# `cargo fmt --all` discovers. `--keep-going` reports formatting failures from +# every member while retaining a bounded rustfmt command line per invocation. # Check Rust source formatting. [script("pwsh", "-NoProfile")] @@ -1091,7 +1091,7 @@ anvil-fmt: anvil-fmt-validate-prereqs anvil-impact $ErrorActionPreference = 'Stop' $include = (& "{{ just_executable() }}" _anvil-impact-include modified) if ($include -eq '--skip') { Write-Host 'anvil-fmt: no modified packages; skipping'; exit 0 } - cargo '+{{ rust_nightly }}' fmt --all --check + cargo each --workspace --keep-going '--' cargo '+{{ rust_nightly }}' fmt --manifest-path '{manifest}' --check if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } # Per-check setup + validate-prereqs @@ -1111,11 +1111,11 @@ anvil-fmt: anvil-fmt-validate-prereqs anvil-impact # Install prerequisites for the `anvil-fmt` recipe. [group("anvil-setup")] -anvil-fmt-setup installer="install": anvil-component-nightly-rustfmt-install +anvil-fmt-setup installer="install": anvil-component-nightly-rustfmt-install (anvil-tool-cargo-each-install installer) # Validate prerequisites for the `anvil-fmt` recipe. [group("anvil-setup")] -anvil-fmt-validate-prereqs: anvil-component-nightly-rustfmt-validate-prereqs +anvil-fmt-validate-prereqs: anvil-component-nightly-rustfmt-validate-prereqs anvil-tool-cargo-each-validate-prereqs === justfiles/anvil/checks/license-headers.just === # Copyright (c) Microsoft Corporation. @@ -1365,6 +1365,8 @@ anvil-llvm-cov-validate-prereqs: anvil-component-nightly-llvm-tools-validate-pre # only through *other* members (e.g. concurrent-queue), and run # single-threaded (loom drives its own scheduling). --release per the # loom docs: permutation exploration is wall-clock dominated by the SUT. +# Anvil does not impose a global exploration bound: each model owns its +# topology and must remain tractable under exhaustive exploration. # # Fail-loud guard: a crate that *declares* loom support -- a `loom` # feature or a `cfg(loom)`-gated dependency -- but exposes no @@ -1953,13 +1955,14 @@ anvil-readme-check-validate-prereqs: anvil-tool-cargo-doc2readme-validate-prereq # # Several real-world conditions aren't actually SemVer violations: # - bin-only crates have no API to compare (reported as a missing lib target). +# - `publish = false` crates have no registry consumer contract. # - crates newly added since the PR base have no manifest at their # current workspace-relative path in that baseline. # - crates renamed since the PR base have no matching package in that # baseline ("package `` not found in ..."). # - crates where the branch baseline lacks a lib target the # current source has (bin -> bin+lib transition). -# We pre-filter to library-bearing crates from cargo metadata, skip +# We pre-filter to publishable library-bearing crates from cargo metadata, skip # packages whose current manifest path is absent from the baseline, # then run cargo-semver-checks per-package and tolerate the remaining # "no-comparable-baseline" failure modes. @@ -1979,8 +1982,8 @@ anvil-readme-check-validate-prereqs: anvil-tool-cargo-doc2readme-validate-prereq # # TODO(anvil-runner): even after a `cargo ox-run` helper absorbs the # skip/splat preamble, this recipe stays multi-step: cargo-metadata -# filter to library crates (cargo-semver-checks --workspace fails on -# bin-only workspaces), intersect the affected set ($include) with that +# filter to publishable library crates (cargo-semver-checks --workspace +# fails on bin-only workspaces), intersect the affected set ($include) with that # set, verify the PR branch baseline is present, skip newly-added # manifest paths, then invoke per-crate against that baseline with # special handling for renamed crates ("package `` not found in @@ -1998,14 +2001,14 @@ anvil-semver-check: anvil-semver-check-validate-prereqs anvil-impact exit 0 } $pkg = @(if ($include) { -split $include } else { '--workspace' }) - # Build the candidate package list. Keep each package's metadata so + # Build the publishable candidate package list. Keep each package's metadata so # its manifest path can also be checked in the baseline. Always # iterate per-package over library crates only -- cargo-semver-checks # --workspace would fail on workspaces that contain bin-only crates # (reported as a missing lib target), and we want the same tolerance for # new / renamed / bin->lib-transition crates regardless of whether # we got here via impact-scoping (cloud workflows) or full-workspace - # fallback (local). + # fallback (local). Crates with `publish = false` have no registry consumer contract. $libPkgs = @{} $metadataJson = & cargo metadata --no-deps --format-version 1 if ($LASTEXITCODE -ne 0) { @@ -2019,7 +2022,8 @@ anvil-semver-check: anvil-semver-check-validate-prereqs anvil-impact exit 1 } foreach ($p in $meta.packages) { - if ($p.targets | Where-Object { $_.kind -contains 'lib' }) { + if (($p.targets | Where-Object { $_.kind -contains 'lib' }) -and + -not ($p.publish -is [array] -and $p.publish.Count -eq 0)) { $libPkgs[$p.name] = $p } } @@ -2038,7 +2042,7 @@ anvil-semver-check: anvil-semver-check-validate-prereqs anvil-impact } } if ($packages.Count -eq 0) { - Write-Host 'anvil-semver-check: no affected library crates; skipping' + Write-Host 'anvil-semver-check: no affected publishable library crates; skipping' Remove-Item -LiteralPath $commentFile -ErrorAction SilentlyContinue exit 0 } @@ -3838,10 +3842,11 @@ anvil-scheduled-test-validate-prereqs: \ # Each check belongs to one of four buckets, which determines how it scopes # its work to the packages impacted by a change: # -# - "modified": only run when at least one package's source files changed -# in the diff. The check's underlying tool is workspace-wide or -# directory-scoped (cargo fmt --all, cargo heather), so -# it doesn't take --package; it short-circuits on the "--skip" sentinel. +# - "modified": only run when at least one package's source files +# changed in the diff. The tier is only a skip gate: once admitted, +# the check runs its complete selected scope rather than splicing +# package arguments. That scope may be workspace members (cargo each) +# or the whole workspace (cargo heather, cargo sort). # # - "affected": run on the affected set (modified ∪ reverse-deps within the # workspace). The tool takes --package; the recipe splices the resolved @@ -3883,8 +3888,8 @@ anvil-scheduled-test-validate-prereqs: \ # # This keeps the per-recipe boilerplate to a single capture, a one-line skip # guard, and the cargo invocation. Modified-tier recipes never splice -# `$include` into cargo (their tools are workspace-wide); they only check the -# skip sentinel. +# `$include` into their command; they only check the skip sentinel and then run +# the check's complete selected scope. # # Every recipe whose body uses multi-line conditionals or array-splat # splicing is annotated with [script("pwsh", "-NoProfile")]. pwsh is preinstalled on @@ -5450,6 +5455,14 @@ anvil-tool-cargo-doc2readme-install installer="install": (_install-tool "cargo-d [group("anvil-setup")] anvil-tool-cargo-doc2readme-validate-prereqs: (_check-tool "cargo-doc2readme" cargo_doc2readme_version) +# Install the pinned `cargo-each` tool. +[group("anvil-setup")] +anvil-tool-cargo-each-install installer="install": (_install-tool "cargo-each" cargo_each_version installer) + +# Validate that the pinned `cargo-each` tool is available. +[group("anvil-setup")] +anvil-tool-cargo-each-validate-prereqs: (_check-tool "cargo-each" cargo_each_version) + # Install the pinned `cargo-ensure-no-cyclic-deps` tool. [group("anvil-setup")] anvil-tool-cargo-ensure-no-cyclic-deps-install installer="install": (_install-tool "cargo-ensure-no-cyclic-deps" cargo_ensure_no_cyclic_deps_version installer) @@ -5620,6 +5633,7 @@ cargo_coverage_gate_version := "0.2.0" cargo_delta_version := "0.3.1" cargo_deny_version := "0.20.2" cargo_doc2readme_version := "0.7.3" +cargo_each_version := "0.1.0" cargo_ensure_no_cyclic_deps_version := "0.2.0" cargo_ensure_no_default_features_version := "1.1.0" cargo_hack_version := "0.6.45" diff --git a/crates/cargo-gamma-lib/src/exec/verdict/hubs.rs b/crates/cargo-gamma-lib/src/exec/verdict/hubs.rs index 894a6aa1..ba28d269 100644 --- a/crates/cargo-gamma-lib/src/exec/verdict/hubs.rs +++ b/crates/cargo-gamma-lib/src/exec/verdict/hubs.rs @@ -218,18 +218,12 @@ mod loom_models { use core::time::Duration; use loom::sync::Arc; + use loom::sync::atomic::AtomicUsize; use super::{Pulse, Readers}; - /// Two simultaneous binaries, each with stdout and stderr readers. - /// - /// This is the smallest production topology that exercises both contention between jobs and - /// contention between the two streams of one job. The atomic operations are identical for - /// additional jobs, so these four streams expose every distinct participant role while keeping - /// the state space tractable in CI. - const CONCURRENT_JOBS: usize = 2; - const STREAMS_PER_JOB: usize = 2; - const CONCURRENT_READER_STREAMS: usize = CONCURRENT_JOBS * STREAMS_PER_JOB; + /// The smallest topology that exercises a race between identical reader operations. + const CONCURRENT_READERS: usize = 2; /// A wakeup is never lost, whatever order the waiter and the notifier run in. /// @@ -269,33 +263,27 @@ mod loom_models { }); } - /// The live count returns to exactly zero after all four production reader roles finish. + /// The live count returns to exactly zero after concurrent readers finish. /// - /// The coordinator first establishes the four-reader high-water mark, then the two jobs finish - /// their two readers concurrently. Modeling each job as one thread preserves cross-job - /// contention and all four decrements without exploring equivalent permutations among four - /// symmetric finisher threads. The contended `live` observation also models diagnostics racing - /// abandoned readers as they finally close. + /// Two participants are the smallest topology that exercises the decrement race. Additional + /// reader streams perform the same atomic operation and only multiply equivalent permutations. + /// The contended `live` observation also models diagnostics racing abandoned readers as they + /// finally close. Every increment must still have exactly one decrement. pub(super) fn a_readers_gauge_returns_to_exactly_zero_under_any_interleaving() { loom::model(|| { - let readers = Arc::new(Readers::new()); - let mut finishers = Vec::with_capacity(CONCURRENT_JOBS); + let readers = Arc::new(Readers { + live: AtomicUsize::new(CONCURRENT_READERS), + peak: AtomicUsize::new(CONCURRENT_READERS), + }); + let mut finishers = Vec::with_capacity(CONCURRENT_READERS); - for _stream in 0..CONCURRENT_READER_STREAMS { - readers.started(); - } - - for _job in 0..CONCURRENT_JOBS { + for _reader in 0..CONCURRENT_READERS { let readers = Arc::clone(&readers); - finishers.push(loom::thread::spawn(move || { - for _stream in 0..STREAMS_PER_JOB { - readers.finished(); - } - })); + finishers.push(loom::thread::spawn(move || readers.finished())); } assert!( - readers.live() <= CONCURRENT_READER_STREAMS, + readers.live() <= CONCURRENT_READERS, "a contended live read exceeded the number of started readers" ); @@ -303,41 +291,33 @@ mod loom_models { finisher.join().unwrap(); } - assert_eq!( - readers.peak(), - CONCURRENT_READER_STREAMS, - "counting a started reader failed to raise the peak" - ); assert_eq!(readers.live(), 0, "a decrement was lost or double-counted"); }); } - /// The peak never understates four readers started concurrently by two jobs. + /// The peak never understates readers started concurrently. /// - /// Two worker-side participants each start the stdout and stderr readers of one simultaneous - /// job. None finishes, so all four streams are genuinely live. The observations before the joins - /// race the `fetch_add` and `fetch_max` operations, exercising the production loads under - /// contention; the observations after the joins must see the exact final count and peak. - pub(super) fn a_readers_peak_never_understates_four_concurrent_starts() { + /// Two participants are the smallest topology that exercises the increment and peak-update + /// races. Additional readers perform the same atomic operations and only multiply equivalent + /// permutations. Neither participant finishes, so both are genuinely live. The observations + /// before the joins race the `fetch_add` and `fetch_max` operations, exercising the production + /// loads under contention; the observations after the joins must see the exact count and peak. + pub(super) fn a_readers_peak_never_understates_concurrent_starts() { loom::model(|| { let readers = Arc::new(Readers::new()); - let mut workers = Vec::with_capacity(CONCURRENT_JOBS); + let mut workers = Vec::with_capacity(CONCURRENT_READERS); - for _job in 0..CONCURRENT_JOBS { + for _reader in 0..CONCURRENT_READERS { let readers = Arc::clone(&readers); - workers.push(loom::thread::spawn(move || { - for _stream in 0..STREAMS_PER_JOB { - readers.started(); - } - })); + workers.push(loom::thread::spawn(move || readers.started())); } assert!( - readers.live() <= CONCURRENT_READER_STREAMS, + readers.live() <= CONCURRENT_READERS, "a contended live read exceeded the number of starters" ); assert!( - readers.peak() <= CONCURRENT_READER_STREAMS, + readers.peak() <= CONCURRENT_READERS, "a contended peak read exceeded the number of starters" ); @@ -345,8 +325,8 @@ mod loom_models { worker.join().unwrap(); } - assert_eq!(readers.live(), CONCURRENT_READER_STREAMS, "a concurrent increment was lost"); - assert_eq!(readers.peak(), CONCURRENT_READER_STREAMS, "the peak understated four live readers"); + assert_eq!(readers.live(), CONCURRENT_READERS, "a concurrent increment was lost"); + assert_eq!(readers.peak(), CONCURRENT_READERS, "the peak understated concurrent readers"); }); } } @@ -355,5 +335,5 @@ mod loom_models { pub(crate) fn run_loom_models() { loom_models::a_pulse_wakeup_is_never_lost_under_any_interleaving(); loom_models::a_readers_gauge_returns_to_exactly_zero_under_any_interleaving(); - loom_models::a_readers_peak_never_understates_four_concurrent_starts(); + loom_models::a_readers_peak_never_understates_concurrent_starts(); } diff --git a/justfiles/anvil/checks/cargo-sort.just b/justfiles/anvil/checks/cargo-sort.just index a0af1ccb..773d7ed9 100644 --- a/justfiles/anvil/checks/cargo-sort.just +++ b/justfiles/anvil/checks/cargo-sort.just @@ -8,16 +8,12 @@ # Modified tier. # -# `--check-format` makes the formatting diff fail the check, not just the -# sort order. cargo-sort 2.1.2+ downgraded formatting-only diffs to -# warnings by default (PR #129); without this flag cargo-sort 2.1.2+ -# would pass on formatting drift. The flag preserves the intended -# check behavior across the supported range. +# cargo-sort 2.1.2 and newer report formatting-only differences as warnings +# unless `--check-format` is set. Keep it enabled so dependency ordering and +# Cargo manifest style are both enforced. # # `--grouped` keeps the blank lines between groups of key/value pairs -# (e.g. blank-line-separated dependency blocks) intact, matching -# oxidizer-github's convention; without it cargo-sort would flag those -# blank lines as a formatting diff and fail the check. +# (e.g. blank-line-separated dependency blocks) intact. # Check that Cargo.toml dependency tables are sorted. [script("pwsh", "-NoProfile")] diff --git a/justfiles/anvil/checks/fmt.just b/justfiles/anvil/checks/fmt.just index 14741516..9d855e7d 100644 --- a/justfiles/anvil/checks/fmt.just +++ b/justfiles/anvil/checks/fmt.just @@ -12,6 +12,10 @@ # format_code_in_doc_comments). Floating nightly would mean # format-drift breaking cloud workflows on rustup updates — the same trap we # explicitly avoid for udeps/miri/careful/external-types. +# +# Iterate workspace members rather than every local path dependency that +# `cargo fmt --all` discovers. `--keep-going` reports formatting failures from +# every member while retaining a bounded rustfmt command line per invocation. # Check Rust source formatting. [script("pwsh", "-NoProfile")] @@ -19,7 +23,7 @@ anvil-fmt: anvil-fmt-validate-prereqs anvil-impact $ErrorActionPreference = 'Stop' $include = (& "{{ just_executable() }}" _anvil-impact-include modified) if ($include -eq '--skip') { Write-Host 'anvil-fmt: no modified packages; skipping'; exit 0 } - cargo '+{{ rust_nightly }}' fmt --all --check + cargo each --workspace --keep-going '--' cargo '+{{ rust_nightly }}' fmt --manifest-path '{manifest}' --check if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } # Per-check setup + validate-prereqs @@ -39,8 +43,8 @@ anvil-fmt: anvil-fmt-validate-prereqs anvil-impact # Install prerequisites for the `anvil-fmt` recipe. [group("anvil-setup")] -anvil-fmt-setup installer="install": anvil-component-nightly-rustfmt-install +anvil-fmt-setup installer="install": anvil-component-nightly-rustfmt-install (anvil-tool-cargo-each-install installer) # Validate prerequisites for the `anvil-fmt` recipe. [group("anvil-setup")] -anvil-fmt-validate-prereqs: anvil-component-nightly-rustfmt-validate-prereqs +anvil-fmt-validate-prereqs: anvil-component-nightly-rustfmt-validate-prereqs anvil-tool-cargo-each-validate-prereqs diff --git a/justfiles/anvil/checks/loom.just b/justfiles/anvil/checks/loom.just index 0f88aba6..3db72eac 100644 --- a/justfiles/anvil/checks/loom.just +++ b/justfiles/anvil/checks/loom.just @@ -33,6 +33,8 @@ # only through *other* members (e.g. concurrent-queue), and run # single-threaded (loom drives its own scheduling). --release per the # loom docs: permutation exploration is wall-clock dominated by the SUT. +# Anvil does not impose a global exploration bound: each model owns its +# topology and must remain tractable under exhaustive exploration. # # Fail-loud guard: a crate that *declares* loom support -- a `loom` # feature or a `cfg(loom)`-gated dependency -- but exposes no diff --git a/justfiles/anvil/checks/semver-check.just b/justfiles/anvil/checks/semver-check.just index df65eaf6..e63a67a4 100644 --- a/justfiles/anvil/checks/semver-check.just +++ b/justfiles/anvil/checks/semver-check.just @@ -11,13 +11,14 @@ # # Several real-world conditions aren't actually SemVer violations: # - bin-only crates have no API to compare (reported as a missing lib target). +# - `publish = false` crates have no registry consumer contract. # - crates newly added since the PR base have no manifest at their # current workspace-relative path in that baseline. # - crates renamed since the PR base have no matching package in that # baseline ("package `` not found in ..."). # - crates where the branch baseline lacks a lib target the # current source has (bin -> bin+lib transition). -# We pre-filter to library-bearing crates from cargo metadata, skip +# We pre-filter to publishable library-bearing crates from cargo metadata, skip # packages whose current manifest path is absent from the baseline, # then run cargo-semver-checks per-package and tolerate the remaining # "no-comparable-baseline" failure modes. @@ -37,8 +38,8 @@ # # TODO(anvil-runner): even after a `cargo ox-run` helper absorbs the # skip/splat preamble, this recipe stays multi-step: cargo-metadata -# filter to library crates (cargo-semver-checks --workspace fails on -# bin-only workspaces), intersect the affected set ($include) with that +# filter to publishable library crates (cargo-semver-checks --workspace +# fails on bin-only workspaces), intersect the affected set ($include) with that # set, verify the PR branch baseline is present, skip newly-added # manifest paths, then invoke per-crate against that baseline with # special handling for renamed crates ("package `` not found in @@ -56,14 +57,14 @@ anvil-semver-check: anvil-semver-check-validate-prereqs anvil-impact exit 0 } $pkg = @(if ($include) { -split $include } else { '--workspace' }) - # Build the candidate package list. Keep each package's metadata so + # Build the publishable candidate package list. Keep each package's metadata so # its manifest path can also be checked in the baseline. Always # iterate per-package over library crates only -- cargo-semver-checks # --workspace would fail on workspaces that contain bin-only crates # (reported as a missing lib target), and we want the same tolerance for # new / renamed / bin->lib-transition crates regardless of whether # we got here via impact-scoping (cloud workflows) or full-workspace - # fallback (local). + # fallback (local). Crates with `publish = false` have no registry consumer contract. $libPkgs = @{} $metadataJson = & cargo metadata --no-deps --format-version 1 if ($LASTEXITCODE -ne 0) { @@ -77,7 +78,8 @@ anvil-semver-check: anvil-semver-check-validate-prereqs anvil-impact exit 1 } foreach ($p in $meta.packages) { - if ($p.targets | Where-Object { $_.kind -contains 'lib' }) { + if (($p.targets | Where-Object { $_.kind -contains 'lib' }) -and + -not ($p.publish -is [array] -and $p.publish.Count -eq 0)) { $libPkgs[$p.name] = $p } } @@ -96,7 +98,7 @@ anvil-semver-check: anvil-semver-check-validate-prereqs anvil-impact } } if ($packages.Count -eq 0) { - Write-Host 'anvil-semver-check: no affected library crates; skipping' + Write-Host 'anvil-semver-check: no affected publishable library crates; skipping' Remove-Item -LiteralPath $commentFile -ErrorAction SilentlyContinue exit 0 } diff --git a/justfiles/anvil/helpers.just b/justfiles/anvil/helpers.just index 5be4b411..cda16c0d 100644 --- a/justfiles/anvil/helpers.just +++ b/justfiles/anvil/helpers.just @@ -10,10 +10,11 @@ # Each check belongs to one of four buckets, which determines how it scopes # its work to the packages impacted by a change: # -# - "modified": only run when at least one package's source files changed -# in the diff. The check's underlying tool is workspace-wide or -# directory-scoped (cargo fmt --all, cargo heather), so -# it doesn't take --package; it short-circuits on the "--skip" sentinel. +# - "modified": only run when at least one package's source files +# changed in the diff. The tier is only a skip gate: once admitted, +# the check runs its complete selected scope rather than splicing +# package arguments. That scope may be workspace members (cargo each) +# or the whole workspace (cargo heather, cargo sort). # # - "affected": run on the affected set (modified ∪ reverse-deps within the # workspace). The tool takes --package; the recipe splices the resolved @@ -55,8 +56,8 @@ # # This keeps the per-recipe boilerplate to a single capture, a one-line skip # guard, and the cargo invocation. Modified-tier recipes never splice -# `$include` into cargo (their tools are workspace-wide); they only check the -# skip sentinel. +# `$include` into their command; they only check the skip sentinel and then run +# the check's complete selected scope. # # Every recipe whose body uses multi-line conditionals or array-splat # splicing is annotated with [script("pwsh", "-NoProfile")]. pwsh is preinstalled on diff --git a/justfiles/anvil/tools.just b/justfiles/anvil/tools.just index cb6aab14..d7062aa7 100644 --- a/justfiles/anvil/tools.just +++ b/justfiles/anvil/tools.just @@ -631,6 +631,14 @@ anvil-tool-cargo-doc2readme-install installer="install": (_install-tool "cargo-d [group("anvil-setup")] anvil-tool-cargo-doc2readme-validate-prereqs: (_check-tool "cargo-doc2readme" cargo_doc2readme_version) +# Install the pinned `cargo-each` tool. +[group("anvil-setup")] +anvil-tool-cargo-each-install installer="install": (_install-tool "cargo-each" cargo_each_version installer) + +# Validate that the pinned `cargo-each` tool is available. +[group("anvil-setup")] +anvil-tool-cargo-each-validate-prereqs: (_check-tool "cargo-each" cargo_each_version) + # Install the pinned `cargo-ensure-no-cyclic-deps` tool. [group("anvil-setup")] anvil-tool-cargo-ensure-no-cyclic-deps-install installer="install": (_install-tool "cargo-ensure-no-cyclic-deps" cargo_ensure_no_cyclic_deps_version installer) diff --git a/justfiles/anvil/versions.just b/justfiles/anvil/versions.just index 0a4aeaa1..6dbdfa72 100644 --- a/justfiles/anvil/versions.just +++ b/justfiles/anvil/versions.just @@ -58,6 +58,7 @@ cargo_coverage_gate_version := "0.2.0" cargo_delta_version := "0.3.1" cargo_deny_version := "0.20.2" cargo_doc2readme_version := "0.7.3" +cargo_each_version := "0.1.0" cargo_ensure_no_cyclic_deps_version := "0.2.0" cargo_ensure_no_default_features_version := "1.1.0" cargo_hack_version := "0.6.45"