From 0dc5b3a9bc5999778e0a520cb11f0335badc3637 Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 29 Jul 2026 00:34:02 +0200 Subject: [PATCH 01/21] Adopt Polonius on pinned nightly toolchain Pin `nightly-2026-06-25` in `rust-toolchain.toml` and enable the Polonius alpha borrow-checking analysis (`-Zpolonius=next`) for every build path: - `.cargo/config.toml` applies the flag to plain Cargo invocations, rust-analyzer, and `cargo kani`. - Makefile recipes that set `RUSTFLAGS` (which overrides the config table) re-state the flag via the new `POLONIUS_FLAGS` variable. - CI collapses the stable/MSRV matrices in `ci.yml` and `netsukefile-test.yml` to the pinned nightly and aligns `coverage-main.yml`. - `Cargo.toml` drops `rust-version = "1.89.0"`, which cannot express a nightly requirement. ADR-006 records the policy decision and its release consequences. The developers' guide, design document, README, documentation index, and a new `CHANGELOG.md` document the toolchain contract. This prepares the borrow-centric API evolution tracked by issue #465. Co-Authored-By: Claude Fable 5 --- .cargo/config.toml | 10 ++ .github/workflows/ci.yml | 36 ++------ .github/workflows/coverage-main.yml | 4 +- .github/workflows/netsukefile-test.yml | 14 ++- CHANGELOG.md | 20 ++++ Cargo.toml | 1 - Makefile | 9 +- README.md | 6 +- ...dr-006-adopt-polonius-nightly-toolchain.md | 91 +++++++++++++++++++ docs/contents.md | 4 + docs/developers-guide.md | 24 ++++- docs/netsuke-design.md | 12 +++ rust-toolchain.toml | 6 +- 13 files changed, 194 insertions(+), 43 deletions(-) create mode 100644 .cargo/config.toml create mode 100644 CHANGELOG.md create mode 100644 docs/adr-006-adopt-polonius-nightly-toolchain.md diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 000000000..fba592bc3 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,10 @@ +# Enable the Polonius alpha borrow-checking analysis for every Cargo +# invocation, including rust-analyzer and `cargo kani`, so editors and +# verification tooling agree with CI about what borrows are legal. +# +# Note: an inherited `RUSTFLAGS` environment variable overrides this table. +# Wrappers that set `RUSTFLAGS` (see the Makefile) must re-state +# `-Zpolonius=next` themselves. See +# docs/adr-006-adopt-polonius-nightly-toolchain.md. +[build] +rustflags = ["-Zpolonius=next"] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 062b17583..513949263 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,25 +7,16 @@ on: jobs: build-test: - runs-on: ${{ matrix.os }} - strategy: - fail-fast: false - matrix: - os: [ubuntu-latest] - rust: [stable, '1.89.0'] - # Nightly is experimental; stable and MSRV are the supported toolchains - include: - - os: ubuntu-latest - rust: nightly - experimental: true - continue-on-error: ${{ matrix.experimental || false }} + runs-on: ubuntu-latest permissions: contents: read env: CARGO_TERM_COLOR: always BUILD_PROFILE: debug - # Override rust-toolchain.toml to actually use the matrix toolchain - RUSTUP_TOOLCHAIN: ${{ matrix.rust }} + # The tree requires -Zpolonius=next (see + # docs/adr-006-adopt-polonius-nightly-toolchain.md), so CI builds with + # the dated nightly pinned in rust-toolchain.toml. + NETSUKE_RUST_TOOLCHAIN: nightly-2026-06-25 WHITAKER_INSTALLER_VERSION: '0.2.6' steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -36,7 +27,7 @@ jobs: - name: Setup Rust uses: leynos/shared-actions/.github/actions/setup-rust@4d696e72fff6db49f34302ccf119ba978f1032c9 with: - toolchain: ${{ matrix.rust }} + toolchain: ${{ env.NETSUKE_RUST_TOOLCHAIN }} components: rustfmt, clippy - name: Show rustc version run: | @@ -48,7 +39,6 @@ jobs: - name: Format run: make check-fmt - name: Cache Whitaker installer - if: matrix.rust == 'stable' uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | @@ -56,7 +46,6 @@ jobs: ~/.cache/cargo-binstall key: whitaker-installer-${{ runner.os }}-${{ runner.arch }}-${{ env.WHITAKER_INSTALLER_VERSION }} - name: Install Whitaker - if: matrix.rust == 'stable' run: | if ! command -v whitaker-installer >/dev/null 2>&1; then if cargo binstall --version >/dev/null 2>&1; then @@ -68,13 +57,7 @@ jobs: fi whitaker-installer - name: Lint - if: matrix.rust == 'stable' run: make lint - # Whitaker lints under its own pinned toolchain, so re-running it per - # matrix leg repeats identical work; non-stable legs run Clippy only. - - name: Lint (Clippy only) - if: matrix.rust != 'stable' - run: make lint-clippy - name: Setup uv uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 with: @@ -86,9 +69,9 @@ jobs: path: | .typos-oxendict-base.json .typos-oxendict-base.toml - key: oxendict-${{ runner.os }}-${{ matrix.rust }}-${{ hashFiles('typos.local.toml') }}-${{ github.run_id }} + key: oxendict-${{ runner.os }}-${{ env.NETSUKE_RUST_TOOLCHAIN }}-${{ hashFiles('typos.local.toml') }}-${{ github.run_id }} restore-keys: | - oxendict-${{ runner.os }}-${{ matrix.rust }}-${{ hashFiles('typos.local.toml') }}- + oxendict-${{ runner.os }}-${{ env.NETSUKE_RUST_TOOLCHAIN }}-${{ hashFiles('typos.local.toml') }}- - name: Spelling run: make spelling - name: Workflow contract tests @@ -96,7 +79,6 @@ jobs: - name: Test run: make test - name: Test and Measure Coverage - if: matrix.rust == 'stable' uses: leynos/shared-actions/.github/actions/generate-coverage@18bed1ca49a6de3d8882bd72635a32ae3f023d57 with: output-path: lcov.info @@ -105,7 +87,7 @@ jobs: # coverage-main.yml (caches saved on main are readable by every PR). with-ratchet: 'true' - name: Check coverage against CodeScene gates - if: matrix.rust == 'stable' && env.CS_ACCESS_TOKEN != '' && github.event_name == 'pull_request' + if: env.CS_ACCESS_TOKEN != '' && github.event_name == 'pull_request' env: CS_ACCESS_TOKEN: ${{ secrets.CS_ACCESS_TOKEN }} uses: leynos/shared-actions/.github/actions/upload-codescene-coverage@18bed1ca49a6de3d8882bd72635a32ae3f023d57 diff --git a/.github/workflows/coverage-main.yml b/.github/workflows/coverage-main.yml index 75907d7a0..0814e696f 100644 --- a/.github/workflows/coverage-main.yml +++ b/.github/workflows/coverage-main.yml @@ -24,7 +24,9 @@ jobs: - name: Setup Rust uses: leynos/shared-actions/.github/actions/setup-rust@4d696e72fff6db49f34302ccf119ba978f1032c9 with: - toolchain: stable + # Match rust-toolchain.toml: the tree needs -Zpolonius=next (see + # docs/adr-006-adopt-polonius-nightly-toolchain.md). + toolchain: nightly-2026-06-25 components: rustfmt, clippy - name: Test and Measure Coverage uses: leynos/shared-actions/.github/actions/generate-coverage@18bed1ca49a6de3d8882bd72635a32ae3f023d57 diff --git a/.github/workflows/netsukefile-test.yml b/.github/workflows/netsukefile-test.yml index b24598eee..4ef358ccf 100644 --- a/.github/workflows/netsukefile-test.yml +++ b/.github/workflows/netsukefile-test.yml @@ -9,21 +9,19 @@ on: jobs: netsukefile: runs-on: ubuntu-22.04 - strategy: - fail-fast: false - matrix: - rust: [stable, '1.89.0'] permissions: contents: read env: - RUSTUP_TOOLCHAIN: ${{ matrix.rust }} + # Match rust-toolchain.toml: the tree needs -Zpolonius=next (see + # docs/adr-006-adopt-polonius-nightly-toolchain.md). + NETSUKE_RUST_TOOLCHAIN: nightly-2026-06-25 steps: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup Rust uses: leynos/shared-actions/.github/actions/setup-rust@4d696e72fff6db49f34302ccf119ba978f1032c9 with: - toolchain: ${{ matrix.rust }} + toolchain: ${{ env.NETSUKE_RUST_TOOLCHAIN }} - name: Show rustc version run: | rustup show @@ -36,9 +34,9 @@ jobs: path: | ~/.cargo/registry ~/.cargo/git - key: ${{ runner.os }}-cargo-${{ matrix.rust }}-${{ hashFiles('**/Cargo.lock') }} + key: ${{ runner.os }}-cargo-${{ env.NETSUKE_RUST_TOOLCHAIN }}-${{ hashFiles('**/Cargo.lock') }} restore-keys: | - ${{ runner.os }}-cargo-${{ matrix.rust }}- + ${{ runner.os }}-cargo-${{ env.NETSUKE_RUST_TOOLCHAIN }}- ${{ runner.os }}-cargo- - name: Clean build artefacts run: make clean diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..a5483151f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,20 @@ +# Changelog + +## Unreleased + +### Changed + +- Build with the Polonius borrow checker (`-Zpolonius=next`) on the pinned + `nightly-2026-06-25` toolchain; building from source now requires that + nightly, which `rustup` installs automatically + ([#465](https://github.com/leynos/netsuke/issues/465)) +- Remove the `rust-version = "1.89.0"` minimum-supported-Rust-version + declaration from `Cargo.toml`; `rust-toolchain.toml` is now the single + source of truth for the compiler contract + ([#465](https://github.com/leynos/netsuke/issues/465)) + +## [0.1.0] - 2026-07-28 + +_Initial release._ + +[0.1.0]: https://github.com/leynos/netsuke/releases/tag/v0.1.0 diff --git a/Cargo.toml b/Cargo.toml index 8b81a480a..438d1fe8a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,6 @@ name = "netsuke" version = "0.1.0" edition = "2024" -rust-version = "1.89.0" include = [ "src/**", "locales/**", diff --git a/Makefile b/Makefile index 939267dd3..fb1c8a301 100644 --- a/Makefile +++ b/Makefile @@ -2,6 +2,9 @@ APP ?= netsuke CARGO ?= $(shell command -v cargo 2>/dev/null || printf '%s' "$$HOME/.cargo/bin/cargo") +# The Polonius borrow-checker flag normally flows from .cargo/config.toml, but +# any recipe that sets RUSTFLAGS overrides that table and must re-state it. +POLONIUS_FLAGS ?= -Zpolonius=next BUILD_JOBS ?= CLIPPY_FLAGS ?= --all-targets --all-features -- -D warnings KANI ?= cargo kani @@ -52,7 +55,7 @@ clean: ## Remove build artefacts $(CARGO) clean test: ## Run tests with warnings treated as errors - RUSTFLAGS="-D warnings" $(CARGO) test --all-targets --all-features $(BUILD_JOBS) + RUSTFLAGS="-D warnings $(POLONIUS_FLAGS)" $(CARGO) test --all-targets --all-features $(BUILD_JOBS) test-workflow-contracts: ## Validate the mutation-testing caller contract uv run --with 'pytest>=8' --with 'pyyaml>=6' pytest tests/workflow_contracts -q @@ -69,7 +72,7 @@ lint-clippy: ## Run rustdoc and Clippy with warnings denied $(CARGO) clippy $(CLIPPY_FLAGS) lint-whitaker: ## Run the Whitaker Dylint suite with warnings denied - RUSTFLAGS="-D warnings" $(WHITAKER) --all -- --all-targets --all-features + RUSTFLAGS="-D warnings $(POLONIUS_FLAGS)" $(WHITAKER) --all -- --all-targets --all-features fmt: ## Format Rust and Markdown sources $(CARGO) fmt --all @@ -79,7 +82,7 @@ check-fmt: ## Verify formatting $(CARGO) fmt --all -- --check typecheck: ## Typecheck all targets and features - RUSTFLAGS="-D warnings" $(CARGO) check --all-targets --all-features $(BUILD_JOBS) + RUSTFLAGS="-D warnings $(POLONIUS_FLAGS)" $(CARGO) check --all-targets --all-features $(BUILD_JOBS) markdownlint: spelling ## Lint Markdown and enforce en-GB-oxendict spelling $(MDLINT) "**/*.md" diff --git a/README.md b/README.md index b695c067d..157edc9e5 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,11 @@ ______________________________________________________________________ Netsuke currently requires: - [Ninja](https://ninja-build.org/) on `PATH`; -- Rust 1.89 or later when installing from source. +- when installing from source, the dated Rust nightly toolchain pinned in + [`rust-toolchain.toml`](rust-toolchain.toml) (`rustup` installs it + automatically in a checkout). Netsuke builds with the Polonius borrow + checker (`-Zpolonius=next`), which is nightly-only until it stabilizes; see + [ADR-006](docs/adr-006-adopt-polonius-nightly-toolchain.md). ### Installation diff --git a/docs/adr-006-adopt-polonius-nightly-toolchain.md b/docs/adr-006-adopt-polonius-nightly-toolchain.md new file mode 100644 index 000000000..854dd02db --- /dev/null +++ b/docs/adr-006-adopt-polonius-nightly-toolchain.md @@ -0,0 +1,91 @@ +# Architecture Decision Record (ADR): Adopt the Polonius borrow checker on a pinned nightly toolchain + +## Status + +Accepted. + +## Date + +2026-07-29. + +## Context and problem statement + +Netsuke's internal APIs carry the shape that the non-lexical-lifetimes (NLL) +borrow checker imposed on the whole Rust ecosystem: lookups that clone keys +unconditionally, registries that hand back owned values, and error paths that +compute context eagerly. The Polonius alpha analysis (`-Zpolonius=next`) +accepts a strict superset of NLL and removes the lifetime limitation behind +several of these shapes, so the natural borrow-returning form of an accessor +can compile where NLL rejected it. + +Adopting those borrow-centric designs binds the source tree to a +Polonius-enabled compiler, which is nightly-only until the analysis +stabilizes. That conflicts with three standing policies: + +- `rust-toolchain.toml` pinned stable `1.89.0`; +- `Cargo.toml` declared `rust-version = "1.89.0"` as a minimum supported Rust + version (MSRV); +- continuous integration (CI) built a `stable`/`1.89.0` matrix with nightly as + an experimental leg. + +Netsuke is a pre-1.0 application whose only API consumers are its own crates, +so internal API quality was judged to outweigh a stable-toolchain guarantee +(issue #465). + +## Decision + +Adopt Polonius now, as a nightly-only source tree: + +- Pin the dated toolchain `nightly-2026-06-25` in `rust-toolchain.toml` so + builds stay reproducible. +- Enable `-Zpolonius=next` in `.cargo/config.toml` under `[build] rustflags`, + so plain Cargo invocations, rust-analyzer, and `cargo kani` all borrow-check + with the same analysis. Makefile recipes that set `RUSTFLAGS` (which + overrides that table) re-state the flag via the `POLONIUS_FLAGS` variable. +- Collapse the CI matrices in `ci.yml` and `netsukefile-test.yml` to the + pinned nightly, and align `coverage-main.yml`. Stable and MSRV legs are + removed because the tree no longer compiles without Polonius. +- Remove the `rust-version` field from `Cargo.toml`. Cargo cannot express a + nightly requirement there, and advertising `1.89.0` would misstate the + contract; `rust-toolchain.toml` is now the single source of truth. + +Every borrow-centric rewrite that depends on the flag is verified both with +and without `-Zpolonius=next` and recorded in +[polonius migration notes](polonius.md), including refusals where owned style +remains correct. + +## Rationale + +- **Design over deployment breadth.** Netsuke ships binaries, not a library + API. Consumers install packaged artefacts or build from source; the + toolchain pin costs contributors one `rustup` fetch, whereas NLL-era + double lookups and key clones cost every call site, forever. +- **Reproducibility.** A dated nightly behaves like a release: the same + compiler bits build the tree everywhere. `rustup` provisions it + automatically from `rust-toolchain.toml`. +- **Coherent tooling.** Putting the flag in `.cargo/config.toml` keeps + rust-analyzer, Clippy, Whitaker (whose Dylint driver is nightly-based), and + Kani borrow-checking the same dialect, avoiding phantom editor errors on + correct code. +- **Stabilization path.** Polonius is a Rust project goal for stabilization. + When `-Zpolonius=next` becomes default behaviour on stable, the pin and the + flag can be dropped without touching the migrated code, and an MSRV can be + re-declared at that release. + +## Consequences + +- Publishing to crates.io remains possible but installs require a nightly + compiler until Polonius stabilizes; the README documents this. +- Release packaging builds from the pinned nightly. Binary artefacts are + unaffected: the borrow checker changes what compiles, not what is + generated. +- Dependabot-style toolchain drift is impossible; moving the pin is a + deliberate act. Move it forward periodically (and especially once Polonius + stabilizes), re-running the full gate suite, and update this ADR's + references when doing so. +- Sites that genuinely require Polonius are tagged `POLONIUS(...)` in source + and must not be rewritten into NLL-era defensive forms; `AGENTS.md` and + [polonius migration notes](polonius.md) carry the anti-regression guidance. +- `cargo +stable` invocations fail on `-Zpolonius=next`. This is intentional: + the failure is loud and immediate rather than a confusing borrowck error + later. diff --git a/docs/contents.md b/docs/contents.md index 76f63bc06..4e7367123 100644 --- a/docs/contents.md +++ b/docs/contents.md @@ -41,6 +41,8 @@ operator, user, and contributor references are easier to find. - [adr-005-typed-which-resolve-error.md](adr-005-typed-which-resolve-error.md): Typed executable resolver error decision record for `which` and `command_available`. +- [adr-006-adopt-polonius-nightly-toolchain.md](adr-006-adopt-polonius-nightly-toolchain.md): + Pinned-nightly Polonius borrow-checker adoption decision record. ## User and operator guides @@ -60,6 +62,8 @@ operator, user, and contributor references are easier to find. - [developers-guide.md](developers-guide.md): Engineering workflow, quality gates, testing strategy, and stdlib resolver-boundary conventions. +- [polonius.md](polonius.md): Polonius migration audit, borrow-centric API + evolution log, and principled refusals. - [documentation-style-guide.md](documentation-style-guide.md): Documentation conventions, roadmap-writing rules, and Markdown requirements. - [execplans/](execplans/): Execution plans and implementation handoff notes. diff --git a/docs/developers-guide.md b/docs/developers-guide.md index 858c9593d..5352365ce 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -76,6 +76,25 @@ they are per-invocation arguments tagged `#[serde(skip)]` on would silently change the artefact destination — a footgun the design avoids by construction. + +## Toolchain and borrow checker + +Netsuke builds on the dated nightly toolchain pinned in `rust-toolchain.toml` +with the Polonius alpha borrow-checking analysis (`-Zpolonius=next`) enabled. +`rustup` provisions the toolchain automatically, and `.cargo/config.toml` +applies the flag to every Cargo invocation, including rust-analyzer and +`cargo kani`. Makefile recipes that set `RUSTFLAGS` re-state the flag through +the `POLONIUS_FLAGS` variable because an inherited `RUSTFLAGS` environment +variable overrides `.cargo/config.toml`. + +[ADR-006](adr-006-adopt-polonius-nightly-toolchain.md) records the policy +decision, and the [polonius migration notes](polonius.md) track every site +whose design depends on the analysis. Sites tagged `POLONIUS(...)` fail to +compile under plain non-lexical lifetimes (NLL); do not rewrite them into +double lookups, unconditional key clones, or id indirection, and do not pad +new code with defensive clones that only NLL required. When a borrow-centric +form fails to compile, consult the migration notes before restructuring. + ## Quality gates Run these commands before finalizing any change: @@ -331,7 +350,10 @@ make install-kani `cargo install --locked kani-verifier --version `, runs `cargo kani setup`, and verifies that `cargo kani` is callable. Kani may manage its own supporting Rust nightly toolchain during setup. That toolchain must not -replace the repository's ordinary stable Rust workflow. +replace the repository's pinned nightly workflow (see +[ADR-006](adr-006-adopt-polonius-nightly-toolchain.md)). Kani builds pick up +`-Zpolonius=next` from `.cargo/config.toml`, so Polonius-dependent code +verifies unchanged. Delegated prover targets print maintainer diagnostics to standard error before invoking `rust-prover-tools`. Expect `prover-tools:` lines containing the diff --git a/docs/netsuke-design.md b/docs/netsuke-design.md index 004900fe6..404b3d2a4 100644 --- a/docs/netsuke-design.md +++ b/docs/netsuke-design.md @@ -2932,6 +2932,18 @@ selected for this project and the rationale for their inclusion. | Logging | tracing | Structured, levelled diagnostic output for debugging and insight. | | Versioning | semver | The standard library for parsing and evaluating Semantic Versioning strings, essential for the `netsuke_version` field. | +Netsuke compiles with the Polonius alpha borrow-checking analysis +(`-Zpolonius=next`) on the dated nightly toolchain pinned in +`rust-toolchain.toml` +([ADR-006](adr-006-adopt-polonius-nightly-toolchain.md)). Internal APIs +follow a borrow-centric design contract: lookups and registries return +references (`&mut V` accessors with clone-on-miss keys), mutation happens in +place, and error context is built lazily on the failure path. Owned-value +style is reserved for genuine constraints — aliasing, suspension points, +thread and process boundaries, and persistent identity — and each such +refusal is recorded in the [polonius migration notes](polonius.md) alongside +the sites that depend on the analysis. + ### 9.3 Future Enhancements The architecture described in this document provides a solid foundation for a diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 8f0ca5d1c..03ad4a7cc 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,3 +1,7 @@ +# Netsuke builds with the Polonius alpha borrow-checking analysis +# (`-Zpolonius=next`), which is nightly-only. The dated pin keeps builds +# reproducible; see docs/adr-006-adopt-polonius-nightly-toolchain.md before +# changing the channel. [toolchain] -channel = "1.89.0" +channel = "nightly-2026-06-25" components = ["rustfmt", "clippy", "rust-analyzer"] From 22861791e2aa4ac658c42375449084b8513d077a Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 29 Jul 2026 00:45:52 +0200 Subject: [PATCH 02/21] Evolve graph_view node registry to borrow-returning form Introduce `NodePathRegistry` in `src/graph_view/mod.rs` and route the three `entry(path.clone()).or_insert(NodeKind::Source)` sites through `ensure_node_mut`, a get-or-insert accessor that returns `&mut NodeKind`, performs one lookup on the hit path, and clones the path only on insertion. Target registration moves behind `insert_target` so callers pass borrowed paths throughout. The accessor is the canonical Polonius problem-case-3 shape: the `get_mut` loan escapes only via the early return, so the insertion on the miss path is accepted under `-Zpolonius=next` and rejected by NLL with E0499. Both outcomes were compiler-verified on nightly-2026-06-25, and the site carries a `POLONIUS(case-3)` tag with anti-regression guidance. Behaviour is unchanged: the graph_view unit, snapshot, and property suites pass as-is, and a new parameterized test pins the hit and miss semantics of the accessor. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 4 +++ src/graph_view/mod.rs | 65 +++++++++++++++++++++++++++++++++-------- src/graph_view/tests.rs | 21 +++++++++++++ 3 files changed, 78 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a5483151f..f5ea92b75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ ### Changed +- Route graph-view node registration through a borrow-returning + `NodePathRegistry` accessor that looks paths up once on hits and clones a + path only on insertion + ([#465](https://github.com/leynos/netsuke/issues/465)) - Build with the Polonius borrow checker (`-Zpolonius=next`) on the pinned `nightly-2026-06-25` toolchain; building from source now requires that nightly, which `rustup` installs automatically diff --git a/src/graph_view/mod.rs b/src/graph_view/mod.rs index 153a6e892..e5d85f7cf 100644 --- a/src/graph_view/mod.rs +++ b/src/graph_view/mod.rs @@ -8,7 +8,7 @@ use std::collections::{BTreeMap, BTreeSet}; -use camino::Utf8PathBuf; +use camino::{Utf8Path, Utf8PathBuf}; use crate::ir::{BuildEdge, BuildGraph}; @@ -96,16 +96,17 @@ impl GraphView { pub fn from_build_graph(graph: &BuildGraph) -> Self { let edges_seen = collect_unique_edges(graph); - let mut node_paths: BTreeMap = BTreeMap::new(); + let mut registry = NodePathRegistry::default(); let mut node_metadata: BTreeMap = BTreeMap::new(); let mut edges: BTreeSet = BTreeSet::new(); for edge in &edges_seen { - register_outputs(graph, edge, &mut node_paths, &mut node_metadata); - register_inputs_and_edges(edge, &mut node_paths, &mut edges); + register_outputs(graph, edge, &mut registry, &mut node_metadata); + register_inputs_and_edges(edge, &mut registry, &mut edges); } - let nodes = node_paths + let nodes = registry + .into_inner() .into_iter() .map(|(path, kind)| { let meta = node_metadata.remove(&path).unwrap_or_default(); @@ -137,6 +138,46 @@ struct NodeMetadata { description: Option, } +/// Registry mapping node paths to their [`NodeKind`] classification. +/// +/// Wraps the projection's path map behind borrow-returning accessors so +/// callers work with references instead of cloning keys defensively. +#[derive(Debug, Default)] +struct NodePathRegistry { + paths: BTreeMap, +} + +impl NodePathRegistry { + /// Return the registered kind for `path`, recording a [`NodeKind::Source`] + /// node when the path is not yet known. + /// + /// Performs a single lookup on the hit path and clones `path` only when + /// inserting. An existing registration (for example a target produced by + /// an earlier edge) is returned unchanged. + // POLONIUS(case-3): the `get_mut` loan escapes only via the early return, + // so the insertion below is legal under `-Zpolonius=next` but rejected by + // NLL (E0499). Verified both ways on nightly-2026-06-25. Do not rewrite + // into `entry(path.clone())` or a `contains_key` double lookup. + fn ensure_node_mut(&mut self, path: &Utf8Path) -> &mut NodeKind { + if let Some(kind) = self.paths.get_mut(path) { + return kind; + } + self.paths + .entry(path.to_owned()) + .or_insert(NodeKind::Source) + } + + /// Record `path` as a target node, replacing any existing registration. + fn insert_target(&mut self, path: &Utf8Path, kind: NodeKind) { + self.paths.insert(path.to_owned(), kind); + } + + /// Consume the registry, yielding the sorted path map. + fn into_inner(self) -> BTreeMap { + self.paths + } +} + /// Deduplicate the build edges referenced by [`BuildGraph::targets`]. /// /// `BuildGraph::targets` maps every output path back to a (cloned) `BuildEdge`, @@ -155,7 +196,7 @@ fn collect_unique_edges(graph: &BuildGraph) -> Vec { fn register_outputs( graph: &BuildGraph, edge: &BuildEdge, - node_paths: &mut BTreeMap, + registry: &mut NodePathRegistry, node_metadata: &mut BTreeMap, ) { let description = graph @@ -167,8 +208,8 @@ fn register_outputs( .iter() .chain(edge.implicit_outputs.iter()); for out in outputs { - node_paths.insert( - out.clone(), + registry.insert_target( + out, NodeKind::Target { phony: edge.phony, always: edge.always, @@ -186,12 +227,12 @@ fn register_outputs( fn register_inputs_and_edges( edge: &BuildEdge, - node_paths: &mut BTreeMap, + registry: &mut NodePathRegistry, edges: &mut BTreeSet, ) { let implicit: BTreeSet<&Utf8PathBuf> = edge.implicit_outputs.iter().collect(); for input in &edge.inputs { - node_paths.entry(input.clone()).or_insert(NodeKind::Source); + registry.ensure_node_mut(input); for out in edge .explicit_outputs .iter() @@ -210,7 +251,7 @@ fn register_inputs_and_edges( } } for dep in &edge.implicit_deps { - node_paths.entry(dep.clone()).or_insert(NodeKind::Source); + registry.ensure_node_mut(dep); for out in edge .explicit_outputs .iter() @@ -224,7 +265,7 @@ fn register_inputs_and_edges( } } for dep in &edge.order_only_deps { - node_paths.entry(dep.clone()).or_insert(NodeKind::Source); + registry.ensure_node_mut(dep); for out in edge .explicit_outputs .iter() diff --git a/src/graph_view/tests.rs b/src/graph_view/tests.rs index ad19fadbb..b62a6de1a 100644 --- a/src/graph_view/tests.rs +++ b/src/graph_view/tests.rs @@ -357,3 +357,24 @@ fn golden_html_output_matches_snapshot() -> Result<()> { }); Ok(()) } + +#[rstest] +#[case::miss_registers_source(None, NodeKind::Source)] +#[case::hit_preserves_target( + Some(NodeKind::Target { phony: true, always: false }), + NodeKind::Target { phony: true, always: false }, +)] +fn ensure_node_mut_registers_or_returns_existing( + #[case] pre_registered: Option, + #[case] expected: NodeKind, +) { + let mut registry = super::NodePathRegistry::default(); + let path = p("out/app"); + if let Some(kind) = pre_registered { + registry.insert_target(&path, kind); + } + assert_eq!(*registry.ensure_node_mut(&path), expected); + let paths = registry.into_inner(); + assert_eq!(paths.len(), 1); + assert_eq!(paths.get(&path), Some(&expected)); +} From 7564c7299e007dd19df90603fd3dddcf6eeb0c26 Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 29 Jul 2026 00:47:19 +0200 Subject: [PATCH 03/21] Drop redundant key clone in group_by grouping loop `group_by_filter` cloned each resolved key value before passing it to `IndexMap::entry`, but the key is not used again afterwards, so the entry call can consume it directly. Classification for the Polonius audit: this compiles under plain NLL as well as `-Zpolonius=next`, so the clone was habit rather than a borrow-checker workaround; no toolchain caveat applies. Co-Authored-By: Claude Fable 5 --- src/stdlib/collections.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/stdlib/collections.rs b/src/stdlib/collections.rs index b19182f10..8ad7d2590 100644 --- a/src/stdlib/collections.rs +++ b/src/stdlib/collections.rs @@ -130,7 +130,7 @@ fn group_by_filter(values: &Value, attr: &str) -> Result { for item in iter { let key_value = resolve_group_key(&item, attr)?; - groups.entry(key_value.clone()).or_default().push(item); + groups.entry(key_value).or_default().push(item); } Ok(Value::from_object(GroupedValues::new(groups))) From 081cfa840c4f6e0cf1f8e8a2a399799ccfd53c78 Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 29 Jul 2026 00:48:06 +0200 Subject: [PATCH 04/21] Snapshot borrowed keys in cycle detection traversal `CycleDetector::detect_targets` collected an owned clone of every target path purely to sort them for deterministic traversal. The target map is borrowed for the whole `'targets` lifetime, so the snapshot can hold `&'targets Utf8Path` references instead, removing one allocation per node on every cycle analysis. Classification for the Polonius audit: this compiles under plain NLL as well as `-Zpolonius=next` because the collection exists for sorting, not to end a borrow; the owned snapshot was habit. Behaviour is unchanged and the cycle unit and property suites pass as-is. Co-Authored-By: Claude Fable 5 --- src/ir/cycle.rs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/ir/cycle.rs b/src/ir/cycle.rs index 880dafd70..23a828372 100644 --- a/src/ir/cycle.rs +++ b/src/ir/cycle.rs @@ -169,13 +169,18 @@ impl<'targets> CycleDetector<'targets> { #[cfg(not(kani))] fn detect_targets(&mut self, search: CycleSearch) -> CycleVisitResult { - let mut nodes: Vec = self.targets.keys().cloned().collect(); + // Snapshot borrowed keys (not clones): the `'targets` map outlives + // the traversal, so the collected references stay valid while `self` + // is mutated. The collection exists for sorting, not to appease the + // borrow checker. + let mut nodes: Vec<&'targets Utf8Path> = + self.targets.keys().map(Utf8PathBuf::as_path).collect(); // Sort keys for deterministic traversal order. The O(n log n) cost is // negligible for typical build graphs (100–10 000 targets) and is // outweighed by the benefit of stable, reproducible error messages. - nodes.sort_by(|left, right| path_cmp(left.as_path(), right.as_path())); + nodes.sort_by(|left, right| path_cmp(left, right)); for node in nodes { - let Some((target, _)) = target_entry_for_path(self.targets, node.as_path()) else { + let Some((target, _)) = target_entry_for_path(self.targets, node) else { continue; }; if self.is_visited(target) { From 4a033a0854e48fa658ebd7e12f25fc68f667a482 Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 29 Jul 2026 00:49:49 +0200 Subject: [PATCH 05/21] Borrow search directories in the which resolver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `EnvSnapshot::resolved_dirs` cloned the current working directory and every `PATH` entry into an owned `Vec` for each uncached resolution, although the search loop and miss diagnostics only read the directories. Return `Vec<&Utf8Path>` borrowed from the snapshot instead, thread the borrowed slice through `HandleMissContext`, and copy the paths into the owned `ResolveError::NotFound` only at the error boundary, where the diagnostic genuinely outlives the snapshot. Classification for the Polonius audit: compiles under plain NLL as well as `-Zpolonius=next` — the owned returns were habit, not a borrow-checker workaround. The lookup tests compare against borrowed paths now; the assertions themselves are unchanged. Co-Authored-By: Claude Fable 5 --- src/stdlib/which/env.rs | 14 ++++++++++---- src/stdlib/which/lookup/mod.rs | 2 +- src/stdlib/which/lookup/tests.rs | 4 ++-- src/stdlib/which/resolve_error.rs | 6 +++--- 4 files changed, 16 insertions(+), 10 deletions(-) diff --git a/src/stdlib/which/env.rs b/src/stdlib/which/env.rs index 6717ef9aa..99946296f 100644 --- a/src/stdlib/which/env.rs +++ b/src/stdlib/which/env.rs @@ -50,16 +50,22 @@ impl EnvSnapshot { }) } - pub(super) fn resolved_dirs(&self, mode: CwdMode) -> Vec { + /// List the directories to search, borrowing them from the snapshot. + /// + /// Returns references rather than owned paths: the search loop and the + /// miss diagnostics only read the directories, and the error path copies + /// them into the owned [`super::resolve_error::ResolveError`] at the + /// boundary where the data outlives the snapshot. + pub(super) fn resolved_dirs(&self, mode: CwdMode) -> Vec<&Utf8Path> { let mut dirs = Vec::new(); if matches!(mode, CwdMode::Always) { - dirs.push(self.cwd.clone()); + dirs.push(self.cwd.as_path()); } for entry in &self.entries { match entry { - PathEntry::Dir(path) => dirs.push(path.clone()), + PathEntry::Dir(path) => dirs.push(path.as_path()), PathEntry::CurrentDir if matches!(mode, CwdMode::Always | CwdMode::Auto) => { - dirs.push(self.cwd.clone()); + dirs.push(self.cwd.as_path()); } PathEntry::CurrentDir => {} } diff --git a/src/stdlib/which/lookup/mod.rs b/src/stdlib/which/lookup/mod.rs index a507cf145..043de7433 100644 --- a/src/stdlib/which/lookup/mod.rs +++ b/src/stdlib/which/lookup/mod.rs @@ -234,7 +234,7 @@ struct HandleMissContext<'a> { env: &'a EnvSnapshot, command: &'a str, options: &'a WhichOptions, - dirs: &'a [Utf8PathBuf], + dirs: &'a [&'a Utf8Path], workspace_skips: &'a WorkspaceSkipList, } diff --git a/src/stdlib/which/lookup/tests.rs b/src/stdlib/which/lookup/tests.rs index 65ff47301..bf333017d 100644 --- a/src/stdlib/which/lookup/tests.rs +++ b/src/stdlib/which/lookup/tests.rs @@ -174,11 +174,11 @@ fn relative_path_entries_resolve_against_cwd( let resolved_dirs = snapshot.resolved_dirs(CwdMode::Never); ensure!( - resolved_dirs.contains(&bin), + resolved_dirs.contains(&bin.as_path()), "resolved_dirs missing bin: {resolved_dirs:?}" ); ensure!( - resolved_dirs.contains(&tools), + resolved_dirs.contains(&tools.as_path()), "resolved_dirs missing tools: {resolved_dirs:?}" ); diff --git a/src/stdlib/which/resolve_error.rs b/src/stdlib/which/resolve_error.rs index f2d526c22..241adfa96 100644 --- a/src/stdlib/which/resolve_error.rs +++ b/src/stdlib/which/resolve_error.rs @@ -2,7 +2,7 @@ use std::{fmt, io}; -use camino::Utf8PathBuf; +use camino::{Utf8Path, Utf8PathBuf}; use walkdir; use super::options::CwdMode; @@ -108,10 +108,10 @@ impl std::error::Error for ResolveError { /// /// `command` is the lookup key, `dirs` is the searched directory set, and /// `mode` records how the current directory contributed to the search. -pub(super) fn not_found(command: &str, dirs: &[Utf8PathBuf], mode: CwdMode) -> ResolveError { +pub(super) fn not_found(command: &str, dirs: &[&Utf8Path], mode: CwdMode) -> ResolveError { ResolveError::NotFound { command: command.to_owned(), - dirs: dirs.to_vec(), + dirs: dirs.iter().map(|dir| dir.to_path_buf()).collect(), cwd_mode: mode, } } From d87fe4a2035f980e122c06bf93be3fa79a564aa0 Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 29 Jul 2026 00:50:24 +0200 Subject: [PATCH 06/21] Tag principled Polonius refusals at ownership boundaries Record the audit's deliberate refusals in source so future passes (by humans or agents) do not convert them into borrow-returning forms: - `register_action` keeps returning the owned action hash because the hash is persistent IR identity stored on every `BuildEdge` and named in the generated Ninja file: `POLONIUS-REFUSED(id-is-data)`. - The which resolver's cache clones hits out of the LRU because a reference cannot outlive the `MutexGuard`: `POLONIUS-REFUSED(lock-boundary)`. - `GroupedValues::new` keeps the owned-key `entry` form because the first-wins registration is miss-dominant: `POLONIUS-REFUSED(miss-dominant)`. No behaviour changes; comments only. Co-Authored-By: Claude Fable 5 --- src/ir/from_manifest_support.rs | 7 +++++++ src/stdlib/collections.rs | 4 ++++ src/stdlib/which/cache.rs | 4 ++++ 3 files changed, 15 insertions(+) diff --git a/src/ir/from_manifest_support.rs b/src/ir/from_manifest_support.rs index 5fa4d38ae..204ea03d5 100644 --- a/src/ir/from_manifest_support.rs +++ b/src/ir/from_manifest_support.rs @@ -51,6 +51,13 @@ pub(super) fn register_action( .with_arg("details", err.to_string()), source: err, })?; + // POLONIUS-REFUSED(id-is-data): the action hash is persistent IR + // identity — callers store it on every `BuildEdge` and it names the + // action in the generated Ninja file. Returning the owned hash (rather + // than a reference to an interned key) is a data-model choice, not an + // NLL workaround; the write-only `contains_key` guard below already + // compiles under NLL. Convert to a borrow-returning accessor only if + // callers develop a demonstrated need for the canonical interned value. if !actions.contains_key(hash.as_str()) { actions.insert(hash.clone(), action); } diff --git a/src/stdlib/collections.rs b/src/stdlib/collections.rs index 8ad7d2590..1e70ad2df 100644 --- a/src/stdlib/collections.rs +++ b/src/stdlib/collections.rs @@ -27,6 +27,10 @@ struct GroupedValues { impl GroupedValues { fn new(groups: IndexMap>) -> Self { let mut string_keys = IndexMap::new(); + // POLONIUS-REFUSED(miss-dominant): group keys are unique, so this + // first-wins registration almost always inserts; the owned-key + // `entry` form pays nothing on the rare duplicate-label hit and a + // borrow-returning accessor would buy nothing here. for key in groups.keys() { if let Some(label) = key.as_str() { string_keys diff --git a/src/stdlib/which/cache.rs b/src/stdlib/which/cache.rs index 09a98ae14..d709b795d 100644 --- a/src/stdlib/which/cache.rs +++ b/src/stdlib/which/cache.rs @@ -93,6 +93,10 @@ impl WhichResolver { Ok(matches) } + // POLONIUS-REFUSED(lock-boundary): the hit is cloned out of the LRU + // because a reference cannot outlive the `MutexGuard`, and the resolver + // is shared across template evaluation sites. Owned returns at this + // boundary are an aliasing/synchronization constraint, not NLL residue. fn try_cache(&self, key: &CacheKey) -> Option> { let mut guard = self.lock_cache(); guard.get(key).map(|entry| entry.matches.clone()) From 9b2fa4d30beb1911b8c15e3ec9689c332d2cf963 Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 29 Jul 2026 00:52:02 +0200 Subject: [PATCH 07/21] Document the Polonius audit and anti-regression contract Add `docs/polonius.md` recording the complete two-pass audit: the compiler-verified `POLONIUS(case-3)` site in graph_view, the pass-both evolutions (group_by key, cycle-detection snapshot, which resolver directories), the tagged refusals with their permanent constraints, the non-candidates reviewed and cleared, clone counts before and after (158 to 151 in `src/`), and the stabilization checklist. Add a "Borrow checker: Polonius, not NLL" section to `AGENTS.md` so coding agents do not rewrite borrow-centric sites into double lookups, defensive clones, or id indirection out of NLL-era habit. Co-Authored-By: Claude Fable 5 --- AGENTS.md | 23 ++++++++ docs/polonius.md | 134 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 157 insertions(+) create mode 100644 docs/polonius.md diff --git a/AGENTS.md b/AGENTS.md index 8c8d022b9..c0410a1fc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -133,6 +133,29 @@ This repository is written in Rust and uses Cargo for building and dependency management. Contributors should follow these best practices when working on the project: +### Borrow checker: Polonius, not NLL + +Netsuke compiles with the Polonius alpha analysis (`-Zpolonius=next`) on the +dated nightly pinned in `rust-toolchain.toml` (see +`docs/adr-006-adopt-polonius-nightly-toolchain.md` and `docs/polonius.md`). +Internal APIs are borrow-centric: lookups and get-or-create accessors return +references, clone keys only on insertion, and build error context lazily. + +- **Never** rewrite a site tagged `POLONIUS(...)` into a double lookup + (`contains_key` + `get_mut`), an `entry(key.clone())` call, or an + id/index round-trip. Those forms are the NLL-era workarounds this + codebase deliberately retired; the direct form is compiler-verified. +- **Never** silence a borrow error by adding a defensive `.clone()`, an + eager error-context computation, or a snapshot `collect()` without first + compiling the natural borrow-returning form under the project toolchain — + it usually compiles. +- Respect `POLONIUS-REFUSED(...)` tags: the named constraint (persistent + identity, lock boundaries, aliasing, suspension points, thread + boundaries) is permanent. Do not convert those sites to + reference-returning forms. +- When adding a new borrow-centric API, verify it with and without + `-Zpolonius=next` and record the classification in `docs/polonius.md`. + - Run `make check-fmt`, `make lint`, and `make test` before committing. These targets wrap the following commands, so contributors understand the exact behaviour and policy enforced: diff --git a/docs/polonius.md b/docs/polonius.md new file mode 100644 index 000000000..973bb44e2 --- /dev/null +++ b/docs/polonius.md @@ -0,0 +1,134 @@ +# Polonius migration notes + +Netsuke compiles with the Polonius alpha borrow-checking analysis +(`-Zpolonius=next`) on the dated nightly pinned in `rust-toolchain.toml`. +[ADR-006](adr-006-adopt-polonius-nightly-toolchain.md) records the toolchain +policy; this document records the audit that motivated it, the API +evolutions it enabled, and the refusals that bound it. Issue +[#465](https://github.com/leynos/netsuke/issues/465) tracked the migration. + +## Method + +The migration ran the `nll-to-polonius` two-pass audit with the compiler as +the oracle: + +1. **Workaround scan** — mechanical sweep for local non-lexical-lifetimes + (NLL) workaround shapes: double lookups, `entry()` with unconditionally + cloned keys, re-lookup after insert, index-returning finders, + borrow-killing `drop()` calls, and eager error context. +2. **Design-pressure scan** — structural sweep for owned lookup results, + id/index indirection, clone-modify-writeback, snapshot-collect loops, and + per-module clone hotspots. + +Every change was compiled twice on `nightly-2026-06-25`: once with +`-Zpolonius=next` (must pass) and once without (the outcome classifies the +change). A failure without the flag proves the design genuinely depends on +Polonius and the site is tagged `POLONIUS(...)`; success means the old form +was habit rather than necessity and the improvement carries no toolchain +caveat. Behavioural test suites were required to pass unchanged in either +case. + +## Polonius-dependent sites + +| Site | Tag | Verification | +| --- | --- | --- | +| `src/graph_view/mod.rs` — `NodePathRegistry::ensure_node_mut` | `POLONIUS(case-3)` | Passes with `-Zpolonius=next`; rejected by NLL with E0499 on nightly-2026-06-25 | + +`ensure_node_mut` is the get-or-insert accessor behind graph projection: it +returns `&mut NodeKind`, performs a single lookup on the hit path, and +clones the path only on insertion. It replaced three +`entry(path.clone()).or_insert(NodeKind::Source)` sites that cloned every +input, implicit-dependency, and order-only path on every registration. The +`get_mut` loan escapes only via the early return, which is the canonical +Polonius problem-case-3 shape (conditional early return of a borrow). + +## Evolutions that compile under both checkers + +These came out of the design-pressure scan. Each compiles under plain NLL as +well — the owned style was habit, so they carry no toolchain caveat: + +- `src/stdlib/collections.rs` — `group_by_filter` consumed its resolved key + in `entry(key_value)` instead of cloning it first. +- `src/ir/cycle.rs` — `detect_targets` snapshots borrowed + `&'targets Utf8Path` keys for its deterministic sort instead of cloning + every target path per analysis. The snapshot exists for sorting, not to + end a borrow, so it stays. +- `src/stdlib/which/env.rs` — `EnvSnapshot::resolved_dirs` returns + `Vec<&Utf8Path>` borrowed from the snapshot; the search loop reads + borrowed directories and the paths are copied into the owned + `ResolveError::NotFound` only at the error boundary. + +## Refusals + +Owned style retained deliberately. The constraint, not the borrow checker, +is load-bearing; each site carries the matching source tag: + +| Site | Tag | Constraint | +| --- | --- | --- | +| `src/ir/from_manifest_support.rs` — `register_action` | `POLONIUS-REFUSED(id-is-data)` | The action hash is persistent IR identity: stored on every `BuildEdge` and named in the generated Ninja file. Remains owned unless callers demonstrate a need for the canonical interned value. | +| `src/stdlib/which/cache.rs` — `WhichResolver::try_cache` | `POLONIUS-REFUSED(lock-boundary)` | Cache hits are cloned out of the LRU because references cannot outlive the `MutexGuard`; the resolver is shared across evaluation sites. | +| `src/stdlib/collections.rs` — `GroupedValues::new` | `POLONIUS-REFUSED(miss-dominant)` | First-wins string-key registration almost always inserts, so the owned-key `entry` form pays nothing on the rare hit. | + +## Non-candidates reviewed and cleared + +Scanner suspects that turned out not to be NLL residue: + +- `src/ir/from_manifest_support.rs` — the `contains_key`/`insert` guard in + `register_action` keeps no reference, so it already compiles under NLL + (write-only guarding); the refusal above covers the owned hash it returns. +- `src/ir/cycle.rs:238` — the doc comment on `visit_known_edge` blaming the + borrow checker describes the `'targets` borrow discipline accurately and + needs no Polonius caveat. +- `src/cli/merge.rs` — clones construct the resolved `Cli` from borrowed + layers; owned construction of a new value, not clone-modify-writeback. +- `build_l10n_audit.rs` — `find_matching_brace` and `find_raw_string_end` + return byte offsets into source text; the index is the result (a data id), + not a borrow dodge. +- Test-suite `drop()` calls (environment guards, HTTP fixture teardown) are + semantic Drop effects, not borrow appeasement. + +## Clone counts + +Measured with `rg --count '\.clone\(\)'` over `src/` (tests included where +they live in `src/`): + +| Scope | Before | After | +| --- | --- | --- | +| `src/` total | 158 | 151 | +| `src/graph_view/mod.rs` | 17 | 14 | +| `src/ir/cycle.rs` (non-test) | 1 | 0 | +| `src/stdlib/which/env.rs` | 4 | 1 | +| `src/stdlib/collections.rs` | 4 | 3 | + +The scanner's clone-modify-writeback section was empty before and after the +migration. The remaining graph_view clones construct owned keys for the two +projection maps and owned metadata — data ownership, not workaround shapes. + +## Stabilization + +When `-Zpolonius=next` (or its successor) reaches stable Rust: + +1. Move `rust-toolchain.toml` to the stabilizing release and delete the + `[build] rustflags` entry in `.cargo/config.toml` plus the Makefile + `POLONIUS_FLAGS` variable. +2. Re-declare `rust-version` in `Cargo.toml` at that release. +3. Keep the `POLONIUS(...)` tags: they still explain why the shape exists; + reword "nightly-only" phrasing in the ADR and guides. + +## Anti-regression guidance + +The contract for new code and reviews (also summarized in `AGENTS.md` and +the [developers' guide](developers-guide.md)): + +- Do not rewrite `POLONIUS(...)` sites into double lookups, + `entry(key.clone())`, or `contains_key` guards — the direct form is + intentional and compiler-verified. +- Do not add defensive clones, id indirection, or eager error context to + satisfy a borrow error without first checking whether the natural + borrow-returning form compiles under the project toolchain. +- Respect `POLONIUS-REFUSED(...)` tags: the named constraint (identity, + locks, aliasing, suspension points, thread boundaries) is permanent, and + "simplifying" those sites into reference-returning forms will not compile + or will break the design. +- Classify any new borrow-centric API by compiling with and without the + flag, then record it here. From 8f56c9d9c6b0690d36dd10a9512f049b9191f935 Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 29 Jul 2026 00:57:11 +0200 Subject: [PATCH 08/21] Compile Kani cfg policy fixture directly, not via trybuild The `cases.pass()` trybuild case rebuilt `netsuke` as a dependency of its scratch project, and trybuild discards ambient `RUSTFLAGS` and workspace `build.rustflags` in favour of its own `--config` flags, so the dependency compiled under plain NLL and rejected the `POLONIUS(case-3)` accessor in graph_view (E0499). The fixture only asserts on `include_str!` copies of `Cargo.toml` and the `Makefile` and has no dependencies, so compile it with the workspace `rustc` through the same harness the sibling check-cfg tests use, run the produced binary, and assert both steps succeed. The policy assertions are unchanged; drop the now-unused `trybuild` dev-dependency. Co-Authored-By: Claude Fable 5 --- Cargo.lock | 55 -------------------------------- Cargo.toml | 1 - tests/kani_cfg_ui_tests.rs | 37 ++++++++++++++++++--- tests/ui/cfg_kani_policy_pass.rs | 2 +- 4 files changed, 33 insertions(+), 62 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c5510e3b7..cfe55faf3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1420,7 +1420,6 @@ dependencies = [ "toml 0.8.23", "tracing", "tracing-subscriber", - "trybuild", "ureq", "url", "wait-timeout", @@ -2531,12 +2530,6 @@ dependencies = [ "libc", ] -[[package]] -name = "target-triple" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "591ef38edfb78ca4771ee32cf494cb8771944bee237a9b91fc9c1424ac4b777b" - [[package]] name = "tempfile" version = "3.27.0" @@ -2550,15 +2543,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "termcolor" -version = "1.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" -dependencies = [ - "winapi-util", -] - [[package]] name = "terminal_size" version = "0.4.2" @@ -2755,21 +2739,6 @@ dependencies = [ "winnow 0.7.14", ] -[[package]] -name = "toml" -version = "1.1.0+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8195ca05e4eb728f4ba94f3e3291661320af739c4e43779cbdfae82ab239fcc" -dependencies = [ - "indexmap", - "serde_core", - "serde_spanned 1.1.1", - "toml_datetime 1.1.1+spec-1.1.0", - "toml_parser", - "toml_writer", - "winnow 1.0.3", -] - [[package]] name = "toml_datetime" version = "0.6.11" @@ -2788,15 +2757,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "toml_datetime" -version = "1.1.1+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" -dependencies = [ - "serde_core", -] - [[package]] name = "toml_edit" version = "0.22.27" @@ -2901,21 +2861,6 @@ dependencies = [ "tracing-log", ] -[[package]] -name = "trybuild" -version = "1.0.118" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06649c6f63d86604ba0c8950d5a1829fc9a17afd70fc6629f481d75b6a624c78" -dependencies = [ - "glob", - "serde", - "serde_derive", - "serde_json", - "target-triple", - "termcolor", - "toml 1.1.0+spec-1.1.0", -] - [[package]] name = "type-map" version = "0.5.1" diff --git a/Cargo.toml b/Cargo.toml index 438d1fe8a..049fc4c96 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -172,7 +172,6 @@ strip-ansi-escapes = "0.2" toml = "0.8" serde_yaml = "0.9" proptest = "1.11.0" -trybuild = "1.0.116" # Target-specific dev-deps [target.'cfg(unix)'.dev-dependencies] diff --git a/tests/kani_cfg_ui_tests.rs b/tests/kani_cfg_ui_tests.rs index 3d8a5bfbc..0f4ada031 100644 --- a/tests/kani_cfg_ui_tests.rs +++ b/tests/kani_cfg_ui_tests.rs @@ -12,10 +12,34 @@ use std::{ }; /// Verify repository policy files used by the `cfg(kani)` compile contract. +/// +/// The fixture is compiled with the workspace `rustc` and then executed, so +/// its assertions run against the checked-in policy files. Trybuild +/// previously drove this case, but trybuild discards ambient `RUSTFLAGS` +/// and workspace `build.rustflags`, so it rebuilt the `netsuke` dependency +/// without `-Zpolonius=next` and rejected the crate's `POLONIUS(...)` +/// sites; the fixture needs no dependencies, so a direct compile preserves +/// the contract (see docs/polonius.md). #[test] -fn trybuild_validates_kani_cfg_policy_sources() { - let cases = trybuild::TestCases::new(); - cases.pass("tests/ui/cfg_kani_policy_pass.rs"); +fn compiled_fixture_validates_kani_cfg_policy_sources() -> io::Result<()> { + let output_dir = tempfile::tempdir()?; + let binary = output_dir.path().join("cfg-kani-policy-pass"); + let compile = compile_ui_fixture("tests/ui/cfg_kani_policy_pass.rs", &binary)?; + if !compile.status.success() { + return Err(io::Error::other(format!( + "policy fixture should compile:\n{}", + stderr(&compile), + ))); + } + + let run = Command::new(&binary).output()?; + if !run.status.success() { + return Err(io::Error::other(format!( + "policy fixture assertions should pass:\n{}", + stderr(&run), + ))); + } + Ok(()) } /// `cfg(kani)` compiles when the expected check-cfg declaration is active. @@ -55,10 +79,13 @@ fn unknown_cfg_is_rejected_by_compile_time_policy() -> io::Result<()> { } fn rustc_with_kani_check_cfg(source: &str) -> io::Result { + let output_dir = tempfile::tempdir()?; + compile_ui_fixture(source, &output_dir.path().join("ui-test-bin")) +} + +fn compile_ui_fixture(source: &str, output_path: &Path) -> io::Result { let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); let source_path = manifest_dir.join(source); - let output_dir = tempfile::tempdir()?; - let output_path = output_dir.path().join("ui-test-bin"); Command::new(rustc()) .arg("--edition=2024") diff --git a/tests/ui/cfg_kani_policy_pass.rs b/tests/ui/cfg_kani_policy_pass.rs index d829612a6..427e7a222 100644 --- a/tests/ui/cfg_kani_policy_pass.rs +++ b/tests/ui/cfg_kani_policy_pass.rs @@ -1,4 +1,4 @@ -//! Trybuild pass case for repository-level Kani cfg policy. +//! Compile-and-run pass case for repository-level Kani cfg policy. const CARGO_TOML: &str = include_str!("../../Cargo.toml"); const MAKEFILE: &str = include_str!("../../Makefile"); From 5000642f1604c257bb08e15fee316a5f232df799 Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 29 Jul 2026 00:57:35 +0200 Subject: [PATCH 09/21] Record harness consequences in Polonius notes Document that trybuild replaces workspace rustflags and so cannot build the Polonius-only crate as a fixture dependency, and that Kani and Whitaker inherit the flag through the workspace configuration. Co-Authored-By: Claude Fable 5 --- docs/polonius.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/docs/polonius.md b/docs/polonius.md index 973bb44e2..69502ff03 100644 --- a/docs/polonius.md +++ b/docs/polonius.md @@ -87,6 +87,21 @@ Scanner suspects that turned out not to be NLL residue: - Test-suite `drop()` calls (environment guards, HTTP fixture teardown) are semantic Drop effects, not borrow appeasement. +## Harness consequences + +Tooling that rebuilds the crate with its own flags must propagate the +Polonius flag or avoid compiling the crate: + +- **trybuild** discards ambient `RUSTFLAGS` and workspace `build.rustflags`, + replacing them via `--config` on its scratch project, and it always builds + the host crate as a fixture dependency. The Kani cfg policy fixture is + therefore compiled and run directly with the workspace `rustc` + (`tests/kani_cfg_ui_tests.rs`); do not reintroduce trybuild cases that + depend on the `netsuke` crate while the tree is Polonius-only. +- **Kani** and **Whitaker** run under their own toolchains but read the + workspace `.cargo/config.toml` or the Makefile `RUSTFLAGS`, so they + borrow-check with `-Zpolonius=next` and need no special handling. + ## Clone counts Measured with `rg --count '\.clone\(\)'` over `src/` (tests included where From da75c18a02b41a7bfd1b84b8d9e71ea2d4edb5d4 Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 29 Jul 2026 02:12:39 +0200 Subject: [PATCH 10/21] Keep Polonius flag when CI presets RUSTFLAGS CI failed at `make lint`: `actions-rust-lang/setup-rust-toolchain` (nested inside the shared setup-rust action) exports `RUSTFLAGS="-D warnings"` into the job environment when the variable is unset, and an ambient `RUSTFLAGS` overrides the `.cargo/config.toml` `build.rustflags` table. `make lint-clippy` did not re-state the flags, so Clippy compiled under plain NLL and rejected the tagged `POLONIUS(case-3)` accessor with E0499. Fix both layers: - Pre-set `RUSTFLAGS` (including `-Zpolonius=next`) at job level in `ci.yml`, `netsukefile-test.yml`, and `coverage-main.yml`. The setup action defers to an existing value, and `cargo-llvm-cov` appends its instrumentation flags to it, so coverage inherits the flag too. - Harden the Makefile: `lint-clippy` re-states the flags for Clippy, and the `cargo doc` and binary-build recipes append `$(POLONIUS_FLAGS)` to any ambient `RUSTFLAGS` so a hostile environment can never strip the analysis. Verified locally by running `make lint-clippy` and `make build` with `RUSTFLAGS="-D warnings"` exported, reproducing the CI environment; both now pass. The harness-consequences section of `docs/polonius.md` records the CI behaviour and flags the shared mutation workflow as the remaining environment outside this repository's control. Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 4 ++++ .github/workflows/coverage-main.yml | 4 ++++ .github/workflows/netsukefile-test.yml | 3 +++ Makefile | 6 +++--- docs/polonius.md | 13 +++++++++++++ 5 files changed, 27 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 513949263..1de655e32 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,6 +17,10 @@ jobs: # docs/adr-006-adopt-polonius-nightly-toolchain.md), so CI builds with # the dated nightly pinned in rust-toolchain.toml. NETSUKE_RUST_TOOLCHAIN: nightly-2026-06-25 + # Pre-set RUSTFLAGS so setup-rust-toolchain's "-D warnings" default does + # not shadow .cargo/config.toml and strip -Zpolonius=next; tools such as + # cargo-llvm-cov append their own flags to this value. + RUSTFLAGS: -D warnings -Zpolonius=next WHITAKER_INSTALLER_VERSION: '0.2.6' steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 diff --git a/.github/workflows/coverage-main.yml b/.github/workflows/coverage-main.yml index 0814e696f..fbf27a91a 100644 --- a/.github/workflows/coverage-main.yml +++ b/.github/workflows/coverage-main.yml @@ -19,6 +19,10 @@ jobs: env: CARGO_TERM_COLOR: always BUILD_PROFILE: debug + # Pre-set RUSTFLAGS so setup-rust-toolchain's "-D warnings" default does + # not shadow .cargo/config.toml and strip -Zpolonius=next; cargo-llvm-cov + # appends its instrumentation flags to this value. + RUSTFLAGS: -D warnings -Zpolonius=next steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup Rust diff --git a/.github/workflows/netsukefile-test.yml b/.github/workflows/netsukefile-test.yml index 4ef358ccf..842cd494b 100644 --- a/.github/workflows/netsukefile-test.yml +++ b/.github/workflows/netsukefile-test.yml @@ -15,6 +15,9 @@ jobs: # Match rust-toolchain.toml: the tree needs -Zpolonius=next (see # docs/adr-006-adopt-polonius-nightly-toolchain.md). NETSUKE_RUST_TOOLCHAIN: nightly-2026-06-25 + # Pre-set RUSTFLAGS so setup-rust-toolchain's "-D warnings" default does + # not shadow .cargo/config.toml and strip -Zpolonius=next. + RUSTFLAGS: -Zpolonius=next steps: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 diff --git a/Makefile b/Makefile index fb1c8a301..e7a1c436a 100644 --- a/Makefile +++ b/Makefile @@ -63,13 +63,13 @@ test-workflow-contracts: ## Validate the mutation-testing caller contract test-typos-config: spelling-helper-test ## Verify the shared spelling-policy integration target/%/$(APP): ## Build binary in debug or release mode - $(CARGO) build $(BUILD_JOBS) $(if $(findstring release,$(@)),--release) --bin $(APP) + RUSTFLAGS="$${RUSTFLAGS-} $(POLONIUS_FLAGS)" $(CARGO) build $(BUILD_JOBS) $(if $(findstring release,$(@)),--release) --bin $(APP) lint: lint-clippy lint-whitaker ## Run Clippy and the Whitaker Dylint suite with warnings denied lint-clippy: ## Run rustdoc and Clippy with warnings denied - RUSTDOCFLAGS="$(RUSTDOC_FLAGS)" $(CARGO) doc --no-deps - $(CARGO) clippy $(CLIPPY_FLAGS) + RUSTDOCFLAGS="$(RUSTDOC_FLAGS)" RUSTFLAGS="$${RUSTFLAGS-} $(POLONIUS_FLAGS)" $(CARGO) doc --no-deps + RUSTFLAGS="-D warnings $(POLONIUS_FLAGS)" $(CARGO) clippy $(CLIPPY_FLAGS) lint-whitaker: ## Run the Whitaker Dylint suite with warnings denied RUSTFLAGS="-D warnings $(POLONIUS_FLAGS)" $(WHITAKER) --all -- --all-targets --all-features diff --git a/docs/polonius.md b/docs/polonius.md index 69502ff03..c34b5b15a 100644 --- a/docs/polonius.md +++ b/docs/polonius.md @@ -101,6 +101,19 @@ Polonius flag or avoid compiling the crate: - **Kani** and **Whitaker** run under their own toolchains but read the workspace `.cargo/config.toml` or the Makefile `RUSTFLAGS`, so they borrow-check with `-Zpolonius=next` and need no special handling. +- **CI setup actions**: `actions-rust-lang/setup-rust-toolchain` exports + `RUSTFLAGS="-D warnings"` into the job environment when the variable is + unset, which shadows `.cargo/config.toml` for every later step. The + workflows therefore pre-set `RUSTFLAGS` (including `-Zpolonius=next`) at + job level — the action defers to an existing value — and the Makefile + recipes append `POLONIUS_FLAGS` to any ambient `RUSTFLAGS` as a second + line of defence. `cargo-llvm-cov` appends its instrumentation flags to + the ambient value, so coverage inherits the flag from the job + environment. +- **cargo-mutants** (scheduled, informational) runs through the shared + `mutation-cargo.yml` workflow, which controls its own environment; if its + runs regress with E0499 at tagged sites, the shared workflow needs the + same `RUSTFLAGS` treatment. ## Clone counts From 931fb6174eb5c0590a6f990dc262a6e4c5fd95bf Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 29 Jul 2026 02:20:52 +0200 Subject: [PATCH 11/21] Restructure edge registration to flatten nested logic CodeScene's delta gate flagged `register_inputs_and_edges` as a bumpy road: three sibling loops each nesting conditional logic. Replace the free function with an `EdgeRegistrar` that groups the edge with the mutable projection state, splits input registration from dependency-class registration, and shares the duplicated output chain through a single `all_outputs` iterator (also reused by `register_outputs`). Behaviour is unchanged: the graph_view unit, snapshot, and property suites pass as-is, the CodeScene delta reports no issues, and the `POLONIUS(case-3)` accessor still verifies both ways (E0499 under plain NLL). Co-Authored-By: Claude Fable 5 --- src/graph_view/mod.rs | 106 ++++++++++++++++++++++++------------------ 1 file changed, 60 insertions(+), 46 deletions(-) diff --git a/src/graph_view/mod.rs b/src/graph_view/mod.rs index e5d85f7cf..c6b237ebb 100644 --- a/src/graph_view/mod.rs +++ b/src/graph_view/mod.rs @@ -102,7 +102,12 @@ impl GraphView { for edge in &edges_seen { register_outputs(graph, edge, &mut registry, &mut node_metadata); - register_inputs_and_edges(edge, &mut registry, &mut edges); + EdgeRegistrar { + edge, + registry: &mut registry, + edges: &mut edges, + } + .register(); } let nodes = registry @@ -203,11 +208,7 @@ fn register_outputs( .actions .get(&edge.action_id) .and_then(|action| action.description.clone()); - let outputs = edge - .explicit_outputs - .iter() - .chain(edge.implicit_outputs.iter()); - for out in outputs { + for out in all_outputs(edge) { registry.insert_target( out, NodeKind::Target { @@ -225,57 +226,70 @@ fn register_outputs( } } -fn register_inputs_and_edges( - edge: &BuildEdge, - registry: &mut NodePathRegistry, - edges: &mut BTreeSet, -) { - let implicit: BTreeSet<&Utf8PathBuf> = edge.implicit_outputs.iter().collect(); - for input in &edge.inputs { - registry.ensure_node_mut(input); - for out in edge - .explicit_outputs - .iter() - .chain(edge.implicit_outputs.iter()) - { +/// Every output produced by `edge`, explicit first, then implicit. +fn all_outputs(edge: &BuildEdge) -> impl Iterator { + edge.explicit_outputs + .iter() + .chain(edge.implicit_outputs.iter()) +} + +/// Registers the dependency nodes and edges contributed by one build edge. +/// +/// Groups the edge with the mutable projection state so the per-class +/// helpers share one borrow of the registry and edge set. +struct EdgeRegistrar<'a> { + edge: &'a BuildEdge, + registry: &'a mut NodePathRegistry, + edges: &'a mut BTreeSet, +} + +impl EdgeRegistrar<'_> { + /// Register the edge's inputs and dependencies with their edge classes. + fn register(&mut self) { + self.register_inputs(); + let edge = self.edge; + self.register_dependencies(&edge.implicit_deps, EdgeClass::ImplicitDep); + self.register_dependencies(&edge.order_only_deps, EdgeClass::OrderOnly); + } + + /// Register explicit inputs, classifying edges into implicit outputs. + fn register_inputs(&mut self) { + let edge = self.edge; + let implicit: BTreeSet<&Utf8PathBuf> = edge.implicit_outputs.iter().collect(); + for input in &edge.inputs { + self.registry.ensure_node_mut(input); + self.register_input_edges(input, &implicit); + } + } + + /// Add one edge from `input` to every output, classified by whether the + /// output is implicit. + fn register_input_edges(&mut self, input: &Utf8PathBuf, implicit: &BTreeSet<&Utf8PathBuf>) { + for out in all_outputs(self.edge) { let class = if implicit.contains(out) { EdgeClass::ImplicitOutput } else { EdgeClass::Explicit }; - edges.insert(EdgeView { + self.edges.insert(EdgeView { from: input.clone(), to: out.clone(), class, }); } } - for dep in &edge.implicit_deps { - registry.ensure_node_mut(dep); - for out in edge - .explicit_outputs - .iter() - .chain(edge.implicit_outputs.iter()) - { - edges.insert(EdgeView { - from: dep.clone(), - to: out.clone(), - class: EdgeClass::ImplicitDep, - }); - } - } - for dep in &edge.order_only_deps { - registry.ensure_node_mut(dep); - for out in edge - .explicit_outputs - .iter() - .chain(edge.implicit_outputs.iter()) - { - edges.insert(EdgeView { - from: dep.clone(), - to: out.clone(), - class: EdgeClass::OrderOnly, - }); + + /// Register `deps` as source nodes with a `class` edge to every output. + fn register_dependencies(&mut self, deps: &[Utf8PathBuf], class: EdgeClass) { + for dep in deps { + self.registry.ensure_node_mut(dep); + for out in all_outputs(self.edge) { + self.edges.insert(EdgeView { + from: dep.clone(), + to: out.clone(), + class, + }); + } } } } From 2da93c86df5b73b7be7eb7c3c2b9db4e06659a58 Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 29 Jul 2026 11:10:20 +0200 Subject: [PATCH 12/21] Keep Polonius flag in release artefact builds The release dry run failed: `rust-build-release` invokes the shared setup-rust composite, whose nested `setup-rust-toolchain` exports `RUSTFLAGS="-D warnings"` into the job environment when the variable is unset, shadowing `.cargo/config.toml` and stripping `-Zpolonius=next` from the release cargo build (E0499 at the tagged `POLONIUS(case-3)` site). Pre-set `RUSTFLAGS: -Zpolonius=next` at job level in `build-and-package.yml`, mirroring the fix already applied to the CI, coverage, and Netsukefile workflows: the setup action defers to an existing value, so every nested cargo invocation inherits the flag. A companion shared-actions change exposing a `rustflags` passthrough input is being submitted separately. Co-Authored-By: Claude Fable 5 --- .github/workflows/build-and-package.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/build-and-package.yml b/.github/workflows/build-and-package.yml index 673b20637..4eeecd19f 100644 --- a/.github/workflows/build-and-package.yml +++ b/.github/workflows/build-and-package.yml @@ -67,6 +67,11 @@ jobs: BIN_NAME: ${{ inputs['bin-name'] }} VERSION: ${{ inputs.version }} MAN_ARCH: ${{ inputs['package-arch'] != '' && inputs['package-arch'] || 'unknown' }} + # Pre-set RUSTFLAGS so the setup-rust-toolchain step nested inside + # rust-build-release does not export its "-D warnings" default, which + # would shadow .cargo/config.toml and strip -Zpolonius=next (see + # docs/adr-006-adopt-polonius-nightly-toolchain.md). + RUSTFLAGS: -Zpolonius=next steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 From 32f998d40d4608dc19932e74013371819317a432 Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 29 Jul 2026 14:54:31 +0200 Subject: [PATCH 13/21] Address review: checkout credentials and audit wording - Disable persisted checkout credentials in `coverage-main.yml` and `netsukefile-test.yml`, matching the hardened checkout in `ci.yml`; neither job pushes, so the cached token serves no purpose. - Clarify the validation contract in `docs/polonius.md`: the no-flag compile exists only to classify whether an individual change depends on Polonius, while the complete behavioural suite runs under `-Zpolonius=next`, the tree's only supported configuration. Co-Authored-By: Claude Fable 5 --- .github/workflows/coverage-main.yml | 2 ++ .github/workflows/netsukefile-test.yml | 2 ++ docs/polonius.md | 13 +++++++------ 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/.github/workflows/coverage-main.yml b/.github/workflows/coverage-main.yml index fbf27a91a..ddfba2129 100644 --- a/.github/workflows/coverage-main.yml +++ b/.github/workflows/coverage-main.yml @@ -25,6 +25,8 @@ jobs: RUSTFLAGS: -D warnings -Zpolonius=next steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Setup Rust uses: leynos/shared-actions/.github/actions/setup-rust@4d696e72fff6db49f34302ccf119ba978f1032c9 with: diff --git a/.github/workflows/netsukefile-test.yml b/.github/workflows/netsukefile-test.yml index 842cd494b..3121f3322 100644 --- a/.github/workflows/netsukefile-test.yml +++ b/.github/workflows/netsukefile-test.yml @@ -21,6 +21,8 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Setup Rust uses: leynos/shared-actions/.github/actions/setup-rust@4d696e72fff6db49f34302ccf119ba978f1032c9 with: diff --git a/docs/polonius.md b/docs/polonius.md index c34b5b15a..706a02fcd 100644 --- a/docs/polonius.md +++ b/docs/polonius.md @@ -21,12 +21,13 @@ the oracle: per-module clone hotspots. Every change was compiled twice on `nightly-2026-06-25`: once with -`-Zpolonius=next` (must pass) and once without (the outcome classifies the -change). A failure without the flag proves the design genuinely depends on -Polonius and the site is tagged `POLONIUS(...)`; success means the old form -was habit rather than necessity and the improvement carries no toolchain -caveat. Behavioural test suites were required to pass unchanged in either -case. +`-Zpolonius=next` (must pass) and once without. The no-flag compile exists +only to classify the individual change: a failure proves the design +genuinely depends on Polonius and the site is tagged `POLONIUS(...)`; +success means the old form was habit rather than necessity and the +improvement carries no toolchain caveat. The complete behavioural test +suite runs under `-Zpolonius=next` — the tree's only supported +configuration — and was required to pass unchanged after every change. ## Polonius-dependent sites From 6935b5fc98de478ca4d23ccfc24723bbfd447e87 Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 29 Jul 2026 14:57:17 +0200 Subject: [PATCH 14/21] Document explicit toolchain for registry installs The crates.io package excludes `rust-toolchain.toml` and `.cargo/config.toml` (and registry builds run outside a checkout, so Cargo would not apply them regardless), meaning a bare `cargo install netsuke` of a Polonius-dependent release would compile under the user's default toolchain and fail with E0499. Keeping the distributed source NLL-compatible would forfeit the borrow-centric design this branch exists to adopt, so document and pin the explicit install command instead: rustup toolchain install nightly-2026-06-25 RUSTFLAGS=-Zpolonius=next cargo +nightly-2026-06-25 install netsuke Update the tested-example blocks in the README and users' guide, the contract expectation in `documentation_examples_tests.rs`, the ADR-006 release consequence, the polonius harness notes, and the changelog. The users' guide also loses a stale "Rust 1.89 or later" prerequisite. Source installs from a checkout are unaffected: the pinned toolchain and workspace configuration apply there. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 6 ++++-- README.md | 7 +++++-- docs/adr-006-adopt-polonius-nightly-toolchain.md | 12 ++++++++++-- docs/polonius.md | 7 +++++++ docs/users-guide.md | 12 +++++++++--- tests/documentation_examples_tests.rs | 8 +++++++- 6 files changed, 42 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f5ea92b75..34642f62f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,10 @@ path only on insertion ([#465](https://github.com/leynos/netsuke/issues/465)) - Build with the Polonius borrow checker (`-Zpolonius=next`) on the pinned - `nightly-2026-06-25` toolchain; building from source now requires that - nightly, which `rustup` installs automatically + `nightly-2026-06-25` toolchain; checkout builds pick this up + automatically via `rustup`, while registry installs must pass the + toolchain and flag explicitly + (`RUSTFLAGS=-Zpolonius=next cargo +nightly-2026-06-25 install netsuke`) ([#465](https://github.com/leynos/netsuke/issues/465)) - Remove the `rust-version = "1.89.0"` minimum-supported-Rust-version declaration from `Cargo.toml`; `rust-toolchain.toml` is now the single diff --git a/README.md b/README.md index 157edc9e5..dc5f7281a 100644 --- a/README.md +++ b/README.md @@ -41,12 +41,15 @@ Netsuke currently requires: ### Installation -Netsuke v0.1.0 is available from crates.io: +Netsuke v0.1.0 is available from crates.io. Registry installs build outside a +repository checkout, so neither the pinned toolchain nor the Polonius flag is +picked up automatically; supply both explicitly: ```sh -cargo install netsuke +rustup toolchain install nightly-2026-06-25 +RUSTFLAGS=-Zpolonius=next cargo +nightly-2026-06-25 install netsuke ``` Pre-built installers are available from the diff --git a/docs/adr-006-adopt-polonius-nightly-toolchain.md b/docs/adr-006-adopt-polonius-nightly-toolchain.md index 854dd02db..de1374fc0 100644 --- a/docs/adr-006-adopt-polonius-nightly-toolchain.md +++ b/docs/adr-006-adopt-polonius-nightly-toolchain.md @@ -74,8 +74,16 @@ remains correct. ## Consequences -- Publishing to crates.io remains possible but installs require a nightly - compiler until Polonius stabilizes; the README documents this. +- Publishing to crates.io remains possible, but the packaged source excludes + `rust-toolchain.toml` and `.cargo/config.toml` (and Cargo would not apply + them to a registry build anyway), so a bare `cargo install netsuke` of a + Polonius-dependent release fails borrow checking on the user's default + toolchain. Registry installs must select the pinned nightly and pass the + flag explicitly + (`RUSTFLAGS=-Zpolonius=next cargo +nightly-2026-06-25 install netsuke`); + the README and users' guide document this command and a contract test pins + it. Source installs from a checkout are unaffected because the pinned + toolchain and workspace configuration apply there. - Release packaging builds from the pinned nightly. Binary artefacts are unaffected: the borrow checker changes what compiles, not what is generated. diff --git a/docs/polonius.md b/docs/polonius.md index 706a02fcd..5443e4e9a 100644 --- a/docs/polonius.md +++ b/docs/polonius.md @@ -111,6 +111,13 @@ Polonius flag or avoid compiling the crate: line of defence. `cargo-llvm-cov` appends its instrumentation flags to the ambient value, so coverage inherits the flag from the job environment. +- **Registry installs**: the crates.io package excludes + `rust-toolchain.toml` and `.cargo/config.toml`, and registry builds run + outside the checkout, so `cargo install netsuke` must select the pinned + nightly and pass the flag explicitly + (`RUSTFLAGS=-Zpolonius=next cargo +nightly-2026-06-25 install netsuke`). + The README and users' guide document the command and + `tests/documentation_examples_tests.rs` pins it. - **cargo-mutants** (scheduled, informational) runs through the shared `mutation-cargo.yml` workflow, which controls its own environment; if its runs regress with E0499 at tagged sites, the shared workflow needs the diff --git a/docs/users-guide.md b/docs/users-guide.md index fa188a4bf..78322a54d 100644 --- a/docs/users-guide.md +++ b/docs/users-guide.md @@ -11,14 +11,20 @@ change before 1.0. Pin the Netsuke version in automated workflows. ## Install Netsuke Netsuke requires [Ninja](https://ninja-build.org/) on `PATH`. A source build -also requires Rust 1.89 or later. +also requires the dated Rust nightly toolchain pinned in +`rust-toolchain.toml`, because Netsuke builds with the Polonius borrow +checker (`-Zpolonius=next`); `rustup` installs it automatically inside a +checkout. -Netsuke v0.1.0 is available from crates.io: +Netsuke v0.1.0 is available from crates.io. Registry installs build outside a +repository checkout, so neither the pinned toolchain nor the Polonius flag is +picked up automatically; supply both explicitly: ```sh -cargo install netsuke +rustup toolchain install nightly-2026-06-25 +RUSTFLAGS=-Zpolonius=next cargo +nightly-2026-06-25 install netsuke ``` Pre-built installers are available from the diff --git a/tests/documentation_examples_tests.rs b/tests/documentation_examples_tests.rs index b6618336c..256dca662 100644 --- a/tests/documentation_examples_tests.rs +++ b/tests/documentation_examples_tests.rs @@ -154,7 +154,13 @@ fn documented_first_run_flow_builds( fn installation_examples_match_source_and_release_contracts() -> Result<()> { let readme_release = documented_example("readme-crates-io-install")?; let guide_release = documented_example("guide-crates-io-install")?; - let expected_release = "cargo install netsuke\n"; + // Registry installs run outside a checkout, so the packaged source sees + // neither rust-toolchain.toml nor .cargo/config.toml; the documented + // command must select the pinned nightly and the Polonius flag itself. + let expected_release = concat!( + "rustup toolchain install nightly-2026-06-25\n", + "RUSTFLAGS=-Zpolonius=next cargo +nightly-2026-06-25 install netsuke\n" + ); ensure!(readme_release.body == expected_release, "README drifted"); ensure!(guide_release.body == expected_release, "user guide drifted"); let expected_release_details = [ From 166359a0e81bf1996655a72bbd7bb7bcaf8af435 Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 29 Jul 2026 23:14:14 +0200 Subject: [PATCH 15/21] Address review: config wording, edge helper, cwd duplication - Reword the developers' guide so `.cargo/config.toml` is described as supplying `-Zpolonius=next` by default rather than applying it to every Cargo invocation, keeping the existing note that an inherited `RUSTFLAGS` overrides the table and Makefile recipes re-state it via `POLONIUS_FLAGS`. - Extract the duplicated per-output `EdgeView` insertion in `EdgeRegistrar` into a shared `insert_edges` helper taking the source path and a class-producing closure; both callers keep their class selection and `all_outputs` iteration. - Stop `resolved_dirs` from listing the working directory twice under `CwdMode::Always`: the mode already prepends the working directory, so a current-directory `PATH` entry (an empty component) only duplicated it in search order and miss diagnostics. `CwdMode::Auto` behaviour is unchanged, and a regression test pins both modes. Co-Authored-By: Claude Fable 5 --- docs/developers-guide.md | 5 +++-- src/graph_view/mod.rs | 32 +++++++++++++-------------- src/stdlib/which/env.rs | 4 +++- src/stdlib/which/lookup/tests.rs | 38 ++++++++++++++++++++++++++++++++ 4 files changed, 60 insertions(+), 19 deletions(-) diff --git a/docs/developers-guide.md b/docs/developers-guide.md index 5352365ce..93b305334 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -82,8 +82,9 @@ construction. Netsuke builds on the dated nightly toolchain pinned in `rust-toolchain.toml` with the Polonius alpha borrow-checking analysis (`-Zpolonius=next`) enabled. `rustup` provisions the toolchain automatically, and `.cargo/config.toml` -applies the flag to every Cargo invocation, including rust-analyzer and -`cargo kani`. Makefile recipes that set `RUSTFLAGS` re-state the flag through +supplies the flag by default, covering Cargo invocations such as +rust-analyzer and `cargo kani` that run without `RUSTFLAGS` in the +environment. Makefile recipes that set `RUSTFLAGS` re-state the flag through the `POLONIUS_FLAGS` variable because an inherited `RUSTFLAGS` environment variable overrides `.cargo/config.toml`. diff --git a/src/graph_view/mod.rs b/src/graph_view/mod.rs index c6b237ebb..2cc9a2311 100644 --- a/src/graph_view/mod.rs +++ b/src/graph_view/mod.rs @@ -265,31 +265,31 @@ impl EdgeRegistrar<'_> { /// Add one edge from `input` to every output, classified by whether the /// output is implicit. fn register_input_edges(&mut self, input: &Utf8PathBuf, implicit: &BTreeSet<&Utf8PathBuf>) { - for out in all_outputs(self.edge) { - let class = if implicit.contains(out) { + self.insert_edges(input, |out| { + if implicit.contains(out) { EdgeClass::ImplicitOutput } else { EdgeClass::Explicit - }; - self.edges.insert(EdgeView { - from: input.clone(), - to: out.clone(), - class, - }); - } + } + }); } /// Register `deps` as source nodes with a `class` edge to every output. fn register_dependencies(&mut self, deps: &[Utf8PathBuf], class: EdgeClass) { for dep in deps { self.registry.ensure_node_mut(dep); - for out in all_outputs(self.edge) { - self.edges.insert(EdgeView { - from: dep.clone(), - to: out.clone(), - class, - }); - } + self.insert_edges(dep, |_| class); + } + } + + /// Insert one edge from `from` to every output, classified by `class_for`. + fn insert_edges(&mut self, from: &Utf8PathBuf, class_for: impl Fn(&Utf8PathBuf) -> EdgeClass) { + for out in all_outputs(self.edge) { + self.edges.insert(EdgeView { + from: from.clone(), + to: out.clone(), + class: class_for(out), + }); } } } diff --git a/src/stdlib/which/env.rs b/src/stdlib/which/env.rs index 99946296f..da7196dd4 100644 --- a/src/stdlib/which/env.rs +++ b/src/stdlib/which/env.rs @@ -64,7 +64,9 @@ impl EnvSnapshot { for entry in &self.entries { match entry { PathEntry::Dir(path) => dirs.push(path.as_path()), - PathEntry::CurrentDir if matches!(mode, CwdMode::Always | CwdMode::Auto) => { + // `Always` has already prepended the working directory, so a + // current-directory PATH entry would only duplicate it. + PathEntry::CurrentDir if matches!(mode, CwdMode::Auto) => { dirs.push(self.cwd.as_path()); } PathEntry::CurrentDir => {} diff --git a/src/stdlib/which/lookup/tests.rs b/src/stdlib/which/lookup/tests.rs index bf333017d..d10dc5f15 100644 --- a/src/stdlib/which/lookup/tests.rs +++ b/src/stdlib/which/lookup/tests.rs @@ -309,3 +309,41 @@ fn resolve_direct_appends_pathext(workspace: Result) -> Result<() ); Ok(()) } + +#[rstest] +fn cwd_always_lists_current_directory_once( + #[from(workspace)] workspace_res: Result, +) -> Result<()> { + let workspace = workspace_res?; + let bin = workspace.root().join("bin"); + test_fs::create_dir_all(bin.as_std_path()).context("mkdir bin")?; + + // The leading empty component parses as a current-directory PATH entry. + let path_value = std::env::join_paths([std::path::Path::new(""), bin.as_std_path()]) + .context("join PATH entries")?; + let snapshot = EnvSnapshot::capture(Some(workspace.root()), Some(path_value.as_os_str())) + .context("capture env with a current-directory PATH entry")?; + + let always_dirs = snapshot.resolved_dirs(CwdMode::Always); + let always_cwd_count = always_dirs + .iter() + .filter(|dir| **dir == snapshot.cwd) + .count(); + ensure!( + always_cwd_count == 1, + "Always should list the working directory exactly once: {always_dirs:?}" + ); + ensure!( + always_dirs.first() == Some(&snapshot.cwd.as_path()), + "Always should search the working directory first: {always_dirs:?}" + ); + + let auto_dirs = snapshot.resolved_dirs(CwdMode::Auto); + let auto_cwd_count = auto_dirs.iter().filter(|dir| **dir == snapshot.cwd).count(); + ensure!( + auto_cwd_count == 1, + "Auto should honour the PATH-listed working directory once: {auto_dirs:?}" + ); + + Ok(()) +} From 257283443a9ceb4d970abe6d1f5f72d2b889cec2 Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 29 Jul 2026 23:14:26 +0200 Subject: [PATCH 16/21] Split Windows setup checks out of install contract test `installation_examples_match_source_and_release_contracts` reached 71 lines after gaining the registry-install expectations, tripping CodeScene's large-method threshold. Move the Windows help, PATH, and staging assertions into an `assert_windows_setup_examples` helper; the assertions themselves are unchanged. Co-Authored-By: Claude Fable 5 --- tests/documentation_examples_tests.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/documentation_examples_tests.rs b/tests/documentation_examples_tests.rs index 256dca662..6a858eab0 100644 --- a/tests/documentation_examples_tests.rs +++ b/tests/documentation_examples_tests.rs @@ -190,6 +190,11 @@ fn installation_examples_match_source_and_release_contracts() -> Result<()> { ); ensure!(readme.body == expected, "README source install drifted"); ensure!(guide.body == expected, "user guide source install drifted"); + assert_windows_setup_examples() +} + +/// Check the documented Windows help, PATH, and staging contracts. +fn assert_windows_setup_examples() -> Result<()> { let windows = documented_example("guide-windows-help")?; ensure!(windows.body == "Get-Help Netsuke -Full\n", "help drifted"); let windows_path = documented_example("guide-windows-path")?; From 9aecea5851b14373189a87d7e9ea0be0789b0d68 Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 29 Jul 2026 23:16:32 +0200 Subject: [PATCH 17/21] Extract release-install checks from install contract test Move the crates.io install-command and release-detail assertions into a file-local `assert_release_installation_contract` helper, called first from `installation_examples_match_source_and_release_contracts`, resolving the CodeScene large-method finding. Expected strings, error messages, and assertions are unchanged; the source-install checks stay in the test and the Windows checks remain in the existing helper. Co-Authored-By: Claude Fable 5 --- tests/documentation_examples_tests.rs | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/tests/documentation_examples_tests.rs b/tests/documentation_examples_tests.rs index 6a858eab0..c33bee11a 100644 --- a/tests/documentation_examples_tests.rs +++ b/tests/documentation_examples_tests.rs @@ -152,6 +152,21 @@ fn documented_first_run_flow_builds( #[test] fn installation_examples_match_source_and_release_contracts() -> Result<()> { + assert_release_installation_contract()?; + let readme = documented_example("readme-source-install")?; + let guide = documented_example("guide-source-install")?; + let expected = concat!( + "git clone https://github.com/leynos/netsuke.git\n", + "cd netsuke\n", + "cargo install --path .\n" + ); + ensure!(readme.body == expected, "README source install drifted"); + ensure!(guide.body == expected, "user guide source install drifted"); + assert_windows_setup_examples() +} + +/// Check the documented crates.io install command and release details. +fn assert_release_installation_contract() -> Result<()> { let readme_release = documented_example("readme-crates-io-install")?; let guide_release = documented_example("guide-crates-io-install")?; // Registry installs run outside a checkout, so the packaged source sees @@ -181,16 +196,7 @@ fn installation_examples_match_source_and_release_contracts() -> Result<()> { ); } } - let readme = documented_example("readme-source-install")?; - let guide = documented_example("guide-source-install")?; - let expected = concat!( - "git clone https://github.com/leynos/netsuke.git\n", - "cd netsuke\n", - "cargo install --path .\n" - ); - ensure!(readme.body == expected, "README source install drifted"); - ensure!(guide.body == expected, "user guide source install drifted"); - assert_windows_setup_examples() + Ok(()) } /// Check the documented Windows help, PATH, and staging contracts. From e70cafe4d8e2c2b79221810e6f372c8be223e641 Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 29 Jul 2026 23:19:05 +0200 Subject: [PATCH 18/21] Route quickstart installs through the users' guide The quickstart still suggested a bare `cargo install netsuke`, which registry builds cannot satisfy: the crates.io package carries neither `rust-toolchain.toml` nor `.cargo/config.toml`, and the tree needs `-Zpolonius=next`. Point the prerequisite at the checkout-scoped source build and the canonical registry-install command in the users' guide instead. Add a contract test requiring every tagged registry-install example to select `cargo +nightly-2026-06-25` and pass `RUSTFLAGS=-Zpolonius=next` (checkout-scoped `cargo install --path .` examples are exempt), and guarding the quickstart prose against reintroducing a bare registry install. Co-Authored-By: Claude Fable 5 --- docs/quickstart.md | 7 +++-- tests/documentation_examples_tests.rs | 41 +++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/docs/quickstart.md b/docs/quickstart.md index 949b8c7df..919ab46bf 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -7,8 +7,11 @@ minutes. Before beginning, ensure the following are available: -- **Netsuke** installed (build from source with `cargo build --release` or - install via `cargo install netsuke`) +- **Netsuke** installed — build from source inside a repository checkout with + `cargo build --release`, or follow the registry-install command in the + [users' guide](users-guide.md#install-netsuke). A bare `cargo install` is + unsupported: registry builds need the pinned nightly toolchain and the + Polonius borrow-checker flag supplied explicitly, as the guide shows. - **Ninja** build tool in the system PATH (install via the package manager, e.g., `apt install ninja-build` or `brew install ninja`) diff --git a/tests/documentation_examples_tests.rs b/tests/documentation_examples_tests.rs index c33bee11a..a42acf939 100644 --- a/tests/documentation_examples_tests.rs +++ b/tests/documentation_examples_tests.rs @@ -165,6 +165,47 @@ fn installation_examples_match_source_and_release_contracts() -> Result<()> { assert_windows_setup_examples() } +#[test] +fn registry_install_examples_pin_toolchain_and_polonius() -> Result<()> { + // Registry installs build outside a checkout, where neither + // rust-toolchain.toml nor .cargo/config.toml applies, so every tagged + // example installing from crates.io must select the pinned nightly and + // pass the Polonius flag itself. `cargo install --path .` examples run + // inside a checkout and are exempt. + let mut registry_install_ids = Vec::new(); + for example in load_documented_examples()? { + if !example.body.contains("install netsuke") { + continue; + } + ensure!( + example + .body + .contains("cargo +nightly-2026-06-25 install netsuke"), + "{id} must install with the pinned nightly toolchain", + id = example.id + ); + ensure!( + example.body.contains("RUSTFLAGS=-Zpolonius=next"), + "{id} must pass the Polonius borrow-checker flag", + id = example.id + ); + registry_install_ids.push(example.id); + } + ensure!( + registry_install_ids.len() >= 2, + "expected registry-install examples in the README and users' guide, found {registry_install_ids:?}" + ); + // The quickstart carries no tested-example fences, so guard its prose + // against reintroducing an unsupported bare registry install. + let quickstart = + test_fs::read_to_string("docs/quickstart.md").context("read docs/quickstart.md")?; + ensure!( + !quickstart.contains("cargo install netsuke"), + "docs/quickstart.md must defer to the users' guide install command" + ); + Ok(()) +} + /// Check the documented crates.io install command and release details. fn assert_release_installation_contract() -> Result<()> { let readme_release = documented_example("readme-crates-io-install")?; From f00feef181ce28c7a106c8e18ff12b75f4fe226d Mon Sep 17 00:00:00 2001 From: leynos Date: Thu, 30 Jul 2026 11:25:09 +0200 Subject: [PATCH 19/21] Collapse double blank line left by rebase merge The weave-assisted rebase left two consecutive blank lines ahead of the "Toolchain and borrow checker" heading in the developers' guide, failing markdownlint MD012. Co-Authored-By: Claude Fable 5 --- docs/developers-guide.md | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/developers-guide.md b/docs/developers-guide.md index 93b305334..449fca48c 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -76,7 +76,6 @@ they are per-invocation arguments tagged `#[serde(skip)]` on would silently change the artefact destination — a footgun the design avoids by construction. - ## Toolchain and borrow checker Netsuke builds on the dated nightly toolchain pinned in `rust-toolchain.toml` From 7618f5cbe3145e84e05eaceabf0abd7e36846df7 Mon Sep 17 00:00:00 2001 From: leynos Date: Thu, 30 Jul 2026 11:28:27 +0200 Subject: [PATCH 20/21] Collapse double blank line from rebase merge The weave-assisted rebase again left two consecutive blank lines in the developers' guide, this time ahead of the "Temporary executable test helpers" section that main introduced, failing markdownlint MD012. Co-Authored-By: Claude Fable 5 --- docs/developers-guide.md | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/developers-guide.md b/docs/developers-guide.md index 449fca48c..35a598422 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -496,7 +496,6 @@ packaged crate builds successfully for release. It then uses build-script sources, including `build_l10n_audit.rs`, and rejects stale `ninja_env/` paths. - ### Temporary executable test helpers The low-level executable-stub primitive is owned by From dd61f5fd514e4a888910d3a65026b0942ab822e1 Mon Sep 17 00:00:00 2001 From: leynos Date: Fri, 31 Jul 2026 00:36:46 +0200 Subject: [PATCH 21/21] Recommend cargo binstall for prebuilt registry installs Prebuilt release binaries sidestep the pinned-nightly and Polonius requirements entirely, so recommend `cargo binstall netsuke` as the preferred registry path where cargo-binstall is available. The README and users' guide gain a tagged `binstall` example ahead of the pinned-toolchain source command, and the quickstart prerequisite mentions the option alongside its users'-guide pointer. Extend the install contracts: the new fences are pinned by equality in `assert_release_installation_contract`, and the registry-install rule now checks per line so `cargo binstall netsuke` is exempt while any non-binstall `install netsuke` line must carry both `cargo +nightly-2026-06-25` and `RUSTFLAGS=-Zpolonius=next`. Co-Authored-By: Claude Fable 5 --- README.md | 17 ++++++++-- docs/quickstart.md | 5 ++- docs/users-guide.md | 17 ++++++++-- tests/documentation_examples_tests.rs | 48 +++++++++++++++++---------- 4 files changed, 63 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index dc5f7281a..18350b36e 100644 --- a/README.md +++ b/README.md @@ -41,9 +41,20 @@ Netsuke currently requires: ### Installation -Netsuke v0.1.0 is available from crates.io. Registry installs build outside a -repository checkout, so neither the pinned toolchain nor the Polonius flag is -picked up automatically; supply both explicitly: +Netsuke v0.1.0 is available from crates.io. Where +[`cargo binstall`](https://github.com/cargo-bins/cargo-binstall) is +available, prefer it: it fetches a prebuilt release binary and avoids the +toolchain requirement below. + + + +```sh +cargo binstall netsuke +``` + +Building from the registry instead runs outside a repository checkout, so +neither the pinned toolchain nor the Polonius flag is picked up +automatically; supply both explicitly: diff --git a/docs/quickstart.md b/docs/quickstart.md index 919ab46bf..d8a2af8e6 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -7,7 +7,10 @@ minutes. Before beginning, ensure the following are available: -- **Netsuke** installed — build from source inside a repository checkout with +- **Netsuke** installed — install a prebuilt release binary with + `cargo binstall netsuke` where + [`cargo binstall`](https://github.com/cargo-bins/cargo-binstall) is + available, build from source inside a repository checkout with `cargo build --release`, or follow the registry-install command in the [users' guide](users-guide.md#install-netsuke). A bare `cargo install` is unsupported: registry builds need the pinned nightly toolchain and the diff --git a/docs/users-guide.md b/docs/users-guide.md index 78322a54d..3166cc08c 100644 --- a/docs/users-guide.md +++ b/docs/users-guide.md @@ -16,9 +16,20 @@ also requires the dated Rust nightly toolchain pinned in checker (`-Zpolonius=next`); `rustup` installs it automatically inside a checkout. -Netsuke v0.1.0 is available from crates.io. Registry installs build outside a -repository checkout, so neither the pinned toolchain nor the Polonius flag is -picked up automatically; supply both explicitly: +Netsuke v0.1.0 is available from crates.io. Where +[`cargo binstall`](https://github.com/cargo-bins/cargo-binstall) is +available, prefer it: it fetches a prebuilt release binary and avoids the +toolchain requirement below. + + + +```sh +cargo binstall netsuke +``` + +Building from the registry instead runs outside a repository checkout, so +neither the pinned toolchain nor the Polonius flag is picked up +automatically; supply both explicitly: diff --git a/tests/documentation_examples_tests.rs b/tests/documentation_examples_tests.rs index a42acf939..513020500 100644 --- a/tests/documentation_examples_tests.rs +++ b/tests/documentation_examples_tests.rs @@ -18,6 +18,7 @@ use test_support::netsuke::{NetsukeRun, run_netsuke_in}; const EXPECTED_EXAMPLE_IDS: &[&str] = &[ "guide-accessible-output", + "guide-binstall-install", "guide-cli-usage", "guide-command-available-manifest", "guide-complete-manifest", @@ -36,6 +37,7 @@ const EXPECTED_EXAMPLE_IDS: &[&str] = &[ "guide-windows-help", "guide-windows-help-install", "guide-windows-path", + "readme-binstall-install", "readme-crates-io-install", "readme-first-build-commands", "readme-first-build-manifest", @@ -170,26 +172,27 @@ fn registry_install_examples_pin_toolchain_and_polonius() -> Result<()> { // Registry installs build outside a checkout, where neither // rust-toolchain.toml nor .cargo/config.toml applies, so every tagged // example installing from crates.io must select the pinned nightly and - // pass the Polonius flag itself. `cargo install --path .` examples run - // inside a checkout and are exempt. + // pass the Polonius flag itself. `cargo binstall` fetches a prebuilt + // binary and `cargo install --path .` runs inside a checkout, so both + // are exempt. let mut registry_install_ids = Vec::new(); for example in load_documented_examples()? { - if !example.body.contains("install netsuke") { - continue; + for line in example.body.lines() { + if !line.contains("install netsuke") || line.contains("binstall") { + continue; + } + ensure!( + line.contains("cargo +nightly-2026-06-25 install netsuke"), + "{id} must install with the pinned nightly toolchain: {line}", + id = example.id + ); + ensure!( + line.contains("RUSTFLAGS=-Zpolonius=next"), + "{id} must pass the Polonius borrow-checker flag: {line}", + id = example.id + ); + registry_install_ids.push(example.id.clone()); } - ensure!( - example - .body - .contains("cargo +nightly-2026-06-25 install netsuke"), - "{id} must install with the pinned nightly toolchain", - id = example.id - ); - ensure!( - example.body.contains("RUSTFLAGS=-Zpolonius=next"), - "{id} must pass the Polonius borrow-checker flag", - id = example.id - ); - registry_install_ids.push(example.id); } ensure!( registry_install_ids.len() >= 2, @@ -208,6 +211,17 @@ fn registry_install_examples_pin_toolchain_and_polonius() -> Result<()> { /// Check the documented crates.io install command and release details. fn assert_release_installation_contract() -> Result<()> { + let readme_binstall = documented_example("readme-binstall-install")?; + let guide_binstall = documented_example("guide-binstall-install")?; + let expected_binstall = "cargo binstall netsuke\n"; + ensure!( + readme_binstall.body == expected_binstall, + "README binstall drifted" + ); + ensure!( + guide_binstall.body == expected_binstall, + "user guide binstall drifted" + ); let readme_release = documented_example("readme-crates-io-install")?; let guide_release = documented_example("guide-crates-io-install")?; // Registry installs run outside a checkout, so the packaged source sees