diff --git a/.gitignore b/.gitignore index 40b6baa..2475a88 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ __pycache__/ # Untracked cache of the estate-wide en-GB-oxendict dictionary. .typos-oxendict-base.json .typos-oxendict-base.toml +.grepai/ diff --git a/README.md b/README.md index 6883468..186bfc6 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,9 @@ The template requires **Copier 9.0** or later to avoid incompatibilities. enabled【F:template/Cargo.toml†L1-L9】. - **Pinned toolchain** file specifying a configurable nightly release 【F:template/rust-toolchain.toml.jinja†L1-L3】. +- **Optional Polonius support**, recommended and enabled by default for + applications, with coherent Cargo, Makefile, coverage, release, and agent + guidance that can be disabled for wider library compiler compatibility. - **Project metadata prompts** for repository URL, homepage, crates.io keywords, crates.io categories, nightly date, and optional Linux development target. - **Fast generated tooling** including Cranelift debug code generation, Linux @@ -115,6 +118,7 @@ flowchart LR Additional details are in [`docs/testing.md`](docs/testing.md). User-facing generated-project behaviour is documented in -[`docs/users-guide.md`](docs/users-guide.md). Parent-template development -requirements are documented in +[`docs/users-guide.md`](docs/users-guide.md), with upgrade guidance in the +[`0.2.0 migration guide`](docs/migrations/0.2.0.md). Parent-template +development requirements are documented in [`docs/developers-guide.md`](docs/developers-guide.md). diff --git a/copier.yaml b/copier.yaml index a101d8f..0cc9926 100644 --- a/copier.yaml +++ b/copier.yaml @@ -73,6 +73,14 @@ flavour: default: lib help: 'What type of project? (lib = reusable library, app = executable binary)' +enable_polonius: + type: bool + default: "{{ flavour == 'app' }}" + help: >- + Enable the nightly Polonius borrow checker (-Zpolonius=next)? Recommended + for applications; libraries may disable it to retain wider compiler + compatibility. + codescene_project_id: type: str default: '' @@ -105,6 +113,10 @@ rust_nightly_date: default: '2025-06-10' help: 'Rust nightly toolchain date (YYYY-MM-DD)' placeholder: 'e.g. 2025-06-10' + validator: >- + {% if not (rust_nightly_date | regex_search('^[0-9]{4}-[0-9]{2}-[0-9]{2}\\Z')) %} + Rust nightly date must use YYYY-MM-DD. + {% endif %} dev_target: type: str diff --git a/docs/adr-004-optional-polonius-support.md b/docs/adr-004-optional-polonius-support.md new file mode 100644 index 0000000..b59297b --- /dev/null +++ b/docs/adr-004-optional-polonius-support.md @@ -0,0 +1,52 @@ +# ADR-004: Make Polonius support optional + +## Status + +Accepted. + +## Context + +Polonius permits borrow-centric internal APIs that the stable non-lexical +lifetime analysis rejects, but its alpha analysis still requires a dated +nightly toolchain and `-Zpolonius=next`. Applications can usually accept that +binding, while reusable libraries often need wider compiler compatibility. +Cargo build flags are also replaced whenever tooling supplies `RUSTFLAGS`, so +an enabled project must preserve the Polonius flag across every such build +surface. + +## Decision + +An `enable_polonius` Copier choice defaults to enabled for applications and +disabled for libraries, with either default available for explicit override. +Enabled projects propagate `-Zpolonius=next` through Cargo, Make, coverage, +Continuous Integration (CI) and release builds. This consistent propagation +establishes a single, explicit toolchain contract across every supported build +surface. Enabled projects require a dated nightly toolchain, and every explicit +`RUSTFLAGS` value must remain synchronized with the configured Polonius flag. + +## Progress and outcome + +The template now renders the option, pinned toolchain, compiler flags, policy +guidance, CI and release configuration for the selected state. Parent-template +contract and compilation tests cover enabled and disabled applications and +libraries. The implementation and its validation are complete; future +generated projects retain an explicit opt-out or opt-in as their compatibility +requirements change. + +## Risks + +- A pinned nightly can become unavailable or acquire regressions and therefore + requires deliberate upgrades. +- A new build path that overrides `RUSTFLAGS` can silently omit Polonius unless + its rendered contract is extended and tested. +- Enabling borrow-centric APIs can make source builds fail under stable Rust or + plain nightly without `-Zpolonius=next`. + +## Consequences + +- Generated applications adopt Polonius by default; generated libraries keep + wider compiler compatibility by default. +- Opted-in projects document their nightly requirement and preserve the flag + across supported build paths. +- Maintainers must treat the Copier answer, dated channel, and explicit flag + propagation as one toolchain policy when updating generated projects. diff --git a/docs/developers-guide.md b/docs/developers-guide.md index bc91a57..7599b9d 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -19,9 +19,11 @@ so Python test dependencies must be added to that invocation before tests import them. Keep long runs logged through `tee` into `/tmp`, following the example in `AGENTS.md`. -The tests render both library and application projects, run generated public -gates such as `make all`, validate generated Makefiles with `mbake`, and parse -generated `Cargo.toml` files as TOML. +The tests render both library and application projects with Polonius enabled +and disabled, run generated public gates such as `make all`, validate generated +Makefiles with `mbake`, and parse generated Cargo and workflow configuration. +The Polonius contract checks every `RUSTFLAGS` override, including Linux mold +linking, LLVM coverage, and cross-platform application releases. ## Formatting, Linting, and Type Checking diff --git a/docs/execplans/rust-project-enhancements.md b/docs/execplans/rust-project-enhancements.md index d7f8f1a..8c0327c 100644 --- a/docs/execplans/rust-project-enhancements.md +++ b/docs/execplans/rust-project-enhancements.md @@ -66,6 +66,12 @@ Pinned GitHub Action SHAs drift over time. The mitigation is to resolve the current default-branch commit for each action repository during implementation and record the resulting pins in the plan and pull request validation notes. +Polonius remains a nightly-only alpha analysis, and explicit `RUSTFLAGS` +replace Cargo's build-level flags. Generated projects that opt in therefore +depend on a dated nightly, and every build path that overrides `RUSTFLAGS` must +re-state `-Zpolonius=next`. Contract tests cover the supported override paths; +new paths must extend those tests. + ## Progress - [x] 2026-05-23: Confirmed branch `rust-project-enhancements` and clean @@ -107,6 +113,11 @@ and record the resulting pins in the plan and pull request validation notes. was 9 passed in 18.54 seconds. - [x] 2026-05-23: Pushed `rust-project-enhancements` and created draft pull request . +- [x] 2026-08-02: Extended the completed tooling import with an optional + `enable_polonius` choice, application-recommended and library-compatible + defaults, flag propagation across Cargo, Make, coverage, CI, and release + builds, generated policy guidance, and enabled/disabled contract and + compilation coverage. Recorded the extension in ADR-004. ## Surprises & Discoveries @@ -140,6 +151,12 @@ Validate generated projects through pytest-copier by invoking the generated public `make all` target rather than stitching together private commands from the parent repository. +Expose Polonius as a Copier choice that defaults on for applications and off +for libraries. When enabled, use the dated channel from `rust-toolchain.toml` +and preserve `-Zpolonius=next` wherever `RUSTFLAGS` are replaced; this permits +borrow-centric application internals without imposing nightly compatibility on +generated libraries. ADR-004 records the alternatives and accepted risks. + ## Outcomes & Retrospective The template now renders projects with Cranelift debug codegen, Linux mold @@ -148,3 +165,9 @@ cargo-binstall metadata for app projects, Whitaker linting with CI caching, SHA-pinned CI actions, and pytest-copier coverage that runs generated `make all` gates. The branch was pushed and draft pull request was opened for review. + +The later Polonius extension is also complete. Generated applications now opt +in by default, libraries retain wider compiler compatibility by default, and +all supported flag-override paths preserve the selected compiler contract. +Rendered-project tests exercise both choices, and generated documentation +explains the nightly dependency and borrow-centric design policy. diff --git a/docs/migrations/0.2.0.md b/docs/migrations/0.2.0.md new file mode 100644 index 0000000..9809460 --- /dev/null +++ b/docs/migrations/0.2.0.md @@ -0,0 +1,27 @@ +# Migrating to 0.2.0 + +Version 0.2.0 adds the `enable_polonius` Copier prompt. The prompt controls +whether generated projects use the nightly Polonius borrow-checking analysis +through `-Zpolonius=next`. + +## Update an existing project + +1. Commit or otherwise preserve local changes before updating. +2. Run `copier update` using the same answers file and template source as the + existing project. +3. Review the new `enable_polonius` answer before accepting the rendered + changes. Applications default to enabled; libraries default to disabled. + Either value can be overridden explicitly. +4. Review changes to the Rust toolchain, Cargo configuration, Makefile, + coverage and release workflows as one toolchain-policy update. +5. Run the generated project's full quality gates after resolving Copier + conflicts. + +Enabling Polonius binds the generated project to its configured dated nightly +toolchain. Every build path that sets `RUSTFLAGS` explicitly must include +`-Zpolonius=next` because an explicit value replaces Cargo's build-level +flags. Disabling Polonius retains the wider compiler compatibility expected by +libraries, although the template still uses its configured nightly toolchain. + +The rationale and accepted constraints are recorded in +[ADR-004](../adr-004-optional-polonius-support.md). diff --git a/docs/users-guide.md b/docs/users-guide.md index b11c596..0f051d7 100644 --- a/docs/users-guide.md +++ b/docs/users-guide.md @@ -11,6 +11,12 @@ metadata used in the generated `Cargo.toml`: - `flavour` selects `lib` or `app` and determines the generated structure and release metadata. +- `enable_polonius` enables the nightly Polonius borrow checker + (`-Zpolonius=next`). It defaults to enabled for applications, where + borrow-centric internal APIs can evolve with the project, and disabled for + libraries, which commonly need wider compiler compatibility. Either default + can be overridden. Existing projects should follow the + [0.2.0 migration guide](migrations/0.2.0.md) when adopting this prompt. - `package_description` becomes `[package].description`. - `repository_url` becomes `[package].repository` and is used by generated app projects for cargo-binstall release URLs. @@ -35,6 +41,13 @@ settings, and documented starter code. Library projects render `src/lib.rs`. Application projects render `src/main.rs`, `src/lib.rs`, release automation, and `[package.metadata.binstall]` metadata for binary installation. +When Polonius is enabled, the generated Cargo configuration, Makefile, coverage +workflows, and application release workflow preserve `-Zpolonius=next`. The +Linux target-specific `mold` flags repeat it because Cargo selects target +rustflags instead of merging them with `[build].rustflags`. The generated +project also includes `docs/polonius.md` and matching `AGENTS.md` guidance for +borrow-centric APIs. + Development builds use Cranelift for debug code generation. On Linux targets, `.cargo/config.toml` configures clang to link with `mold` so local debug builds link quickly. Coverage generation uses `lld` instead because LLVM coverage diff --git a/template/.cargo/config.toml.jinja b/template/.cargo/config.toml.jinja index b9d65a4..c94bc83 100644 --- a/template/.cargo/config.toml.jinja +++ b/template/.cargo/config.toml.jinja @@ -1,3 +1,12 @@ +{% if enable_polonius -%} +# Enable the Polonius alpha borrow-checking analysis by default so Cargo, +# rust-analyzer, and verification tools agree about what borrows are legal. +# An inherited RUSTFLAGS value overrides this table; the generated Makefile and +# workflows therefore re-state -Zpolonius=next whenever they set RUSTFLAGS. +[build] +rustflags = ["-Zpolonius=next"] +rustdocflags = ["-Zpolonius=next"] +{% endif -%} [unstable] codegen-backend = true @@ -7,7 +16,11 @@ codegen-backend = "cranelift" {% if dev_target and 'linux' in dev_target -%} [target.{{ dev_target }}] linker = "clang" -rustflags = ["-C", "link-arg=-fuse-ld=mold"] +{% if enable_polonius -%} +# Cargo selects target rustflags instead of merging them with build.rustflags, +# so this target-specific mold configuration must repeat the Polonius flag. +{% endif -%} +rustflags = [{% if enable_polonius %}"-Zpolonius=next", {% endif %}"-C", "link-arg=-fuse-ld=mold"] {% elif dev_target -%} # mold is Linux-only. For faster linking on {{ dev_target }}, add a # platform-specific block here. Common options include lld via clang on macOS diff --git a/template/.github/workflows/ci.yml.jinja b/template/.github/workflows/ci.yml.jinja index 07cdd37..7a9f371 100644 --- a/template/.github/workflows/ci.yml.jinja +++ b/template/.github/workflows/ci.yml.jinja @@ -95,14 +95,14 @@ jobs: - name: Log coverage linker configuration run: | echo "Coverage linker: clang" - echo "Coverage RUSTFLAGS: -C link-arg=-fuse-ld=lld" + echo "Coverage RUSTFLAGS:{% endraw %}{% if enable_polonius %} -Zpolonius=next{% endif %}{% raw %} -C link-arg=-fuse-ld=lld" echo "Coverage CFLAGS: -fuse-ld=lld" echo "Coverage LDFLAGS: -fuse-ld=lld" - name: Test and Measure Coverage uses: leynos/shared-actions/.github/actions/generate-coverage@18bed1ca49a6de3d8882bd72635a32ae3f023d57 env: CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER: clang - RUSTFLAGS: -C link-arg=-fuse-ld=lld + RUSTFLAGS:{% endraw %}{% if enable_polonius %} -Zpolonius=next{% endif %}{% raw %} -C link-arg=-fuse-ld=lld CFLAGS: -fuse-ld=lld LDFLAGS: -fuse-ld=lld with: diff --git a/template/.github/workflows/coverage-main.yml b/template/.github/workflows/coverage-main.yml.jinja similarity index 90% rename from template/.github/workflows/coverage-main.yml rename to template/.github/workflows/coverage-main.yml.jinja index cd28b75..c3ede3f 100644 --- a/template/.github/workflows/coverage-main.yml +++ b/template/.github/workflows/coverage-main.yml.jinja @@ -1,4 +1,4 @@ -name: Coverage (main) +{% raw %}name: Coverage (main) # CodeScene accepts `cs-coverage upload` only for analysed branches, so # main-branch coverage is uploaded here on push; pull requests generate @@ -48,14 +48,14 @@ jobs: - name: Log coverage linker configuration run: | echo "Coverage linker: clang" - echo "Coverage RUSTFLAGS: -C link-arg=-fuse-ld=lld" + echo "Coverage RUSTFLAGS:{% endraw %}{% if enable_polonius %} -Zpolonius=next{% endif %}{% raw %} -C link-arg=-fuse-ld=lld" echo "Coverage CFLAGS: -fuse-ld=lld" echo "Coverage LDFLAGS: -fuse-ld=lld" - name: Test and Measure Coverage uses: leynos/shared-actions/.github/actions/generate-coverage@18bed1ca49a6de3d8882bd72635a32ae3f023d57 env: CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER: clang - RUSTFLAGS: -C link-arg=-fuse-ld=lld + RUSTFLAGS:{% endraw %}{% if enable_polonius %} -Zpolonius=next{% endif %}{% raw %} -C link-arg=-fuse-ld=lld CFLAGS: -fuse-ld=lld LDFLAGS: -fuse-ld=lld with: @@ -71,3 +71,4 @@ jobs: format: lcov access-token: ${{ env.CS_ACCESS_TOKEN }} installer-checksum: ${{ vars.CODESCENE_CLI_SHA256 }} +{% endraw %} diff --git a/template/.github/workflows/{% if flavour == 'app' %}release.yml{% endif %}.jinja b/template/.github/workflows/{% if flavour == 'app' %}release.yml{% endif %}.jinja index 27d4050..1fb3654 100644 --- a/template/.github/workflows/{% if flavour == 'app' %}release.yml{% endif %}.jinja +++ b/template/.github/workflows/{% if flavour == 'app' %}release.yml{% endif %}.jinja @@ -47,7 +47,7 @@ jobs: persist-credentials: false - uses: leynos/shared-actions/.github/actions/setup-rust@18bed1ca49a6de3d8882bd72635a32ae3f023d57 with: - toolchain: stable + toolchain: {% endraw %}{% if enable_polonius %}nightly-{{ rust_nightly_date }}{% else %}stable{% endif %}{% raw %} - name: Cache cross binary id: cache-cross uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae @@ -74,13 +74,11 @@ jobs: restore-keys: | ${{ runner.os }}-cargo-${{ matrix.target }}- - name: Build release binary - # Use +stable to override rust-toolchain.toml (which specifies nightly - # with Cranelift for development) and ensure release builds use stable. + # Select the generated project's supported compiler explicitly and + # omit repository-local linker and Cranelift configuration. env: - # Build release artifacts without repository-local linker flags, - # including mold rustflags from .cargo/config.toml. - RUSTFLAGS: "" - run: cross +stable build --release --target ${{ matrix.target }} + RUSTFLAGS: {% endraw %}{% if enable_polonius %}-Zpolonius=next{% else %}""{% endif %}{% raw %} + run: cross +{% endraw %}{% if enable_polonius %}nightly-{{ rust_nightly_date }}{% else %}stable{% endif %}{% raw %} build --release --target ${{ matrix.target }} - name: Prepare artifact run: | mkdir -p artifacts/${{ matrix.os }}-${{ matrix.arch }} diff --git a/template/AGENTS.md.jinja b/template/AGENTS.md.jinja index 857e247..e1c195f 100644 --- a/template/AGENTS.md.jinja +++ b/template/AGENTS.md.jinja @@ -133,6 +133,25 @@ 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: +{% if enable_polonius -%} +### Borrow checker: Polonius, not NLL + +This repository compiles with the Polonius alpha analysis +(`-Zpolonius=next`) on the dated nightly pinned in `rust-toolchain.toml`. +Internal APIs should be borrow-centric: lookups and get-or-create accessors +return references, keys are cloned only on insertion, and error context is +built lazily. See `docs/polonius.md` for the full contract. + +- Never rewrite a site tagged `POLONIUS(...)` into a double lookup, an + `entry(key.clone())` call, id or index indirection, or eager error context. +- Do not add defensive clones merely to end a borrow without first compiling + the natural borrow-returning form under the project toolchain. +- Respect `POLONIUS-REFUSED(...)` tags. Polonius does not relax aliasing, + suspension-point, lock-boundary, or thread-boundary constraints. +- Verify new borrow-sensitive code with the project toolchain and classify it + with and without `-Zpolonius=next` as described in `docs/polonius.md`. + +{% endif -%} - 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/template/Makefile.jinja b/template/Makefile.jinja index 81e0a90..df229c1 100644 --- a/template/Makefile.jinja +++ b/template/Makefile.jinja @@ -11,16 +11,29 @@ USER_WHITAKER := $(HOME)/.local/bin/whitaker USER_BIN_PATH := $(HOME)/.cargo/bin:$(HOME)/.local/bin:$(HOME)/.bun/bin CARGO ?= cargo BUILD_JOBS ?= +{% if enable_polonius -%} +# RUSTFLAGS overrides .cargo/config.toml, so recipes that set it must re-state +# the Polonius flag explicitly. +POLONIUS_FLAGS ?= -Zpolonius=next +{% else -%} +POLONIUS_FLAGS ?= +{% endif -%} RUST_FLAGS ?= RUST_FLAGS := -D warnings $(RUST_FLAGS) -RUSTDOC_FLAGS ?= +{% if dev_target and 'linux' in dev_target -%} +DEV_LINKER_FLAGS ?= $(if $(filter Linux,$(shell uname -s)),-C link-arg=-fuse-ld=mold) +{% else -%} +DEV_LINKER_FLAGS ?= +{% endif -%} +DEV_RUST_FLAGS ?= $(RUST_FLAGS) $(POLONIUS_FLAGS) $(DEV_LINKER_FLAGS) +RUSTDOC_FLAGS ?= $(POLONIUS_FLAGS) RUSTDOC_FLAGS := -D warnings $(RUSTDOC_FLAGS) CARGO_FLAGS ?= --all-targets --all-features CLIPPY_FLAGS ?= $(CARGO_FLAGS) -- $(RUST_FLAGS) TEST_FLAGS ?= $(CARGO_FLAGS) TEST_CMD := $(if $(shell $(CARGO) nextest --version 2>/dev/null),nextest run,test) COVERAGE_LINKER_FLAGS ?= -fuse-ld=lld -COVERAGE_RUST_FLAGS ?= $(RUST_FLAGS) -C link-arg=$(COVERAGE_LINKER_FLAGS) +COVERAGE_RUST_FLAGS ?= $(RUST_FLAGS) $(POLONIUS_FLAGS) -C link-arg=$(COVERAGE_LINKER_FLAGS) MDLINT ?= markdownlint-cli2 NIXIE ?= nixie TYPOS_VERSION ?= 1.48.0 @@ -38,11 +51,11 @@ clean: ## Remove build artifacts rm -f .typos-oxendict-base.json .typos-oxendict-base.toml test: ## Run tests with warnings treated as errors - RUSTFLAGS="$(RUST_FLAGS)" $(CARGO) $(TEST_CMD) $(TEST_FLAGS) $(BUILD_JOBS) - RUSTFLAGS="$(RUST_FLAGS)" $(CARGO) test --doc --workspace --all-features + RUSTFLAGS="$(DEV_RUST_FLAGS)" $(CARGO) $(TEST_CMD) $(TEST_FLAGS) $(BUILD_JOBS) + RUSTDOCFLAGS="$(RUSTDOC_FLAGS)" RUSTFLAGS="$(DEV_RUST_FLAGS)" $(CARGO) test --doc --workspace --all-features target/%/$(TARGET): ## Build binary in debug or release mode - $(CARGO) build $(BUILD_JOBS) $(if $(findstring release,$(@)),--release){% if flavour == 'app' %} --bin $(TARGET){% endif %} + RUSTFLAGS="$(DEV_RUST_FLAGS)" $(CARGO) build $(BUILD_JOBS) $(if $(findstring release,$(@)),--release){% if flavour == 'app' %} --bin $(TARGET){% endif %} coverage: ## Generate lcov coverage with lld for llvm-tools compatibility @echo "coverage linker flags: $(COVERAGE_LINKER_FLAGS)" @@ -53,13 +66,13 @@ coverage: ## Generate lcov coverage with lld for llvm-tools compatibility $(CARGO) llvm-cov --lcov --output-path lcov.info $(TEST_FLAGS) lint: ## Run Clippy with warnings denied - RUSTDOCFLAGS="$(RUSTDOC_FLAGS)" $(CARGO) doc --no-deps - $(CARGO) clippy $(CLIPPY_FLAGS) + RUSTDOCFLAGS="$(RUSTDOC_FLAGS)" RUSTFLAGS="$(DEV_RUST_FLAGS)" $(CARGO) doc --no-deps + RUSTFLAGS="$(DEV_RUST_FLAGS)" $(CARGO) clippy $(CLIPPY_FLAGS) @echo "Whitaker binary: $(WHITAKER)" - PATH="$(USER_BIN_PATH):$(PATH)" RUSTFLAGS="$(RUST_FLAGS)" $(WHITAKER) --all -- $(CARGO_FLAGS) + PATH="$(USER_BIN_PATH):$(PATH)" RUSTFLAGS="$(DEV_RUST_FLAGS)" $(WHITAKER) --all -- $(CARGO_FLAGS) typecheck: ## Type-check without building - RUSTFLAGS="$(RUST_FLAGS)" $(CARGO) check $(CARGO_FLAGS) + RUSTFLAGS="$(DEV_RUST_FLAGS)" $(CARGO) check $(CARGO_FLAGS) fmt: ## Format Rust and Markdown sources $(CARGO) +nightly fmt --all diff --git a/template/README.md.jinja b/template/README.md.jinja index d3b637a..384d596 100644 --- a/template/README.md.jinja +++ b/template/README.md.jinja @@ -2,6 +2,12 @@ This is a generated project using [Copier](https://copier.readthedocs.io/). +{% if enable_polonius -%} +This project uses the Polonius alpha borrow checker (`-Zpolonius=next`) on the +dated nightly pinned in `rust-toolchain.toml`. See +[the Polonius policy](docs/polonius.md) for the compiler and API contract. + +{% endif -%} ## Documentation - [Documentation contents](docs/contents.md) diff --git a/template/docs/contents.md.jinja b/template/docs/contents.md.jinja index 6e036bc..220b916 100644 --- a/template/docs/contents.md.jinja +++ b/template/docs/contents.md.jinja @@ -14,7 +14,10 @@ documentation set. - [Documentation style guide](documentation-style-guide.md) defines the spelling, structure, Markdown, Architecture Decision Record (ADR), Request for Comments (RFC), and roadmap conventions used by this documentation set. - +{% if enable_polonius -%} +- [Polonius borrow-checker policy](polonius.md) records the nightly compiler + contract, borrow-centric design rules, and audit tags used by this project. +{% endif %} ## Rust reference material - [Reliable testing in Rust via dependency injection](reliable-testing-in-rust-via-dependency-injection.md) diff --git a/template/docs/developers-guide.md.jinja b/template/docs/developers-guide.md.jinja index 2056c55..ebb9743 100644 --- a/template/docs/developers-guide.md.jinja +++ b/template/docs/developers-guide.md.jinja @@ -34,6 +34,17 @@ mutants into new tests. ## Tooling +{% if enable_polonius -%} +### Polonius borrow checker + +This project compiles with the Polonius alpha analysis +(`-Zpolonius=next`) on the dated nightly pinned in `rust-toolchain.toml`. +`.cargo/config.toml` supplies the flag by default; Makefile recipes and +workflows that set `RUSTFLAGS` must re-state it because the environment value +overrides Cargo configuration. See [the Polonius policy](polonius.md) for the +borrow-centric API and audit-tag conventions. + +{% endif -%} Development builds use Cranelift for debug code generation. On Linux targets, `.cargo/config.toml` configures clang to link with `mold` so debug builds link quickly. Coverage generation uses `lld` because LLVM coverage tooling expects diff --git a/template/docs/repository-layout.md.jinja b/template/docs/repository-layout.md.jinja index 2cc430b..3bccf54 100644 --- a/template/docs/repository-layout.md.jinja +++ b/template/docs/repository-layout.md.jinja @@ -25,6 +25,9 @@ compact and omits build output such as `target/`. ├── docs/ │ ├── contents.md │ ├── developers-guide.md +{% if enable_polonius %} +│ ├── polonius.md +{% endif %} │ ├── repository-layout.md │ ├── users-guide.md │ └── ... @@ -70,6 +73,10 @@ compact and omits build output such as `target/`. public build and test commands. - `docs/developers-guide.md`: Explains the contributor workflow and local tooling used to work on the generated project. +{% if enable_polonius %} +- `docs/polonius.md`: Records the Polonius toolchain contract, borrow-centric + design guidance, and audited borrow-checker classifications. +{% endif %} - `docs/repository-layout.md`: Documents the repository tree and path responsibilities. {% if flavour == 'app' %} diff --git a/template/docs/users-guide.md.jinja b/template/docs/users-guide.md.jinja index 5e06f5f..f976763 100644 --- a/template/docs/users-guide.md.jinja +++ b/template/docs/users-guide.md.jinja @@ -10,6 +10,15 @@ settings, and documented starter code. Library projects render `src/lib.rs`. Application projects render `src/main.rs`, `src/lib.rs`, release automation, and `[package.metadata.binstall]` metadata for binary installation. +{% if enable_polonius -%} +This project enables the Polonius alpha borrow checker +(`-Zpolonius=next`) on its dated nightly toolchain. The checked-in Cargo +configuration supplies the flag to normal builds and editor tooling, while the +Makefile and workflows re-state it wherever they override `RUSTFLAGS`. See the +[Polonius policy](polonius.md) before changing the toolchain or borrow-centric +APIs. + +{% endif -%} Development builds use Cranelift for debug code generation. On Linux targets, `.cargo/config.toml` configures clang to link with `mold` so local debug builds link quickly. Coverage generation uses `lld` instead because LLVM coverage diff --git a/template/docs/{% if enable_polonius %}polonius.md{% endif %}.jinja b/template/docs/{% if enable_polonius %}polonius.md{% endif %}.jinja new file mode 100644 index 0000000..51cdea8 --- /dev/null +++ b/template/docs/{% if enable_polonius %}polonius.md{% endif %}.jinja @@ -0,0 +1,68 @@ +# Polonius borrow-checker policy + +This project enables the Polonius alpha borrow-checking analysis +(`-Zpolonius=next`) on the dated nightly pinned in `rust-toolchain.toml`. +Polonius accepts borrow-returning control-flow patterns that non-lexical +lifetimes (NLL) reject, allowing internal APIs to model borrowing directly. + +## Compiler contract + +`.cargo/config.toml` enables Polonius for normal Cargo commands and +rust-analyzer. An inherited `RUSTFLAGS` value overrides that configuration, so +the Makefile and GitHub Actions workflows re-state `-Zpolonius=next` whenever +they set `RUSTFLAGS`. Release builds use the same pinned nightly and flag. + +Source builds performed outside this repository, including registry installs, +do not inherit its toolchain or Cargo configuration. If this crate later ships +Polonius-only source through a registry, its installation instructions must +name both the pinned nightly and `RUSTFLAGS=-Zpolonius=next`, or direct users to +pre-built artefacts. + +## Borrow-centric design + +- Prefer lookup and get-or-create APIs that return references. +- Clone keys only on insertion, not on successful lookup. +- Build owned error context only in the failure branch where it escapes. +- Reserve ids and indexes for persistent or cross-boundary identity, not as + substitutes for references. +- Remember that Polonius changes lifetime analysis, not aliasing: simultaneous + mutable borrows, borrows across suspension points, lock-guard lifetimes, and + thread boundaries remain real constraints. + +## Audit tags + +Use one of these greppable tags when a borrow-sensitive design needs its +classification preserved: + +- `POLONIUS(case-3)` marks code verified to require the Polonius analysis. +- `POLONIUS-CANDIDATE(pattern)` marks an NLL workaround awaiting a verified + borrow-centric rewrite. +- `POLONIUS-REFUSED(constraint)` records why an owned form remains necessary, + such as `aliasing`, `suspension-point`, `lock-boundary`, or `id-is-data`. + +Record verified sites below so later reviews start from evidence rather than +re-running the same analysis. + +| Site | Classification | Verified nightly | Notes | +| --- | --- | --- | --- | +| None yet | Initial generated project | `nightly-{{ rust_nightly_date }}` | Add rows as borrow-sensitive APIs are introduced. | + +## Verification + +For each borrow-sensitive change, first run the supported configuration: + +```sh +make typecheck +make test +``` + +Then classify the changed form under plain NLL by overriding the checked-in +flag deliberately: + +```sh +RUSTFLAGS= cargo +nightly-{{ rust_nightly_date }} check --all-targets --all-features +``` + +A no-flag failure establishes that the design genuinely requires Polonius; it +does not make the supported build invalid. Run the complete behavioural suite +under the normal Polonius-enabled configuration. diff --git a/template/rust-toolchain.toml.jinja b/template/rust-toolchain.toml.jinja index 67d29a9..47f2701 100644 --- a/template/rust-toolchain.toml.jinja +++ b/template/rust-toolchain.toml.jinja @@ -1,3 +1,7 @@ +{% if enable_polonius -%} +# This project enables the nightly-only Polonius alpha borrow checker. Keep the +# dated pin reproducible and review docs/polonius.md before changing it. +{% endif -%} [toolchain] channel = "nightly-{{ rust_nightly_date }}" components = [ diff --git a/template/typos.local.toml b/template/typos.local.toml index 2a71820..4c30bfa 100644 --- a/template/typos.local.toml +++ b/template/typos.local.toml @@ -6,7 +6,7 @@ schema = 1 stems = [] [words] -accepted = ["Flavored", "mold"] +accepted = ["Flavored", "mold", "Polonius"] [words.corrections] diff --git a/template/typos.toml b/template/typos.toml index 0d8fdd7..8ea254d 100644 --- a/template/typos.toml +++ b/template/typos.toml @@ -6,6 +6,7 @@ extend-exclude = [ ".git", ".hypothesis", ".pytest_cache", + ".terraform", ".tox", ".typos-oxendict-base.json", ".typos-oxendict-base.toml", @@ -31,11 +32,14 @@ locale = "en-gb" extend-ignore-re = [ "(?s)```.*?```", "Center \\| Microsoft Learn", + "\\brust-analyzer\\b", "`[^`\\n]+`", ] [default.extend-words] +"ASO" = "ASO" "Flavored" = "Flavored" +"Polonius" = "Polonius" "absolutisable" = "absolutizable" "absolutisation" = "absolutization" "absolutisations" = "absolutizations" @@ -594,6 +598,8 @@ extend-ignore-re = [ "desynchronizers" = "desynchronizers" "desynchronizes" = "desynchronizes" "desynchronizing" = "desynchronizing" +"dialog" = "dialog" +"dialogs" = "dialogs" "dockerisable" = "dockerizable" "dockerisation" = "dockerization" "dockerisations" = "dockerizations" @@ -828,6 +834,7 @@ extend-ignore-re = [ "globalizers" = "globalizers" "globalizes" = "globalizes" "globalizing" = "globalizing" +"handwritten" = "handwritten" "harmonisable" = "harmonizable" "harmonisation" = "harmonization" "harmonisations" = "harmonizations" @@ -1008,6 +1015,24 @@ extend-ignore-re = [ "internationalizers" = "internationalizers" "internationalizes" = "internationalizes" "internationalizing" = "internationalizing" +"italicisable" = "italicizable" +"italicisation" = "italicization" +"italicisations" = "italicizations" +"italicise" = "italicize" +"italicised" = "italicized" +"italiciser" = "italicizer" +"italicisers" = "italicizers" +"italicises" = "italicizes" +"italicising" = "italicizing" +"italicizable" = "italicizable" +"italicization" = "italicization" +"italicizations" = "italicizations" +"italicize" = "italicize" +"italicized" = "italicized" +"italicizer" = "italicizer" +"italicizers" = "italicizers" +"italicizes" = "italicizes" +"italicizing" = "italicizing" "itemisable" = "itemizable" "itemisation" = "itemization" "itemisations" = "itemizations" @@ -1497,6 +1522,7 @@ extend-ignore-re = [ "ordinalizing" = "ordinalizing" "organisable" = "organizable" "organisation" = "organization" +"organisational" = "organizational" "organisations" = "organizations" "organise" = "organize" "organised" = "organized" @@ -1506,6 +1532,7 @@ extend-ignore-re = [ "organising" = "organizing" "organizable" = "organizable" "organization" = "organization" +"organizational" = "organizational" "organizations" = "organizations" "organize" = "organize" "organized" = "organized" @@ -1514,6 +1541,24 @@ extend-ignore-re = [ "organizes" = "organizes" "organizing" = "organizing" "oxendict" = "oxendict" +"oxidisable" = "oxidizable" +"oxidisation" = "oxidization" +"oxidisations" = "oxidizations" +"oxidise" = "oxidize" +"oxidised" = "oxidized" +"oxidiser" = "oxidizer" +"oxidisers" = "oxidizers" +"oxidises" = "oxidizes" +"oxidising" = "oxidizing" +"oxidizable" = "oxidizable" +"oxidization" = "oxidization" +"oxidizations" = "oxidizations" +"oxidize" = "oxidize" +"oxidized" = "oxidized" +"oxidizer" = "oxidizer" +"oxidizers" = "oxidizers" +"oxidizes" = "oxidizes" +"oxidizing" = "oxidizing" "palettisable" = "palettizable" "palettisation" = "palettization" "palettisations" = "palettizations" @@ -1658,6 +1703,24 @@ extend-ignore-re = [ "pluralizers" = "pluralizers" "pluralizes" = "pluralizes" "pluralizing" = "pluralizing" +"polymerisable" = "polymerizable" +"polymerisation" = "polymerization" +"polymerisations" = "polymerizations" +"polymerise" = "polymerize" +"polymerised" = "polymerized" +"polymeriser" = "polymerizer" +"polymerisers" = "polymerizers" +"polymerises" = "polymerizes" +"polymerising" = "polymerizing" +"polymerizable" = "polymerizable" +"polymerization" = "polymerization" +"polymerizations" = "polymerizations" +"polymerize" = "polymerize" +"polymerized" = "polymerized" +"polymerizer" = "polymerizer" +"polymerizers" = "polymerizers" +"polymerizes" = "polymerizes" +"polymerizing" = "polymerizing" "popularisable" = "popularizable" "popularisation" = "popularization" "popularisations" = "popularizations" @@ -2414,6 +2477,24 @@ extend-ignore-re = [ "uncategorizers" = "uncategorizers" "uncategorizes" = "uncategorizes" "uncategorizing" = "uncategorizing" +"underutilisable" = "underutilizable" +"underutilisation" = "underutilization" +"underutilisations" = "underutilizations" +"underutilise" = "underutilize" +"underutilised" = "underutilized" +"underutiliser" = "underutilizer" +"underutilisers" = "underutilizers" +"underutilises" = "underutilizes" +"underutilising" = "underutilizing" +"underutilizable" = "underutilizable" +"underutilization" = "underutilization" +"underutilizations" = "underutilizations" +"underutilize" = "underutilize" +"underutilized" = "underutilized" +"underutilizer" = "underutilizer" +"underutilizers" = "underutilizers" +"underutilizes" = "underutilizes" +"underutilizing" = "underutilizing" "uninitialisable" = "uninitializable" "uninitialisation" = "uninitialization" "uninitialisations" = "uninitializations" diff --git a/tests/helpers/rendering.py b/tests/helpers/rendering.py index 5f6f6b2..8fa56d6 100644 --- a/tests/helpers/rendering.py +++ b/tests/helpers/rendering.py @@ -19,11 +19,38 @@ def render_project( project_name: str, package_name: str, flavour: str = LIB, + enable_polonius: bool | None = None, license_year: int | None = 2026, dev_target: str = "x86_64-unknown-linux-gnu", ) -> CopierProject: - """Render a generated Rust project with publishable metadata.""" - answers: dict[str, str | int] = { + """Render a generated Rust project with publishable metadata. + + Parameters + ---------- + tmp_path : Path + Destination directory for the rendered project. + copier : CopierFixture + Copier test fixture used to render the template. + project_name : str + Human-readable project name supplied to Copier. + package_name : str + Rust package name supplied to Copier. + flavour : str + Generated project flavour, either an application or a library. + enable_polonius : bool | None + Explicit Polonius selection. ``None`` omits the answer so Copier uses + the flavour-based default; a Boolean overrides that default. + license_year : int | None + Copyright year. ``None`` omits the answer so Copier uses its default. + dev_target : str + Rust target triple used for generated development tooling. + + Returns + ------- + CopierProject + The rendered project fixture. + """ + answers: dict[str, str | int | bool] = { "project_name": project_name, "package_name": package_name, "package_description": f"{project_name} package used by template tests.", @@ -36,6 +63,8 @@ def render_project( "flavour": flavour, "dev_target": dev_target, } + if enable_polonius is not None: + answers["enable_polonius"] = enable_polonius if license_year is not None: answers["license_year"] = license_year diff --git a/tests/helpers/tooling_contracts/__init__.py b/tests/helpers/tooling_contracts/__init__.py index 9a86e71..cac977d 100644 --- a/tests/helpers/tooling_contracts/__init__.py +++ b/tests/helpers/tooling_contracts/__init__.py @@ -8,6 +8,9 @@ from tests.helpers.tooling_contracts.orchestration import ( assert_generated_tooling_contracts, ) +from tests.helpers.tooling_contracts.polonius import ( + assert_polonius_toolchain_contracts, +) from tests.helpers.tooling_contracts.workflows import ( assert_ci_coverage_action_contract, assert_coverage_main_workflow_contract, @@ -19,5 +22,6 @@ "assert_coverage_main_workflow_contract", "assert_documentation_navigation_contracts", "assert_generated_tooling_contracts", + "assert_polonius_toolchain_contracts", "extract_checkout_steps", ] diff --git a/tests/helpers/tooling_contracts/polonius.py b/tests/helpers/tooling_contracts/polonius.py new file mode 100644 index 0000000..115796e --- /dev/null +++ b/tests/helpers/tooling_contracts/polonius.py @@ -0,0 +1,242 @@ +"""Assert rendered Polonius toolchain and documentation contracts.""" + +from __future__ import annotations + +import tomllib +from typing import Any + +from tests.helpers.generated_files import ( + parse_yaml_mapping, + require_mapping, + require_sequence, +) + +POLONIUS_FLAG = "-Zpolonius=next" + + +def _named_step(workflow: str, job_name: str, step_name: str) -> dict[str, Any]: + """Return one named step from a rendered workflow.""" + parsed = parse_yaml_mapping(workflow, f"{job_name} workflow") + jobs = require_mapping(parsed, "jobs", f"{job_name} workflow") + job = require_mapping(jobs, job_name, f"{job_name} workflow jobs") + steps = require_sequence(job, "steps", f"{job_name} workflow job") + matches = [ + step + for step in steps + if isinstance(step, dict) and step.get("name") == step_name + ] + assert len(matches) == 1, f"expected one {step_name!r} step in {job_name}" + return matches[0] + + +def _setup_rust_step(release_workflow: str) -> dict[str, Any]: + """Return the setup-rust step from the release workflow.""" + parsed = parse_yaml_mapping(release_workflow, "release workflow") + jobs = require_mapping(parsed, "jobs", "release workflow") + build = require_mapping(jobs, "build", "release workflow jobs") + steps = require_sequence(build, "steps", "release build job") + matches = [ + step + for step in steps + if isinstance(step, dict) and "actions/setup-rust@" in str(step.get("uses")) + ] + assert len(matches) == 1, "expected one setup-rust step in release workflow" + return matches[0] + + +def _assert_cargo_config(cargo_config: str, *, enabled: bool, dev_target: str) -> None: + """Assert Cargo's build and target rustflags retain Polonius as required.""" + config = tomllib.loads(cargo_config) + build_flags = config.get("build", {}).get("rustflags", []) + rustdoc_flags = config.get("build", {}).get("rustdocflags", []) + assert (POLONIUS_FLAG in build_flags) is enabled, ( + "expected build.rustflags Polonius state to match the Copier answer" + ) + assert (POLONIUS_FLAG in rustdoc_flags) is enabled, ( + "expected build.rustdocflags Polonius state to match the Copier answer" + ) + if "linux" not in dev_target: + return + target_flags = config["target"][dev_target]["rustflags"] + assert (POLONIUS_FLAG in target_flags) is enabled, ( + "target rustflags override build.rustflags and must preserve the " + "selected Polonius state" + ) + + +def _assert_makefile(makefile: str, *, enabled: bool, dev_target: str) -> None: + """Assert every generated compile path preserves the selected flag.""" + expected_default = ( + f"POLONIUS_FLAGS ?= {POLONIUS_FLAG}" if enabled else "POLONIUS_FLAGS ?=" + ) + assert expected_default in makefile + assert ( + "COVERAGE_RUST_FLAGS ?= $(RUST_FLAGS) $(POLONIUS_FLAGS) " + "-C link-arg=$(COVERAGE_LINKER_FLAGS)" + ) in makefile + assert ( + "DEV_RUST_FLAGS ?= $(RUST_FLAGS) $(POLONIUS_FLAGS) $(DEV_LINKER_FLAGS)" + in makefile + ) + assert "RUSTDOC_FLAGS ?= $(POLONIUS_FLAGS)" in makefile, ( + "RUSTDOC_FLAGS must inherit POLONIUS_FLAGS" + ) + linker_default = next( + line for line in makefile.splitlines() if line.startswith("DEV_LINKER_FLAGS ?=") + ) + if "linux" in dev_target: + assert "$(filter Linux,$(shell uname -s))" in linker_default + assert "-fuse-ld=mold" in linker_default + else: + assert linker_default == "DEV_LINKER_FLAGS ?=" + compile_lines = [ + line + for line in makefile.splitlines() + if "RUSTFLAGS=" in line and ("$(CARGO)" in line or "$(WHITAKER)" in line) + ] + assert compile_lines, "expected generated compile recipes to set RUSTFLAGS" + assert all( + "$(DEV_RUST_FLAGS)" in line or "$(COVERAGE_RUST_FLAGS)" in line + for line in compile_lines + ), f"compile recipes must use composed Rust flags: {compile_lines!r}" + coverage_recipe = makefile.partition("coverage:")[2].partition("\n\n")[0] + assert 'RUSTFLAGS="$(COVERAGE_RUST_FLAGS)"' in coverage_recipe, ( + "coverage recipe must use composed coverage Rust flags" + ) + rustdoc_lines = [ + line + for line in makefile.splitlines() + if "$(CARGO) test --doc" in line or "$(CARGO) doc" in line + ] + assert len(rustdoc_lines) == 2, ( + f"expected exactly two rustdoc recipes, got: {rustdoc_lines!r}" + ) + assert all('RUSTDOCFLAGS="$(RUSTDOC_FLAGS)"' in line for line in rustdoc_lines), ( + f"rustdoc recipes must use composed rustdoc flags: {rustdoc_lines!r}" + ) + + +def _assert_coverage_workflow(workflow: str, job_name: str, *, enabled: bool) -> None: + """Assert coverage's explicit RUSTFLAGS do not shadow Polonius.""" + coverage = _named_step(workflow, job_name, "Test and Measure Coverage") + env = require_mapping(coverage, "env", "coverage step") + rustflags = str(env.get("RUSTFLAGS", "")) + assert "-C link-arg=-fuse-ld=lld" in rustflags + assert (POLONIUS_FLAG in rustflags) is enabled + + +def _assert_release_workflow( + release_workflow: str, rust_toolchain: str, *, enabled: bool +) -> None: + """Assert release artefacts use a compiler compatible with the source.""" + toolchain_config = tomllib.loads(rust_toolchain) + expected_toolchain = str(toolchain_config["toolchain"]["channel"]) + setup = _setup_rust_step(release_workflow) + setup_inputs = require_mapping(setup, "with", "release setup-rust step") + toolchain = str(setup_inputs.get("toolchain", "")) + build = _named_step(release_workflow, "build", "Build release binary") + env = require_mapping(build, "env", "release build step") + rustflags = str(env.get("RUSTFLAGS", "")) + command = str(build.get("run", "")) + if enabled: + assert toolchain == expected_toolchain + assert rustflags == POLONIUS_FLAG + assert f"cross +{expected_toolchain} build" in command + else: + assert toolchain == "stable" + assert rustflags == "" + assert "cross +stable build" in command + + +def assert_polonius_toolchain_contracts( + *, + enabled: bool, + dev_target: str, + cargo_config: str, + makefile: str, + rust_toolchain: str, + ci_workflow: str, + coverage_main_workflow: str, + release_workflow: str | None, + agents: str, + readme: str, + docs_contents: str, + repository_layout: str, + developers_guide: str, + users_guide: str, + polonius_doc: str | None, +) -> None: + """Assert the selected Polonius state across generated project surfaces. + + Parameters + ---------- + enabled : bool + Expected Polonius selection for the rendered project. + dev_target : str + Development target represented in the rendered Cargo and Make files. + cargo_config : str + Rendered Cargo configuration. + makefile : str + Rendered Makefile. + rust_toolchain : str + Rendered Rust toolchain configuration. + ci_workflow : str + Rendered Continuous Integration workflow. + coverage_main_workflow : str + Rendered main-branch coverage workflow. + release_workflow : str | None + Rendered release workflow, or ``None`` when the project has none. + agents : str + Rendered agent guidance. + readme : str + Rendered project README. + docs_contents : str + Rendered documentation contents page. + repository_layout : str + Rendered repository layout documentation. + developers_guide : str + Rendered developers guide. + users_guide : str + Rendered users guide. + polonius_doc : str | None + Rendered Polonius policy, or ``None`` when Polonius is disabled. + + Raises + ------ + AssertionError + If any rendered surface does not match the selected Polonius state. + """ + _assert_cargo_config(cargo_config, enabled=enabled, dev_target=dev_target) + _assert_makefile(makefile, enabled=enabled, dev_target=dev_target) + _assert_coverage_workflow(ci_workflow, "build-test", enabled=enabled) + _assert_coverage_workflow( + coverage_main_workflow, "coverage-upload", enabled=enabled + ) + if release_workflow is not None: + _assert_release_workflow(release_workflow, rust_toolchain, enabled=enabled) + + if enabled: + assert "Polonius alpha" in rust_toolchain + assert polonius_doc is not None + assert POLONIUS_FLAG in polonius_doc + for surface in ( + agents, + readme, + docs_contents, + repository_layout, + developers_guide, + users_guide, + ): + assert "Polonius" in surface + else: + assert "Polonius" not in rust_toolchain + assert polonius_doc is None + for surface in ( + agents, + readme, + docs_contents, + repository_layout, + developers_guide, + users_guide, + ): + assert "Polonius" not in surface diff --git a/tests/test_template/__snapshots__/test_snapshots.ambr b/tests/test_template/__snapshots__/test_snapshots.ambr index 098b2aa..44cffa3 100644 --- a/tests/test_template/__snapshots__/test_snapshots.ambr +++ b/tests/test_template/__snapshots__/test_snapshots.ambr @@ -108,6 +108,13 @@ }), }), 'cargo_config': ''' + # Enable the Polonius alpha borrow-checking analysis by default so Cargo, + # rust-analyzer, and verification tools agree about what borrows are legal. + # An inherited RUSTFLAGS value overrides this table; the generated Makefile and + # workflows therefore re-state -Zpolonius=next whenever they set RUSTFLAGS. + [build] + rustflags = ["-Zpolonius=next"] + rustdocflags = ["-Zpolonius=next"] [unstable] codegen-backend = true @@ -116,7 +123,9 @@ [target.x86_64-unknown-linux-gnu] linker = "clang" - rustflags = ["-C", "link-arg=-fuse-ld=mold"] + # Cargo selects target rustflags instead of merging them with build.rustflags, + # so this target-specific mold configuration must repeat the Polonius flag. + rustflags = ["-Zpolonius=next", "-C", "link-arg=-fuse-ld=mold"] ''', 'ci_workflow': dict({ @@ -249,7 +258,7 @@ 'name': 'Log coverage linker configuration', 'run': ''' echo "Coverage linker: clang" - echo "Coverage RUSTFLAGS: -C link-arg=-fuse-ld=lld" + echo "Coverage RUSTFLAGS: -Zpolonius=next -C link-arg=-fuse-ld=lld" echo "Coverage CFLAGS: -fuse-ld=lld" echo "Coverage LDFLAGS: -fuse-ld=lld" @@ -260,7 +269,7 @@ 'CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER': 'clang', 'CFLAGS': '-fuse-ld=lld', 'LDFLAGS': '-fuse-ld=lld', - 'RUSTFLAGS': '-C link-arg=-fuse-ld=lld', + 'RUSTFLAGS': '-Zpolonius=next -C link-arg=-fuse-ld=lld', }), 'name': 'Test and Measure Coverage', 'uses': 'leynos/shared-actions/.github/actions/generate-coverage@', @@ -330,7 +339,7 @@ 'name': 'Log coverage linker configuration', 'run': ''' echo "Coverage linker: clang" - echo "Coverage RUSTFLAGS: -C link-arg=-fuse-ld=lld" + echo "Coverage RUSTFLAGS: -Zpolonius=next -C link-arg=-fuse-ld=lld" echo "Coverage CFLAGS: -fuse-ld=lld" echo "Coverage LDFLAGS: -fuse-ld=lld" @@ -341,7 +350,7 @@ 'CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER': 'clang', 'CFLAGS': '-fuse-ld=lld', 'LDFLAGS': '-fuse-ld=lld', - 'RUSTFLAGS': '-C link-arg=-fuse-ld=lld', + 'RUSTFLAGS': '-Zpolonius=next -C link-arg=-fuse-ld=lld', }), 'name': 'Test and Measure Coverage', 'uses': 'leynos/shared-actions/.github/actions/generate-coverage@', @@ -389,16 +398,21 @@ USER_BIN_PATH := $(HOME)/.cargo/bin:$(HOME)/.local/bin:$(HOME)/.bun/bin CARGO ?= cargo BUILD_JOBS ?= + # RUSTFLAGS overrides .cargo/config.toml, so recipes that set it must re-state + # the Polonius flag explicitly. + POLONIUS_FLAGS ?= -Zpolonius=next RUST_FLAGS ?= RUST_FLAGS := -D warnings $(RUST_FLAGS) - RUSTDOC_FLAGS ?= + DEV_LINKER_FLAGS ?= $(if $(filter Linux,$(shell uname -s)),-C link-arg=-fuse-ld=mold) + DEV_RUST_FLAGS ?= $(RUST_FLAGS) $(POLONIUS_FLAGS) $(DEV_LINKER_FLAGS) + RUSTDOC_FLAGS ?= $(POLONIUS_FLAGS) RUSTDOC_FLAGS := -D warnings $(RUSTDOC_FLAGS) CARGO_FLAGS ?= --all-targets --all-features CLIPPY_FLAGS ?= $(CARGO_FLAGS) -- $(RUST_FLAGS) TEST_FLAGS ?= $(CARGO_FLAGS) TEST_CMD := $(if $(shell $(CARGO) nextest --version 2>/dev/null),nextest run,test) COVERAGE_LINKER_FLAGS ?= -fuse-ld=lld - COVERAGE_RUST_FLAGS ?= $(RUST_FLAGS) -C link-arg=$(COVERAGE_LINKER_FLAGS) + COVERAGE_RUST_FLAGS ?= $(RUST_FLAGS) $(POLONIUS_FLAGS) -C link-arg=$(COVERAGE_LINKER_FLAGS) MDLINT ?= markdownlint-cli2 NIXIE ?= nixie TYPOS_VERSION ?= 1.48.0 @@ -416,11 +430,11 @@ \trm -f .typos-oxendict-base.json .typos-oxendict-base.toml test: ## Run tests with warnings treated as errors - \tRUSTFLAGS="$(RUST_FLAGS)" $(CARGO) $(TEST_CMD) $(TEST_FLAGS) $(BUILD_JOBS) - \tRUSTFLAGS="$(RUST_FLAGS)" $(CARGO) test --doc --workspace --all-features + \tRUSTFLAGS="$(DEV_RUST_FLAGS)" $(CARGO) $(TEST_CMD) $(TEST_FLAGS) $(BUILD_JOBS) + \tRUSTDOCFLAGS="$(RUSTDOC_FLAGS)" RUSTFLAGS="$(DEV_RUST_FLAGS)" $(CARGO) test --doc --workspace --all-features target/%/$(TARGET): ## Build binary in debug or release mode - \t$(CARGO) build $(BUILD_JOBS) $(if $(findstring release,$(@)),--release) --bin $(TARGET) + \tRUSTFLAGS="$(DEV_RUST_FLAGS)" $(CARGO) build $(BUILD_JOBS) $(if $(findstring release,$(@)),--release) --bin $(TARGET) coverage: ## Generate lcov coverage with lld for llvm-tools compatibility \t@echo "coverage linker flags: $(COVERAGE_LINKER_FLAGS)" @@ -431,13 +445,13 @@ \t\t$(CARGO) llvm-cov --lcov --output-path lcov.info $(TEST_FLAGS) lint: ## Run Clippy with warnings denied - \tRUSTDOCFLAGS="$(RUSTDOC_FLAGS)" $(CARGO) doc --no-deps - \t$(CARGO) clippy $(CLIPPY_FLAGS) + \tRUSTDOCFLAGS="$(RUSTDOC_FLAGS)" RUSTFLAGS="$(DEV_RUST_FLAGS)" $(CARGO) doc --no-deps + \tRUSTFLAGS="$(DEV_RUST_FLAGS)" $(CARGO) clippy $(CLIPPY_FLAGS) \t@echo "Whitaker binary: $(WHITAKER)" - \tPATH="$(USER_BIN_PATH):$(PATH)" RUSTFLAGS="$(RUST_FLAGS)" $(WHITAKER) --all -- $(CARGO_FLAGS) + \tPATH="$(USER_BIN_PATH):$(PATH)" RUSTFLAGS="$(DEV_RUST_FLAGS)" $(WHITAKER) --all -- $(CARGO_FLAGS) typecheck: ## Type-check without building - \tRUSTFLAGS="$(RUST_FLAGS)" $(CARGO) check $(CARGO_FLAGS) + \tRUSTFLAGS="$(DEV_RUST_FLAGS)" $(CARGO) check $(CARGO_FLAGS) fmt: ## Format Rust and Markdown sources \t$(CARGO) +nightly fmt --all @@ -538,7 +552,7 @@ dict({ 'uses': 'leynos/shared-actions/.github/actions/setup-rust@', 'with': dict({ - 'toolchain': 'stable', + 'toolchain': 'nightly-2025-06-10', }), }), dict({ @@ -581,10 +595,10 @@ }), dict({ 'env': dict({ - 'RUSTFLAGS': '', + 'RUSTFLAGS': '-Zpolonius=next', }), 'name': 'Build release binary', - 'run': 'cross +stable build --release --target ${{ matrix.target }}', + 'run': 'cross +nightly-2025-06-10 build --release --target ${{ matrix.target }}', }), dict({ 'name': 'Prepare artifact', diff --git a/tests/test_template/test_basic_rendering.py b/tests/test_template/test_basic_rendering.py index b53f24d..21e96df 100644 --- a/tests/test_template/test_basic_rendering.py +++ b/tests/test_template/test_basic_rendering.py @@ -4,11 +4,38 @@ from pathlib import Path +import pytest from pytest_copier.plugin import CopierFixture from tests.helpers.rendering import APP, LIB, render_project +def test_template_rejects_unsafe_nightly_date( + tmp_path: Path, copier: CopierFixture +) -> None: + """Reject a nightly date that could inject shell into release builds.""" + with pytest.raises(ValueError, match="must use YYYY-MM-DD"): + copier.copy( + tmp_path, + project_name="UnsafeNightly", + package_name="unsafe_nightly", + rust_nightly_date="2025-06-10; echo INJECTED", + ) + + +def test_template_rejects_nightly_date_with_trailing_newline( + tmp_path: Path, copier: CopierFixture +) -> None: + """Reject a nightly date with content after the expected date.""" + with pytest.raises(ValueError, match="must use YYYY-MM-DD"): + copier.copy( + tmp_path, + project_name="InvalidNightly", + package_name="invalid_nightly", + rust_nightly_date="2025-06-10\n", + ) + + def test_template_renders(tmp_path: Path, copier: CopierFixture) -> None: """Template renders with default values and passes public gates.""" project = render_project( @@ -41,6 +68,12 @@ def test_template_renders_app_flavour(tmp_path: Path, copier: CopierFixture) -> assert (project / ".github" / "workflows" / "release.yml").exists(), ( "expected release workflow to exist for app flavour" ) + assert "-Zpolonius=next" in (project / ".cargo/config.toml").read_text(), ( + "expected app flavour to enable recommended Polonius support by default" + ) + assert (project / "docs/polonius.md").exists(), ( + "expected app flavour to document default Polonius support" + ) project.run("make all") @@ -59,4 +92,10 @@ def test_template_renders_lib_flavour(tmp_path: Path, copier: CopierFixture) -> assert not (project / ".github" / "workflows" / "release.yml").exists(), ( "expected release workflow to be omitted for lib flavour" ) + assert "-Zpolonius=next" not in (project / ".cargo/config.toml").read_text(), ( + "expected lib flavour to retain wider compiler compatibility by default" + ) + assert not (project / "docs/polonius.md").exists(), ( + "expected lib flavour to omit Polonius policy by default" + ) project.run("make all") diff --git a/tests/test_template/test_compilation.py b/tests/test_template/test_compilation.py index 20f4f29..7e8a540 100644 --- a/tests/test_template/test_compilation.py +++ b/tests/test_template/test_compilation.py @@ -21,3 +21,37 @@ def test_template_compiles(tmp_path: Path, copier: CopierFixture, flavour: str) flavour=flavour, ) project.run("cargo check --all-targets --all-features") + + +def test_polonius_project_accepts_single_lookup_get_or_insert( + tmp_path: Path, copier: CopierFixture +) -> None: + """Polonius-enabled public typecheck accepts a borrow-returning accessor.""" + project = render_project( + tmp_path, + copier, + project_name="PoloniusExample", + package_name="polonius_example", + flavour=LIB, + enable_polonius=True, + ) + (project / "src/lib.rs").write_text( + """//! Borrow-centric Polonius compilation fixture. + +use std::collections::HashMap; + +/// Return the existing value or insert its default with one hit-path lookup. +pub fn get_or_insert<'values>( + values: &'values mut HashMap, + key: &str, +) -> &'values mut u8 { + if let Some(value) = values.get_mut(key) { + return value; + } + values.entry(key.to_owned()).or_default() +} +""", + encoding="utf-8", + ) + + project.run("make typecheck") diff --git a/tests/test_template/test_tooling_contracts.py b/tests/test_template/test_tooling_contracts.py index 7bb4066..9da5bb9 100644 --- a/tests/test_template/test_tooling_contracts.py +++ b/tests/test_template/test_tooling_contracts.py @@ -5,6 +5,8 @@ from pathlib import Path import pytest +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st from pytest_copier.plugin import CopierFixture from tests.helpers.generated_files import ( @@ -19,20 +21,38 @@ from tests.helpers.tooling_contracts import ( assert_coverage_main_workflow_contract, assert_generated_tooling_contracts, + assert_polonius_toolchain_contracts, ) from tests.helpers.tooling_contracts.workflows import _assert_ci_workflow_contracts +POLONIUS_RENDER_CASES = tuple( + (flavour, enable_polonius, dev_target) + for flavour in (LIB, APP) + for enable_polonius in (None, False, True) + for dev_target in ( + "x86_64-unknown-linux-gnu", + "aarch64-apple-darwin", + "", + ) +) + + @pytest.mark.parametrize( - ("flavour", "dev_target"), + ("flavour", "dev_target", "enable_polonius"), [ - (LIB, "x86_64-unknown-linux-gnu"), - (APP, "x86_64-unknown-linux-gnu"), - (LIB, "aarch64-apple-darwin"), + (LIB, "x86_64-unknown-linux-gnu", False), + (APP, "x86_64-unknown-linux-gnu", True), + (LIB, "aarch64-apple-darwin", True), + (APP, "x86_64-unknown-linux-gnu", False), ], ) def test_generated_tooling_contracts( - tmp_path: Path, copier: CopierFixture, flavour: str, dev_target: str + tmp_path: Path, + copier: CopierFixture, + flavour: str, + dev_target: str, + enable_polonius: bool, ) -> None: """Generated projects include the requested Rust tooling contracts.""" project = render_project( @@ -41,6 +61,7 @@ def test_generated_tooling_contracts( project_name="ToolingExample", package_name="tooling_example", flavour=flavour, + enable_polonius=enable_polonius, dev_target=dev_target, ) @@ -63,8 +84,11 @@ def test_generated_tooling_contracts( project / ".github/workflows/mutation-testing.yml" ) docs_contents = read_generated_text(project / "docs/contents.md") + developers_guide = read_generated_text(project / "docs/developers-guide.md") repository_layout = read_generated_text(project / "docs/repository-layout.md") readme = read_generated_text(project / "README.md") + users_guide = read_generated_text(project / "docs/users-guide.md") + agents = read_generated_text(project / "AGENTS.md") rust_toolchain = read_generated_text(project / "rust-toolchain.toml") test_stub = read_generated_text(project / "tests/stub.rs") typos_config = read_generated_text(project / "typos.toml") @@ -80,6 +104,10 @@ def test_generated_tooling_contracts( if flavour == APP else None ) + polonius_path = project / "docs/polonius.md" + polonius_doc = ( + read_generated_text(polonius_path) if polonius_path.exists() else None + ) assert_generated_tooling_contracts( package=package, metadata=metadata, @@ -100,12 +128,80 @@ def test_generated_tooling_contracts( release_workflow=release_workflow, ) assert_coverage_main_workflow_contract(coverage_main_workflow) + assert_polonius_toolchain_contracts( + enabled=enable_polonius, + dev_target=dev_target, + cargo_config=cargo_config, + makefile=makefile, + rust_toolchain=rust_toolchain, + ci_workflow=ci_workflow, + coverage_main_workflow=coverage_main_workflow, + release_workflow=release_workflow, + agents=agents, + readme=readme, + docs_contents=docs_contents, + repository_layout=repository_layout, + developers_guide=developers_guide, + users_guide=users_guide, + polonius_doc=polonius_doc, + ) assert '[default]\nlocale = "en-gb"' in typos_config - assert 'accepted = ["Flavored", "mold"]' in typos_overlay + assert 'accepted = ["Flavored", "mold", "Polonius"]' in typos_overlay assert "DEFAULT_BASE_URL" in spelling_generator assert "_local_cache_is_current" in spelling_core +@given(case=st.sampled_from(POLONIUS_RENDER_CASES)) +@settings( + deadline=None, + max_examples=len(POLONIUS_RENDER_CASES), + suppress_health_check=[HealthCheck.function_scoped_fixture], +) +def test_polonius_flag_invariant_across_rendered_configuration_space( + tmp_path_factory: pytest.TempPathFactory, + copier: CopierFixture, + case: tuple[str, bool | None, str], +) -> None: + """Every rendered RUSTFLAGS override preserves the selected Polonius state.""" + flavour, enable_polonius, dev_target = case + project = render_project( + tmp_path_factory.mktemp("polonius-contract"), + copier, + project_name="InvariantProperty", + package_name="invariant_property", + flavour=flavour, + enable_polonius=enable_polonius, + dev_target=dev_target, + ) + expected_enabled = flavour == APP if enable_polonius is None else enable_polonius + release_path = project / ".github/workflows/release.yml" + polonius_path = project / "docs/polonius.md" + + assert_polonius_toolchain_contracts( + enabled=expected_enabled, + dev_target=dev_target, + cargo_config=read_generated_text(project / ".cargo/config.toml"), + makefile=read_generated_text(project / "Makefile"), + rust_toolchain=read_generated_text(project / "rust-toolchain.toml"), + ci_workflow=read_generated_text(project / ".github/workflows/ci.yml"), + coverage_main_workflow=read_generated_text( + project / ".github/workflows/coverage-main.yml" + ), + release_workflow=( + read_generated_text(release_path) if release_path.exists() else None + ), + agents=read_generated_text(project / "AGENTS.md"), + readme=read_generated_text(project / "README.md"), + docs_contents=read_generated_text(project / "docs/contents.md"), + repository_layout=read_generated_text(project / "docs/repository-layout.md"), + developers_guide=read_generated_text(project / "docs/developers-guide.md"), + users_guide=read_generated_text(project / "docs/users-guide.md"), + polonius_doc=( + read_generated_text(polonius_path) if polonius_path.exists() else None + ), + ) + + def test_ci_contract_rejects_unguarded_duplicate_audit_step( tmp_path: Path, copier: CopierFixture ) -> None: diff --git a/typos.local.toml b/typos.local.toml index 2c785f7..3196d69 100644 --- a/typos.local.toml +++ b/typos.local.toml @@ -6,7 +6,7 @@ schema = 1 stems = [] [words] -accepted = ["Flavored", "mold"] +accepted = ["Flavored", "mold", "Polonius"] [words.corrections] diff --git a/typos.toml b/typos.toml index de5cdf2..c231e6c 100644 --- a/typos.toml +++ b/typos.toml @@ -41,6 +41,7 @@ extend-ignore-re = [ [default.extend-words] "ASO" = "ASO" "Flavored" = "Flavored" +"Polonius" = "Polonius" "absolutisable" = "absolutizable" "absolutisation" = "absolutization" "absolutisations" = "absolutizations"