diff --git a/AGENTS.md b/AGENTS.md index deb40b334..c0bd61a40 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -197,8 +197,9 @@ working on the Rust portions of the project: ``` validating formatting across the Rust workspace without modifying files. - - `make lint` executes its Rustdoc and Clippy steps through - `RUST_DEBUG_CARGO`, then runs Whitaker separately: + - `make lint` runs `lint-clippy`, `lint-whitaker`, and spelling in sequence. + Its Rustdoc and Clippy leaf runs through `RUST_DEBUG_CARGO`; Whitaker is a + separate fragment-free leaf: ```sh cd rust && RUSTDOCFLAGS="$(RUSTDOC_FLAGS)" \ diff --git a/Makefile b/Makefile index c84915bae..2a3f30da6 100644 --- a/Makefile +++ b/Makefile @@ -73,8 +73,23 @@ TEST_JOBS ?= 1 TEST_FLAGS ?= $(CARGO_FLAGS) --jobs $(TEST_JOBS) DOCTEST_FLAGS ?= --workspace --doc --all-features $(BUILD_JOBS) TEST_RUSTFLAGS ?= $(RUST_FLAGS) -C codegen-units=1 -WHITAKER_CARGO_FLAGS ?= $(CARGO_FLAGS) --jobs 1 WHITAKER_RUSTFLAGS ?= $(RUST_FLAGS) -C codegen-units=1 +# Whitaker's `--all` selects its lint libraries, rather than Cargo packages. +# Keep the package boundary explicit and Makefile-owned so every Rust member is +# checked even when the wrapper's library-selection semantics change. +override WHITAKER_PACKAGES := cuprum-rust cuprum-streams cuprum-native-io +# Both the package list and the flags built from it are `override`n. Marking +# only the list would leave the derived flags caller-writable, and a caller +# could pass `WHITAKER_PACKAGE_FLAGS=--package evil` to displace every audited +# package while the list itself still looked intact. +override WHITAKER_PACKAGE_FLAGS := $(foreach package,$(WHITAKER_PACKAGES),--package $(package)) +# The audited Whitaker command is Makefile-owned end to end. It does not forward +# `CARGO_FLAGS`: that variable is caller-overridable, so a caller could pass +# `--config ../tools/dev-fast/config.toml` after Whitaker's `--` separator and +# quietly acquire the development fragment on a route that must stay +# fragment-free. The flags below are the whole of what Whitaker may receive. +override WHITAKER_CARGO_FLAGS := $(WHITAKER_PACKAGE_FLAGS) \ + --all-targets --all-features --jobs 1 # Extra flags for the `maturin develop` invocation in the `develop` target. # Empty by default: a debug build is what contributors and the extension-tests # job want. The benchmark ratchet needs an optimized build, and an optimized @@ -201,15 +216,30 @@ SKYLOS_WHITELIST_LOCK ?= .skylos-whitelist.lock MDLINT_FILES_FIND = bash -o pipefail -c 'git ls-files -z --cached --others --exclude-standard -- "$$@" | while IFS= read -r -d "" markdown_file; do if [ -f "$$markdown_file" ] && [ ! -L "$$markdown_file" ]; then case "$$markdown_file" in -*) printf "./%s\0" "$$markdown_file" ;; *) printf "%s\0" "$$markdown_file" ;; esac; fi; done' -- $(MARKDOWN_GLOBS) MDLINT_FIX_COMMAND = unset FORCE_COLOR; $(LOCAL_TOOL_ENV) xargs -0 -r $(MDLINT) --fix < "$$markdown_files" MDLINT_CHECK_COMMAND = unset FORCE_COLOR; $(LOCAL_TOOL_ENV) xargs -0 -r $(MDLINT) < "$$markdown_files" -.PHONY: help all clean build build-release lint python-lint rust-lint \ - github-actions-lint \ +.PHONY: help all clean build build-release lint python-lint rust-lint lint-clippy \ + lint-whitaker github-actions-lint \ lint-windows fmt check-fmt \ markdownlint spelling nixie test test-python test-rust loom typecheck \ test-extension test-markdown-format develop makeutil skylos-allow \ test-dev-fast-contract dev-fast-check dev-build dev-test msrv-check \ benchmark-micro benchmark-e2e \ $(TOOLS) $(VENV_TOOLS) -.NOTPARALLEL: lint +# Serialize the lint hierarchy. Each aggregate target lists its leaves as plain +# prerequisites, so a caller's `-f` override file is honoured directly by the +# one Make process that reads it, with no forwarding to get wrong. +# +# `.WAIT` would express the ordering in the prerequisite list itself, but it is +# GNU Make 4.4-only. `.NOTPARALLEL` is not: the prereq-less form dates from Make +# 3.79, and `.NOTPARALLEL` with prerequisites from 4.4. Naming the aggregates +# here therefore works on both, and degrades safely in the direction that +# matters: Make 4.3 ignores the prerequisites and serializes the whole run, +# while Make 4.4 serializes exactly the two named subtrees. Both were verified +# against real 4.3 and 4.4 binaries, so the hosted runners' Make needs no pin. +# +# Leaves are deliberately not named here. Naming `lint` alone would leave +# `rust-lint`'s own prerequisites free to interleave under `-j`; naming both +# subtrees is what fixes the order without serializing unrelated work. +.NOTPARALLEL: lint rust-lint .DEFAULT_GOAL := all @@ -301,11 +331,13 @@ python-lint: ruff uv ## Run Ruff, interrogate, pylint, df12-python-lints, and am $(AMBRLEAKS) cuprum/unittests scripts/tests tests $(SKYLOS) $(SKYLOS_PRODUCTION_TARGETS) --exclude $(SKYLOS_EXCLUDE_FOLDERS) --category dead_code --gate --format concise --no-upload --no-provenance --no-grep-verify -rust-lint: $(RUST_DEBUG_PREREQUISITE) ## Run Rust documentation, Clippy, Whitaker, and spelling checks +rust-lint: lint-clippy lint-whitaker spelling ## Run Rust documentation, Clippy, Whitaker, and spelling checks + +lint-clippy: $(RUST_DEBUG_PREREQUISITE) ## Run Rust documentation and Clippy cd $(RUST_DIR) && RUSTDOCFLAGS="$(RUSTDOC_FLAGS)" $(RUST_DEBUG_CARGO) doc --no-deps $(DOC_FLAGS) && $(RUST_DEBUG_CARGO) clippy $(CLIPPY_FLAGS) - @if ! $(LOCAL_TOOL_ENV) command -v $(WHITAKER) >/dev/null 2>&1; then echo "whitaker is required for linting. Install it before running this target." >&2; exit 1; fi + +lint-whitaker: ## Run Whitaker for every Rust workspace package cd $(RUST_DIR) && $(LOCAL_TOOL_ENV) RUSTFLAGS="$(WHITAKER_RUSTFLAGS)" $(WHITAKER) --all -- $(WHITAKER_CARGO_FLAGS) - +$(MAKE) spelling skylos-allow: export SKYLOS_SYMBOL = $(value SYMBOL) skylos-allow: export SKYLOS_REASON = $(value REASON) diff --git a/cuprum/unittests/test_dev_fast_contract.py b/cuprum/unittests/test_dev_fast_contract.py index f5e65d134..dc6c1cbf3 100644 --- a/cuprum/unittests/test_dev_fast_contract.py +++ b/cuprum/unittests/test_dev_fast_contract.py @@ -22,6 +22,12 @@ FRAGMENT_SHA256 = "8619efda5ea1c3232f413ae96ff56869ab6b2b7cd5bdef5a001ad16ebeac23a5" FRAGMENT = "tools/dev-fast/config.toml" RUST_MEMBERS = ("cuprum-rust", "cuprum-streams", "cuprum-native-io") +# The Clippy leaf's own arguments are identical on every host; only the Cargo +# route in front of them varies. `RUST_DEBUG_CARGO` prepends the dev-fast +# toolchain and its `--config` fragment on Linux and nothing at all elsewhere, +# so anchoring an order assertion on that fragment raises ValueError on macOS +# and Windows even though the leaf order under test is unchanged. +_CLIPPY_LEAF = " clippy --all-targets" SAFE_MATURIN_FLAGS = st.sampled_from(( "--release", "-r", @@ -300,6 +306,62 @@ def test_whitaker_and_windows_lint_remain_fragment_free() -> None: ) +def test_portable_lint_orchestration_preserves_leaf_order() -> None: + """The aggregate lint gates avoid GNU Make 4.4-only prerequisite markers.""" + makefile = (repo_root() / "Makefile").read_text(encoding="utf-8") + output = _dry_run("lint") + + # Strip comments before looking for `.WAIT`: the Makefile describes the + # marker in prose precisely to record why it is not used, and a raw text + # search would match that explanation instead of any real prerequisite. + directives = "\n".join( + line for line in makefile.splitlines() if not line.lstrip().startswith("#") + ) + assert ".WAIT" not in directives, "hosted Make must not require GNU Make 4.4" + # `.NOTPARALLEL` with prerequisites is itself GNU Make 4.4-only, so the + # ordering cannot rest on it alone. Make 4.3 ignores the prerequisites and + # serializes the whole run instead, which preserves the order either way. + assert ".NOTPARALLEL: lint rust-lint" in directives, ( + "both lint aggregates must be serialized, present on GNU Make 4.3 and 4.4" + ) + assert "RECURSIVE_MAKE" not in makefile, ( + "lint leaves must run in one Make process so caller `-f` files are " + "honoured without forwarding a `MAKEFILE_LIST` that cannot distinguish " + "`-f` inputs from included files" + ) + python_lint = output.index("python-lint") + clippy = output.index(_CLIPPY_LEAF) + whitaker = output.index("whitaker --all --") + spelling = output.index("typos-config-builder gate --repository . --scope all") + workflow_lint = output.index("yamllint --strict --config-file") + + assert python_lint < clippy < whitaker < spelling < workflow_lint, ( + "lint must serialize Python, Rust leaves, and GitHub Actions validation" + ) + + +def test_leaf_order_is_identical_on_both_cargo_routes() -> None: + """The serialized order must not depend on which Cargo route a host selects. + + The leaf order is a property of the prerequisite graph, not of the toolchain + in front of it. Asserting it on the Linux route alone would let a change + that reorders only the stable route pass unnoticed, so both selectors are + checked here. + """ + for selector in ("yes", ""): + output = _dry_run("lint", variables={"DEV_FAST_HOST_IS_LINUX": selector}) + python_lint = output.index("python-lint") + clippy = output.index(_CLIPPY_LEAF) + whitaker = output.index("whitaker --all --") + spelling = output.index("typos-config-builder gate --repository . --scope all") + workflow_lint = output.index("yamllint --strict --config-file") + + assert python_lint < clippy < whitaker < spelling < workflow_lint, ( + f"lint must serialize its leaves identically with " + f"DEV_FAST_HOST_IS_LINUX={selector!r}" + ) + + def test_msrv_verification_keeps_the_stable_route_fragment_free() -> None: """The accelerated lint compiler cannot replace published-MSRV verification.""" output = _dry_run("msrv-check") diff --git a/cuprum/unittests/test_skylos_lint_contract.py b/cuprum/unittests/test_skylos_lint_contract.py index d623f7bba..d912c3e0d 100644 --- a/cuprum/unittests/test_skylos_lint_contract.py +++ b/cuprum/unittests/test_skylos_lint_contract.py @@ -112,11 +112,11 @@ "$${SKYLOS_REASON}", ) _SKYLOS_WHITELIST_LOCK_TOKENS: typ.Final = (".skylos-whitelist.lock",) -_LINT_PREREQUISITES: typ.Final = ( - "python-lint", - "rust-lint", - "github-actions-lint", -) +_LINT_PREREQUISITES: typ.Final = ("python-lint", "rust-lint", "github-actions-lint") +# `lint` carries no recipe: the leaves are prerequisites, and `.NOTPARALLEL` +# serializes them. Running the leaves in the same Make process is what keeps a +# caller's `-f` override file effective without forwarding `MAKEFILE_LIST`, +# which cannot distinguish `-f` inputs from files pulled in by `include`. _DOCUMENTED_WHITELIST_NAMES: typ.Final = frozenset({"_check_rust_available"}) _RUNTIME_PARAMETER_ENTRY_POINTS: typ.Final = frozenset({ "cuprum.adapters.metrics_adapter.InMemoryMetrics.inc_counter.labels", @@ -305,7 +305,11 @@ def test_lint_recipe_runs_the_production_dead_code_gate() -> None: subject="lint target prerequisites", ) assert lint_prerequisites == _LINT_PREREQUISITES, ( - "Skylos lint delegation contract must retain the Python lint target" + "lint must sequence its leaves as prerequisites in a single Make process" + ) + lint_recipes = _sole_recipe_rule("lint", require_recipes=False).get("recipes") + assert _objects(lint_recipes, subject="lint recipes") == [], ( + "lint must delegate to its leaves as prerequisites, not recursive sub-makes" ) skylos_commands = [ command diff --git a/cuprum/unittests/test_whitaker_make_contract.py b/cuprum/unittests/test_whitaker_make_contract.py new file mode 100644 index 000000000..70720a1b7 --- /dev/null +++ b/cuprum/unittests/test_whitaker_make_contract.py @@ -0,0 +1,295 @@ +"""Exercise the fail-closed Whitaker Makefile boundary.""" + +from __future__ import annotations + +import os +import shutil +import subprocess # ruff: ignore[suspicious-subprocess-import] - controlled Make argv. +import sys +import tomllib +import typing as typ + +import pytest + +from tests.helpers.docs import repo_root + +if typ.TYPE_CHECKING: + from collections import abc as cabc + from pathlib import Path + + +# The Clippy leaf's own arguments are the same on every host; the Cargo route in +# front of them is not. `RUST_DEBUG_CARGO` prepends the dev-fast toolchain and +# its `--config` fragment only on Linux, so anchoring an order assertion on that +# fragment raises ValueError wherever the suite runs on macOS or Windows. +_CLIPPY_LEAF = " clippy --all-targets" + + +def _workspace_packages() -> tuple[str, ...]: + """Return the Rust workspace members from the Cargo manifest itself. + + Reading `rust/Cargo.toml` instead of repeating its names here is what makes + the every-package promise checkable. A member added to the manifest changes + this tuple, so the Makefile's own list can no longer drift out of step with + the workspace while a duplicated literal keeps the contract tests passing. + + Returns + ------- + tuple[str, ...] + The workspace member names, in manifest order. + """ + manifest = tomllib.loads( + (repo_root() / "rust" / "Cargo.toml").read_text(encoding="utf-8") + ) + workspace = manifest["workspace"] + assert isinstance(workspace, dict), "rust/Cargo.toml must declare a workspace" + members = workspace["members"] + assert isinstance(members, list), "the workspace must declare its members" + assert all(isinstance(member, str) for member in members), ( + "every workspace member must be a package name" + ) + return tuple(members) + + +def _expected_cargo_arguments() -> list[str]: + """Return the Cargo arguments every audited Whitaker binding must forward.""" + return [ + *[ + argument + for package in _workspace_packages() + for argument in ("--package", package) + ], + "--all-targets", + "--all-features", + "--jobs", + "1", + ] + + +def _make_executable() -> str: + """Return the GNU Make executable required by the routing contract.""" + executable = shutil.which("make") + assert executable is not None, "Whitaker Makefile tests require GNU Make" + return executable + + +def _dry_run( + *, + variables: dict[str, str] | None = None, + target: str = "lint-whitaker", + makefiles: cabc.Sequence[Path] = (), +) -> str: + """Return the evaluated Whitaker recipe with inert caller tools.""" + completed = subprocess.run( # ruff: ignore[subprocess-without-shell-equals-true] - fixed Make argv. + [ + _make_executable(), + "--dry-run", + *[argument for path in makefiles for argument in ("-f", str(path))], + "CARGO=probe-cargo", + "WHITAKER=probe-whitaker", + *(f"{key}={value}" for key, value in (variables or {}).items()), + target, + ], + capture_output=True, + check=True, + cwd=repo_root(), + env={**os.environ, "MAKEFLAGS": ""}, + text=True, + ) + return completed.stdout + + +def _whitaker_command(output: str) -> str: + """Extract the one binding invocation from Make's evaluated recipe.""" + return next( + line for line in output.splitlines() if "probe-whitaker --all --" in line + ) + + +def test_whitaker_target_covers_the_exact_workspace_packages() -> None: + """Package selection reaches Cargo after the wrapper separator.""" + command = _whitaker_command(_dry_run()) + wrapper, separator, cargo_arguments = command.partition(" -- ") + + assert separator == " -- ", "Whitaker must receive Cargo flags after `--`" + assert wrapper.endswith("probe-whitaker --all"), ( + "Whitaker must load the full lint suite before forwarding Cargo flags" + ) + assert cargo_arguments.split() == _expected_cargo_arguments(), ( + "the binding must enumerate every Rust workspace package exactly once, " + "matching the members rust/Cargo.toml declares" + ) + + +def test_whitaker_package_scope_cannot_be_caller_overridden() -> None: + """An arbitrary Make variable cannot remove an audited workspace package.""" + command = _whitaker_command(_dry_run(variables={"WHITAKER_PACKAGES": "other"})) + + assert "--package other" not in command, "caller scope must not reach Whitaker" + assert all( + f"--package {package}" in command for package in _workspace_packages() + ), "every audited package must remain in the binding" + + +def test_whitaker_target_is_direct_and_fragment_free() -> None: + """The binding cannot be skipped, masked, or routed through dev-fast.""" + command = _whitaker_command(_dry_run()) + + assert "command -v" not in command, "the binding must not have an existence guard" + assert "||" not in command, "the binding must not ignore a wrapper failure" + assert "|" not in command, "the binding must not mask a wrapper failure" + assert "--config" not in command, "Whitaker must not select a Cargo fragment" + assert "nightly-2026-08-23" not in command, ( + "Whitaker must retain its independently pinned verifier toolchain" + ) + + +def test_caller_cargo_flags_cannot_reach_whitaker() -> None: + """A caller-supplied `CARGO_FLAGS` must not pass Whitaker's `--` separator. + + `CARGO_FLAGS` is caller-overridable, so forwarding it would let a caller + append `--config ../tools/dev-fast/config.toml` and quietly acquire the + development fragment on a route that must stay fragment-free. + """ + command = _whitaker_command( + _dry_run( + variables={ + "CARGO_FLAGS": "--all-targets --config ../tools/dev-fast/config.toml" + } + ) + ) + + assert "--config" not in command, "caller Cargo flags must not reach Whitaker" + assert command.split(" -- ", 1)[1].split() == _expected_cargo_arguments(), ( + "the audited flag list must be the whole of what Whitaker receives" + ) + + +def test_caller_package_flags_cannot_displace_audited_packages() -> None: + """The derived package flags must be `override`n, not just the package list. + + Marking only `WHITAKER_PACKAGES` leaves the flags built from it writable, so + a caller can pass `WHITAKER_PACKAGE_FLAGS=--package evil` to displace every + audited package while the list itself still looks intact. + """ + command = _whitaker_command( + _dry_run(variables={"WHITAKER_PACKAGE_FLAGS": "--package evil"}) + ) + + assert "--package evil" not in command, ( + "caller-derived package flags must not displace audited packages" + ) + assert command.split(" -- ", 1)[1].split() == _expected_cargo_arguments(), ( + "every audited package must survive a caller-supplied flag override" + ) + + +def test_caller_cargo_flag_variable_cannot_reach_whitaker() -> None: + """`WHITAKER_CARGO_FLAGS` itself is Makefile-owned, not merely its inputs. + + The list and the flags derived from it are both `override`n, but the final + variable the recipe actually expands is a separate name. Without an + `override` there too, a caller could replace the whole audited flag list in + one word and never touch either protected intermediate. + """ + command = _whitaker_command( + _dry_run( + variables={ + "WHITAKER_CARGO_FLAGS": ( + "--package evil --config ../tools/dev-fast/config.toml" + ) + } + ) + ) + + assert "--package evil" not in command, ( + "a caller must not replace the audited Whitaker flag list wholesale" + ) + assert "--config" not in command, ( + "a replacement flag list must not smuggle in the development fragment" + ) + assert command.split(" -- ", 1)[1].split() == _expected_cargo_arguments(), ( + "the Makefile must own the final Whitaker flag variable end to end" + ) + + +def test_rust_lint_runs_clippy_whitaker_and_spelling_in_order() -> None: + """The aggregate Rust gate preserves the intended sequential leaf order.""" + output = _dry_run(target="rust-lint") + clippy = output.index(_CLIPPY_LEAF) + whitaker = output.index("probe-whitaker --all --") + spelling = output.index("typos-config-builder gate --repository . --scope all") + + assert clippy < whitaker < spelling, ( + "rust-lint must finish Clippy before Whitaker and spelling" + ) + + +def test_lint_target_runs_the_leaf_hierarchy_without_wait_markers() -> None: + """The hosted Make route serializes leaves without GNU Make 4.4 syntax.""" + makefile = (repo_root() / "Makefile").read_text(encoding="utf-8") + output = _dry_run(target="lint") + + # Strip comments first: the Makefile names `.WAIT` in prose to record why it + # is deliberately unused, and a raw search would match that explanation. + directives = "\n".join( + line for line in makefile.splitlines() if not line.lstrip().startswith("#") + ) + assert ".WAIT" not in directives, "the binding must support the hosted Make" + assert output.index("python-lint") < output.index("probe-whitaker --all --"), ( + "Python lint must complete before the Whitaker leaf starts" + ) + assert output.index("probe-whitaker --all --") < output.index( + "yamllint --strict --config-file" + ), "GitHub Actions lint must run after the Rust leaves" + + +@pytest.mark.parametrize("target", ["lint-clippy", "rust-lint", "lint"]) +def test_included_override_fragments_apply_exactly_once( + tmp_path: Path, target: str +) -> None: + """A caller fragment reached through `include` must not be applied twice. + + GNU Make records `-f` inputs and files reached through `include` in the same + `MAKEFILE_LIST`, with nothing to tell them apart. Forwarding that list to a + sub-make therefore re-reads an included fragment once per recursion level, + so a non-idempotent `CARGO_FLAGS += ...` compounds: `--fragment-marker` + appeared 3 times at `lint` and twice at `rust-lint` before this regression + test. Sequencing the leaves inside one Make process removes the sub-make + and the second read with it. + """ + fragment = tmp_path / "common.mk" + fragment.write_text("CLIPPY_FLAGS += --fragment-marker\n", encoding="utf-8") + override = tmp_path / "override.mk" + override.write_text(f"include {fragment}\n", encoding="utf-8") + + # The repository Makefile must come first: a bare `-f override.mk` replaces + # the implicit default rather than augmenting it, leaving `lint` undefined. + output = _dry_run(target=target, makefiles=(repo_root() / "Makefile", override)) + + assert output.count("--fragment-marker") == 1, ( + f"{target} must apply a caller's included fragment exactly once" + ) + + +def test_whitaker_failure_propagates_through_make(tmp_path: Path) -> None: + """A failing suite executable must fail the leaf target unchanged by a guard.""" + failing_whitaker = tmp_path / "failing-whitaker" + failing_whitaker.write_text( + f"#!{sys.executable}\nimport sys\nsys.exit(73)\n", encoding="utf-8" + ) + failing_whitaker.chmod(0o755) + + completed = subprocess.run( # ruff: ignore[subprocess-without-shell-equals-true] - fixed Make argv. + [_make_executable(), "lint-whitaker", f"WHITAKER={failing_whitaker}"], + capture_output=True, + check=False, + cwd=repo_root(), + env={**os.environ, "MAKEFLAGS": ""}, + text=True, + ) + + assert completed.returncode != 0, "a failing Whitaker executable must fail Make" + assert "Error 73" in completed.stderr, ( + "Make must report the wrapper's original non-zero status" + ) diff --git a/cuprum/unittests/test_workflow_lint.py b/cuprum/unittests/test_workflow_lint.py index a1fb51231..327f1b65c 100644 --- a/cuprum/unittests/test_workflow_lint.py +++ b/cuprum/unittests/test_workflow_lint.py @@ -279,7 +279,11 @@ def test_the_lint_target_runs_the_workflow_linters(tmp_path: pth.Path) -> None: ) overrides = tmp_path / "lint-target-overrides.mk" overrides.write_text( - ".PHONY: python-lint rust-lint\npython-lint:\n\t@:\nrust-lint:\n\t@:\n", + ".PHONY: python-lint lint-clippy lint-whitaker spelling\n" + "python-lint:\n\t@:\n" + "lint-clippy:\n\t@:\n" + "lint-whitaker:\n\t@:\n" + "spelling:\n\t@:\n", encoding="utf-8", ) diff --git a/docs/developers-guide.md b/docs/developers-guide.md index b479bc0aa..ea3e3112a 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -381,7 +381,61 @@ verification, MSRV, and Whitaker commands, together with macOS and Windows, retain their prescribed fragment-free or separately pinned toolchains. The stable pin declares `rustfmt`, `clippy`, and `rust-analyzer` for local maintenance. The Whitaker action receives `WHITAKER_INSTALLER_VERSION` from the -job environment (`0.2.7`, the workflow's configured installer version). +job environment (`0.2.7`, the workflow's configured installer version). The +Makefile runs `lint-clippy`, `lint-whitaker`, and spelling sequentially; +`lint-whitaker` passes Cargo `--package` arguments for `cuprum-rust`, +`cuprum-streams`, and `cuprum-native-io` after Whitaker's `--` separator. +Whitaker's `--all` chooses lint libraries, not workspace packages. + +Whitaker's lint suite is a rolling release, and that is the distribution model +rather than a defect. Its `rolling-release.yml` runs on every push to `main` +and publishes prebuilt lint libraries for that commit as +`whitaker-lints---.tar.zst`, together with a per-target +manifest naming the same `git_sha`. The source revision is therefore part of +the artefact identity: a suite is addressed by commit SHA, not by version +number. The installer verifies each downloaded archive's SHA-256 against that +manifest before extraction, because `VerificationPolicy::default()` requires a +checksum. + +Retention is the limit, and it is narrower than the tag name suggests. All +assets live under one `rolling` tag that is *moved* to the newest commit rather +than recreated: `scripts/publish-rolling-release.sh` uploads the new archives, +replaces the manifests, repoints the tag, and only then deletes the superseded +`whitaker-lints-*` assets. That ordering is deliberate — an earlier +delete-then-recreate pair left the tag and every asset absent for six to seven +seconds per publish, and a downstream consumer resolved `rolling` inside that +gap and silently fell back to a source build +([Whitaker PR 413](https://github.com/leynos/whitaker/pull/413)). By the end +of each publish only the newest commit's lint archives remain, so a `` +names an immutable artefact but not a permanently addressable one. + +The local binding is fragment-free, and it is fail-closed about scope. The +package list, the `--package` flags derived from it, and the final +`WHITAKER_CARGO_FLAGS` the recipe expands are all `override`n, so no +caller-supplied variable can narrow the audited package set or replace the flag +list wholesale. The Makefile's list is checked against the workspace itself: +`cuprum/unittests/test_whitaker_make_contract.py` reads the members from +`rust/Cargo.toml` rather than repeating their names, so adding a member without +extending the list fails the contract instead of silently skipping it. The +binding cannot by itself guarantee, however, that the gate consumed a prebuilt, +commit-addressed suite. Three upstream contracts are outstanding, and none +argues against the rolling model. +[Whitaker issue 403](https://github.com/leynos/whitaker/issues/403) covers the +cost of pinning: `--suite-version` (added in installer 0.2.8, and so absent +from the 0.2.7 this repository pins) lets a consumer select a commit +(`SuiteRef`), but prebuilt lint archives are published only for the branch tip, +so a pin forces a source build — the CLI documents the trade as install time +against reproducibility. +[shared-actions issue 499](https://github.com/leynos/shared-actions/issues/499) +covers fail-closed installation: the installer maps prebuilt-suite failures to +`Fallback`, and its dependency install falls back to `cargo binstall` and then +`cargo install --locked`, so a job can silently compile rather than fail. The +switch that would forbid this, `--no-source-fallback`, is on Whitaker's `main` +and is in no release yet. +[Whitaker issue 425](https://github.com/leynos/whitaker/issues/425) asks for a +retained provenance record binding the installed library bytes to the source +revision and archive. Until those contracts land, this gate must remain a draft +integration layer rather than a claim of reproducible binary-only CI. cargo-nextest is no longer installed here at all. The coverage job is the only place it runs, and the shared action installs it from checksummed official