From 8f19f8282e431b6f2a68edaf749643914de1b055 Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 16 Sep 2026 19:48:20 +0200 Subject: [PATCH 01/12] Route debug builds through dev-fast Select the approved nightly Cranelift configuration for compatible Linux debug workflows while retaining stable routing for release, coverage, verification, Whitaker, MSRV, and non-Linux builds. Record the Rust 1.85.0 compatibility contract, provision the checked mold binary in CI, and bind the routing and spelling-policy behaviour with local contract tests. --- .github/actions/setup-dev-fast/action.yml | 31 ++ .github/workflows/ci.yml | 13 + AGENTS.md | 10 +- Makefile | 54 +++- cuprum/unittests/test_dev_fast_contract.py | 288 ++++++++++++++++++ cuprum/unittests/test_workflow_lint.py | 22 +- docs/developers-guide.md | 41 +++ docs/repository-layout.md | 2 + rust/Cargo.toml | 5 + rust/cuprum-native-io/Cargo.toml | 1 + rust/cuprum-rust/Cargo.toml | 1 + rust/cuprum-streams/Cargo.toml | 1 + .../ui/fail/replace_scoped_writer.stderr | 3 + tools/dev-fast/cargo | 35 +++ tools/dev-fast/config.toml | 30 ++ tools/mold/SHA256SUMS | 2 + tools/mold/VERSION | 1 + typos.local.toml | 9 + typos.toml | 7 + 19 files changed, 545 insertions(+), 11 deletions(-) create mode 100644 .github/actions/setup-dev-fast/action.yml create mode 100644 cuprum/unittests/test_dev_fast_contract.py create mode 100755 tools/dev-fast/cargo create mode 100644 tools/dev-fast/config.toml create mode 100644 tools/mold/SHA256SUMS create mode 100644 tools/mold/VERSION diff --git a/.github/actions/setup-dev-fast/action.yml b/.github/actions/setup-dev-fast/action.yml new file mode 100644 index 000000000..18cd582ee --- /dev/null +++ b/.github/actions/setup-dev-fast/action.yml @@ -0,0 +1,31 @@ +--- +name: Set up Linux dev-fast prerequisites +description: Install the pinned Cranelift component and checksum-verified mold binary. +runs: + using: composite + steps: + - name: Install the pinned backend and linker + shell: bash + run: | + set -euo pipefail + toolchain='nightly-2026-08-23' + component='rustc-codegen-cranelift' + version="$(tr -d '\r\n' < tools/mold/VERSION)" + case "$(uname -m)" in + x86_64|amd64) architecture=x86_64 ;; + aarch64|arm64) architecture=aarch64 ;; + *) echo "unsupported mold architecture: $(uname -m)" >&2; exit 1 ;; + esac + archive="mold-${version}-${architecture}-linux.tar.gz" + expected="$(awk -v archive="${archive}" '$2 == archive { print $1 }' tools/mold/SHA256SUMS)" + test "$(printf '%s\n' "${expected}" | sed '/^$/d' | wc -l)" -eq 1 + rustup toolchain install "${toolchain}" --profile minimal + rustup component add "${component}" --toolchain "${toolchain}" + curl --fail --location --silent --show-error \ + --output "${RUNNER_TEMP}/${archive}" \ + "https://github.com/rui314/mold/releases/download/v${version}/${archive}" + printf '%s %s\n' "${expected}" "${RUNNER_TEMP}/${archive}" | sha256sum --check --status + mkdir --parents "${HOME}/.local" + tar --extract --gzip --strip-components=1 --directory "${HOME}/.local" \ + --file "${RUNNER_TEMP}/${archive}" + echo "${HOME}/.local/bin" >> "${GITHUB_PATH}" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7548cbc87..729777321 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -254,6 +254,11 @@ jobs: cache-provider: external use-sccache: 'false' + # Only the Linux debug route selects Cranelift and mold. The stable + # toolchain remains selected for Whitaker and every other lane. + - name: Install Linux dev-fast prerequisites + uses: ./.github/actions/setup-dev-fast + - name: Install Whitaker uses: leynos/shared-actions/.github/actions/install-whitaker@c5a54701c8603a0fa756a6b34c49bc2af75a6c11 with: @@ -290,6 +295,11 @@ jobs: - name: Run lint, including Skylos dead-code detection run: /usr/bin/make ACTIONLINT="$GITHUB_WORKSPACE/actionlint" lint + # The accelerated lint pass uses a maintenance nightly, so retain a + # separate fragment-free compilation check on the published Rust MSRV. + - name: Verify Rust MSRV + run: make msrv-check + # The Windows wheel build compiles this arm but without `-D warnings`, # so it catches a Windows arm that fails to build and nothing else. This # step is what holds the `#[cfg(windows)]` and `#[cfg(unix)]` branches to @@ -716,6 +726,9 @@ jobs: use-sccache: 'false' install-binstall: 'false' + - name: Install Linux dev-fast prerequisites + uses: ./.github/actions/setup-dev-fast + - name: Prepare Cargo dependencies run: cargo fetch --locked --manifest-path rust/cuprum-rust/Cargo.toml diff --git a/AGENTS.md b/AGENTS.md index ad61e00d1..8e950c0cc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -226,8 +226,14 @@ working on the Rust portions of the project: - The project toolchain stays pinned to Rust `1.85.0` and declares `rustfmt`, `clippy`, and `rust-analyzer`. CI also provisions `nightly-2026-05-28` for the maintenance formatter, then restores the project toolchain. The current - `fmt` and `check-fmt` recipes still use the project toolchain; the nightly is - not a build, test, lint, or documentation toolchain. + `fmt` and `check-fmt` recipes still use the project toolchain. Linux-only + debug Rust work additionally uses the separately pinned dev-fast nightly + through `tools/dev-fast/config.toml`; run `make dev-fast-check` first. The + stable toolchain remains mandatory for release, coverage, Whitaker, the + explicit `make msrv-check` verification target, and the macOS and Windows + platform alternatives. All Rust packages inherit Cargo's + `rust-version = "1.85.0"` contract, which also keeps MSRV-aware Clippy + findings valid when debug linting uses the maintenance nightly. - Clippy warnings MUST be disallowed. - Fix any warnings emitted during tests in code instead of silencing them. - Where a function is too long, extract meaningfully named helper functions diff --git a/Makefile b/Makefile index 11bf6082d..b66bbf7cd 100644 --- a/Makefile +++ b/Makefile @@ -16,6 +16,30 @@ TOOLS = $(MDLINT) $(YAMLLINT) $(ACTIONLINT) uv VENV_TOOLS = pytest ruff RUST_DIR ?= rust CARGO ?= cargo +# Rust 1.85.0 remains Cuprum's published compatibility toolchain. This +# separately pinned nightly is selected only for compatible Linux debug work. +MSRV_TOOLCHAIN ?= 1.85.0 +DEV_FAST_TOOLCHAIN ?= nightly-2026-08-23 +DEV_FAST_CRANELIFT_COMPONENT ?= rustc-codegen-cranelift +DEV_FAST_CONFIG ?= tools/dev-fast/config.toml +DEV_FAST_RUST_CONFIG ?= ../$(DEV_FAST_CONFIG) +DEV_FAST_CARGO_BRIDGE ?= tools/dev-fast/cargo +DEV_FAST_ABSOLUTE_CONFIG := $(abspath $(DEV_FAST_CONFIG)) +DEV_FAST_HOST_IS_LINUX := $(if $(filter Linux,$(shell uname -s)),yes) +# `CARGO` remains a caller-injectable single executable. Maturin can select +# that executable but cannot pass Cargo's configuration-file `--config` form. +DEV_FAST_CARGO_COMMAND = RUSTUP_TOOLCHAIN=$(DEV_FAST_TOOLCHAIN) $(CARGO) --config $(DEV_FAST_RUST_CONFIG) +MSRV_CARGO_COMMAND = RUSTUP_TOOLCHAIN=$(MSRV_TOOLCHAIN) $(CARGO) +RUST_DEBUG_CARGO = $(if $(DEV_FAST_HOST_IS_LINUX),$(DEV_FAST_CARGO_COMMAND),$(CARGO)) +RUST_DEBUG_PREREQUISITE = $(if $(DEV_FAST_HOST_IS_LINUX),dev-fast-check) +# Parse release spellings without re-parsing caller-provided flags in a shell. +maturin_release_profile = $(strip $(if $(1),$(if $(and $(filter --profile,$(firstword $(1))),$(filter release,$(word 2,$(1)))),yes,$(call maturin_release_profile,$(wordlist 2,$(words $(1)),$(1)))))) +MATURIN_DEVELOP_IS_RELEASE := $(strip $(filter --release -r --profile=release,$(MATURIN_DEVELOP_FLAGS)) $(call maturin_release_profile,$(MATURIN_DEVELOP_FLAGS))) +DEVELOP_DEV_FAST_ENABLED := $(if $(DEV_FAST_HOST_IS_LINUX),$(if $(MATURIN_DEVELOP_IS_RELEASE),,yes)) +DEVELOP_DEV_FAST_PREREQUISITE = $(if $(DEVELOP_DEV_FAST_ENABLED),dev-fast-check) +DEVELOP_DEV_FAST_ENV = $(if $(DEVELOP_DEV_FAST_ENABLED),RUSTUP_TOOLCHAIN=$(DEV_FAST_TOOLCHAIN) DEV_FAST_CARGO=$(CARGO) DEV_FAST_CONFIG=$(DEV_FAST_ABSOLUTE_CONFIG) CARGO=$(DEV_FAST_CARGO_BRIDGE)) +DEV_FAST_CHECK_COMMAND = test "$(DEV_FAST_HOST_IS_LINUX)" = yes || { printf '%s\n' 'dev-fast is supported only on Linux; use the stable backend on this host' >&2; exit 1; }; test -f "$(DEV_FAST_CONFIG)" || { printf 'dev-fast configuration is missing: %s\n' "$(DEV_FAST_CONFIG)" >&2; exit 1; }; command -v mold >/dev/null 2>&1 || { printf '%s\n' 'mold 2.41.0 is required for Linux dev-fast builds' >&2; exit 1; }; mold --version | grep -q '^mold 2\.41\.0' || { printf '%s\n' 'mold 2.41.0 is required for Linux dev-fast builds' >&2; exit 1; }; rustup component list --installed --toolchain "$(DEV_FAST_TOOLCHAIN)" | grep -q '^$(DEV_FAST_CRANELIFT_COMPONENT)' || { printf 'install %s for %s before using dev-fast\n' "$(DEV_FAST_CRANELIFT_COMPONENT)" "$(DEV_FAST_TOOLCHAIN)" >&2; exit 1; } +DEV_FAST_TEST_COMMAND = if $(LOCAL_TOOL_ENV) command -v cargo-nextest >/dev/null 2>&1; then cd $(RUST_DIR) && CARGO_BUILD_JOBS="$(TEST_CARGO_BUILD_JOBS)" RUSTFLAGS="$(TEST_RUSTFLAGS)" $(DEV_FAST_CARGO_COMMAND) nextest run $(TEST_FLAGS) $(BUILD_JOBS); else echo "cargo-nextest not found; falling back to cargo test." >&2; cd $(RUST_DIR) && CARGO_BUILD_JOBS="$(TEST_CARGO_BUILD_JOBS)" RUSTFLAGS="$(TEST_RUSTFLAGS)" $(DEV_FAST_CARGO_COMMAND) test $(TEST_FLAGS) $(BUILD_JOBS); fi WHITAKER ?= whitaker BUILD_JOBS ?= RUST_FLAGS ?= -D warnings @@ -160,6 +184,7 @@ MDLINT_CHECK_COMMAND = unset FORCE_COLOR; $(LOCAL_TOOL_ENV) xargs -0 -r $(MDLINT lint-windows fmt check-fmt \ markdownlint spelling nixie test test-python test-rust typecheck \ test-extension test-markdown-format develop makeutil skylos-allow \ + test-dev-fast-contract dev-fast-check dev-build dev-test \ benchmark-micro benchmark-e2e \ $(TOOLS) $(VENV_TOOLS) .NOTPARALLEL: lint @@ -176,9 +201,9 @@ build: uv .venv ## Build virtual-env and install deps # Why this exists and why `ensurepip` comes first: see "Building the # extension for tests" in docs/developers-guide.md. -develop: build ## Build the native extension into the dev virtual-env +develop: build $(DEVELOP_DEV_FAST_PREREQUISITE) ## Build the native extension into the dev virtual-env $(UV_RUN_ENV) uv run $(UV_RUN_FLAGS) python -m ensurepip --upgrade - $(UV_RUN_ENV) uv run $(UV_RUN_FLAGS) maturin develop $(MATURIN_DEVELOP_FLAGS) --manifest-path $(RUST_DIR)/cuprum-rust/Cargo.toml + $(DEVELOP_DEV_FAST_ENV) $(UV_RUN_ENV) uv run $(UV_RUN_FLAGS) maturin develop $(MATURIN_DEVELOP_FLAGS) --manifest-path $(RUST_DIR)/cuprum-rust/Cargo.toml build-release: ## Build artefacts (sdist & wheel) python -m build --sdist --wheel @@ -254,8 +279,8 @@ 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: ## Run Rust documentation, Clippy, Whitaker, and spelling checks - cd $(RUST_DIR) && RUSTDOCFLAGS="$(RUSTDOC_FLAGS)" $(CARGO) doc --no-deps $(DOC_FLAGS) && $(CARGO) clippy $(CLIPPY_FLAGS) +rust-lint: $(RUST_DEBUG_PREREQUISITE) ## Run Rust documentation, Clippy, Whitaker, and spelling checks + 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 cd $(RUST_DIR) && $(LOCAL_TOOL_ENV) RUSTFLAGS="$(WHITAKER_RUSTFLAGS)" $(WHITAKER) --all -- $(WHITAKER_CARGO_FLAGS) +$(MAKE) spelling @@ -311,19 +336,34 @@ test-python: build uv $(VENV_TOOLS) makeutil ## Run the Python suite CARGO_BUILD_JOBS="$(PYTEST_CARGO_BUILD_JOBS)" RUSTFLAGS="$(PYTEST_RUSTFLAGS)" $(PYTEST) -v -n $(PYTEST_WORKERS) "$$@" || exit $$?; \ done -test-rust: ## Run the Rust suite +test-rust: $(RUST_DEBUG_PREREQUISITE) ## Run the Rust suite @if $(LOCAL_TOOL_ENV) command -v cargo-nextest >/dev/null 2>&1; then \ - cd $(RUST_DIR) && CARGO_BUILD_JOBS="$(TEST_CARGO_BUILD_JOBS)" RUSTFLAGS="$(TEST_RUSTFLAGS)" $(CARGO) nextest run $(TEST_FLAGS) $(BUILD_JOBS); \ + cd $(RUST_DIR) && CARGO_BUILD_JOBS="$(TEST_CARGO_BUILD_JOBS)" RUSTFLAGS="$(TEST_RUSTFLAGS)" $(RUST_DEBUG_CARGO) nextest run $(TEST_FLAGS) $(BUILD_JOBS); \ else \ echo "cargo-nextest not found; falling back to cargo test." >&2; \ - cd $(RUST_DIR) && CARGO_BUILD_JOBS="$(TEST_CARGO_BUILD_JOBS)" RUSTFLAGS="$(TEST_RUSTFLAGS)" $(CARGO) test $(TEST_FLAGS) $(BUILD_JOBS); \ + cd $(RUST_DIR) && CARGO_BUILD_JOBS="$(TEST_CARGO_BUILD_JOBS)" RUSTFLAGS="$(TEST_RUSTFLAGS)" $(RUST_DEBUG_CARGO) test $(TEST_FLAGS) $(BUILD_JOBS); \ fi +msrv-check: ## Verify every Rust target compiles on the published MSRV + cd $(RUST_DIR) && $(MSRV_CARGO_COMMAND) check --workspace --all-targets --all-features + +dev-fast-check: ## Verify Linux dev-fast prerequisites + @$(DEV_FAST_CHECK_COMMAND) + +dev-build: dev-fast-check ## Build Rust debug targets through the accelerated route + cd $(RUST_DIR) && $(DEV_FAST_CARGO_COMMAND) build $(CARGO_FLAGS) + +dev-test: dev-fast-check ## Test Rust through the accelerated route + @$(DEV_FAST_TEST_COMMAND) + # Run `make develop` first. Without the extension the guard fails the run with # a message naming that command, which is the intended diagnostic. test-extension: build uv $(VENV_TOOLS) ## Run the extension-gated tests, requiring the extension CUPRUM_REQUIRE_RUST_EXTENSION=1 $(PYTEST) -v $(EXTENSION_TEST_TARGETS) +test-dev-fast-contract: build uv $(VENV_TOOLS) ## Validate dev-fast routing and adapter contracts + $(PYTEST) -v cuprum/unittests/test_dev_fast_contract.py + benchmark-micro: build uv ## Run pytest-benchmark microbenchmarks mkdir -p dist/benchmarks $(UV_RUN_ENV) CUPRUM_RUN_BENCHMARKS=1 uv run pytest -q \ diff --git a/cuprum/unittests/test_dev_fast_contract.py b/cuprum/unittests/test_dev_fast_contract.py new file mode 100644 index 000000000..82b0f72b6 --- /dev/null +++ b/cuprum/unittests/test_dev_fast_contract.py @@ -0,0 +1,288 @@ +"""Hold the Linux-only Cargo acceleration route to its explicit contract.""" + +from __future__ import annotations + +import hashlib +import os +import shutil +import subprocess # ruff: ignore[suspicious-subprocess-import] - tests fixed local build wiring. +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 + + +FRAGMENT_SHA256 = "d8fcc29ce680ecf43e96f024bd8d0e1a378d5998efdafc8221902735f9ff4ec4" +FRAGMENT = "tools/dev-fast/config.toml" +RUST_MEMBERS = ("cuprum-rust", "cuprum-streams", "cuprum-native-io") + + +def _dry_run(*goals: str, variables: dict[str, str] | None = None) -> str: + """Return evaluated Make recipes for a controlled caller environment.""" + make = shutil.which("make") + assert make is not None + completed = subprocess.run( # ruff: ignore[subprocess-without-shell-equals-true] + [ + make, + "--dry-run", + "CARGO=probe-cargo", + *(f"{key}={value}" for key, value in (variables or {}).items()), + *goals, + ], + capture_output=True, + check=True, + cwd=repo_root(), + env={**os.environ, "MAKEFLAGS": ""}, + text=True, + ) + return completed.stdout + + +def _routing_lines(output: str) -> list[str]: + """Return evaluated Cargo lines, excluding spelling's unrelated config.""" + return [ + line + for line in output.splitlines() + if "probe-cargo" in line or "CARGO=tools/dev-fast/cargo" in line + ] + + +def _assert_fragment_free(route: str) -> None: + """Reject any selected Linux development acceleration from a protected route.""" + assert "--config" not in route + assert "tools/dev-fast/cargo" not in route + assert "nightly-2026-08-23" not in route + assert "DEV_FAST_" not in route + + +def _assert_fragment_mutation_is_rejected(route: str) -> None: + """Prove fragment-free assertions fail when a protected route is contaminated.""" + mutation = f"{route}\nprobe-cargo --config {FRAGMENT} check" + with pytest.raises(AssertionError): + _assert_fragment_free(mutation) + + +def test_fragment_is_the_approved_immutable_extension() -> None: + """Keep the checked-in Cargo fragment byte-identical to its approved pin.""" + fragment = (repo_root() / FRAGMENT).read_bytes() + assert hashlib.sha256(fragment).hexdigest() == FRAGMENT_SHA256 + + +def test_every_workspace_member_inherits_the_published_msrv() -> None: + """Keep Clippy's MSRV-aware diagnostics aligned with Cargo metadata.""" + rust_root = repo_root() / "rust" + workspace = tomllib.loads((rust_root / "Cargo.toml").read_text(encoding="utf-8")) + assert workspace["workspace"]["package"]["rust-version"] == "1.85.0" + assert tuple(workspace["workspace"]["members"]) == RUST_MEMBERS + for member in RUST_MEMBERS: + manifest = tomllib.loads( + (rust_root / member / "Cargo.toml").read_text(encoding="utf-8") + ) + assert manifest["package"]["rust-version"] == {"workspace": True} + + +def test_linux_debug_routes_select_the_fragment_and_injected_cargo() -> None: + """Every supported debug Cargo invocation explicitly selects the fragment.""" + output = _dry_run("develop", "test-rust", "rust-lint", "dev-build", "dev-test") + routing = "\n".join(_routing_lines(output)) + assert "DEV_FAST_CARGO=probe-cargo" in routing + assert "CARGO=tools/dev-fast/cargo" in routing + direct_cargo = [ + line for line in routing.splitlines() if "probe-cargo --config" in line + ] + assert sum(line.count("probe-cargo --config") for line in direct_cargo) == 7 + assert all( + "--config ../tools/dev-fast/config.toml" in line for line in direct_cargo + ) + assert "RUSTUP_TOOLCHAIN=nightly-2026-08-23" in routing + + +@pytest.mark.parametrize( + "flags", + [ + pytest.param("--release --skip-install", id="release"), + pytest.param("-r --skip-install", id="short_release"), + pytest.param("--profile release --skip-install", id="profile_release"), + ], +) +def test_release_maturin_routes_do_not_select_dev_fast(flags: str) -> None: + """Release spellings cannot accidentally route Maturin through Cranelift.""" + output = _dry_run("develop", variables={"MATURIN_DEVELOP_FLAGS": flags}) + assert "tools/dev-fast/cargo" not in output + assert "DEV_FAST_CARGO=" not in output + assert "RUSTUP_TOOLCHAIN=nightly-2026-08-23" not in output + + +def test_non_linux_alternative_stays_on_the_stable_cargo_route() -> None: + """The explicit non-Linux alternative cannot acquire the Linux fragment.""" + output = _dry_run( + "develop", "test-rust", "rust-lint", variables={"DEV_FAST_HOST_IS_LINUX": ""} + ) + assert "tools/dev-fast/cargo" not in output + assert "--config ../tools/dev-fast/config.toml" not in output + assert "RUSTUP_TOOLCHAIN=nightly-2026-08-23" not in output + + +def test_whitaker_and_windows_lint_remain_fragment_free() -> None: + """Verification and cross-platform routes never inherit Linux acceleration.""" + output = _dry_run("rust-lint", "lint-windows") + whitaker = next(line for line in output.splitlines() if "whitaker --all" in line) + windows = next( + line + for line in output.splitlines() + if "--target x86_64-pc-windows-msvc" in line + ) + assert "--config" not in whitaker + assert "RUSTUP_TOOLCHAIN=nightly-2026-08-23" not in whitaker + assert "--config" not in windows + assert "RUSTUP_TOOLCHAIN=nightly-2026-08-23" not in windows + + +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") + route = next(line for line in output.splitlines() if "probe-cargo check" in line) + assert "RUSTUP_TOOLCHAIN=1.85.0" in route + assert "--config" not in route + assert "nightly-2026-08-23" not in route + + +@pytest.mark.parametrize( + "route", + [ + pytest.param( + lambda: "\n".join([ + _dry_run("build-release"), + (repo_root() / ".github/actions/build-wheels/action.yml").read_text( + encoding="utf-8" + ), + ]), + id="release", + ), + pytest.param( + lambda: (repo_root() / ".github/workflows/coverage-main.yml").read_text( + encoding="utf-8" + ), + id="coverage", + ), + pytest.param( + lambda: _dry_run("boundary-kani", "boundary-miri", "boundary-test"), + id="verification", + ), + ], +) +def test_protected_routes_reject_dev_fast_contamination( + route: cabc.Callable[[], str], +) -> None: + """Keep release, coverage, and verification invocations on their own toolchains.""" + protected_route = route() + _assert_fragment_free(protected_route) + _assert_fragment_mutation_is_rejected(protected_route) + + +def _bridge_environment(tmp_path: Path) -> tuple[dict[str, str], Path]: + """Create a recording Cargo child for the adapter's process contract.""" + argv_path = tmp_path / "argv" + child = tmp_path / "cargo-child" + child.write_text( + '#!/usr/bin/env bash\nprintf \'%s\\n\' "$@" > "$DEV_FAST_ARGV"\nexit 47\n', + encoding="utf-8", + ) + child.chmod(0o755) + return { + "DEV_FAST_ARGV": str(argv_path), + "DEV_FAST_CARGO": str(child), + "DEV_FAST_CONFIG": str(repo_root() / FRAGMENT), + }, argv_path + + +def test_bridge_injects_one_fragment_and_preserves_child_exit( + tmp_path: Path, +) -> None: + """The Maturin adapter preserves Cargo's argv and status through exec.""" + environment, argv_path = _bridge_environment(tmp_path) + result = subprocess.run( # ruff: ignore[subprocess-without-shell-equals-true] + [repo_root() / "tools/dev-fast/cargo", "rustc", "--lib"], + check=False, + cwd=repo_root(), + env={**os.environ, **environment}, + ) + assert result.returncode == 47 + assert argv_path.read_text(encoding="utf-8").splitlines() == [ + "--config", + environment["DEV_FAST_CONFIG"], + "rustc", + "--lib", + ] + + +@pytest.mark.parametrize("config_flag", ["--config", "--config=other.toml"]) +def test_bridge_rejects_duplicate_configuration( + tmp_path: Path, config_flag: str +) -> None: + """A caller cannot override or duplicate the approved configuration.""" + environment, argv_path = _bridge_environment(tmp_path) + command = [repo_root() / "tools/dev-fast/cargo", "rustc", config_flag] + if config_flag == "--config": + command.append("other.toml") + result = subprocess.run( # ruff: ignore[subprocess-without-shell-equals-true] + command, check=False, cwd=repo_root(), env={**os.environ, **environment} + ) + assert result.returncode == 2 + assert not argv_path.exists() + + +def _write_program(directory: Path, name: str, body: str) -> None: + """Create one controlled executable used to mutate a prerequisite.""" + program = directory / name + program.write_text(f"#!/usr/bin/env bash\n{body}\n", encoding="utf-8") + program.chmod(0o755) + + +@pytest.mark.parametrize( + "programs", + [ + pytest.param({}, id="missing_mold"), + pytest.param( + {"mold": "printf '%s\\n' 'mold 2.41.0'", "rustup": "exit 0"}, + id="missing_component", + ), + ], +) +def test_prerequisite_recipe_fails_closed_for_missing_dependencies( + tmp_path: Path, programs: dict[str, str] +) -> None: + """Missing linker or component is a hard failure, not an optimistic skip.""" + for name, body in programs.items(): + _write_program(tmp_path, name, body) + make = shutil.which("make") + assert make is not None + result = subprocess.run( # ruff: ignore[subprocess-without-shell-equals-true] + [make, "dev-fast-check", "DEV_FAST_HOST_IS_LINUX=yes"], + check=False, + cwd=repo_root(), + env={**os.environ, "PATH": f"{tmp_path}:/usr/bin:/bin"}, + text=True, + ) + assert result.returncode != 0 + + +def test_ci_provisions_only_the_pinned_linux_prerequisites() -> None: + """CI installs only the approved binary prerequisites for Linux debug work.""" + action = (repo_root() / ".github/actions/setup-dev-fast/action.yml").read_text( + encoding="utf-8" + ) + workflow = (repo_root() / ".github/workflows/ci.yml").read_text(encoding="utf-8") + assert "nightly-2026-08-23" in action + assert "rustc-codegen-cranelift" in action + assert 'archive="mold-${version}-${architecture}-linux.tar.gz"' in action + assert "sha256sum --check --status" in action + assert "cargo install" not in action + assert workflow.count("uses: ./.github/actions/setup-dev-fast") == 2 + assert "- name: Verify Rust MSRV\n run: make msrv-check" in workflow diff --git a/cuprum/unittests/test_workflow_lint.py b/cuprum/unittests/test_workflow_lint.py index 0f4e05fb8..7490156ea 100644 --- a/cuprum/unittests/test_workflow_lint.py +++ b/cuprum/unittests/test_workflow_lint.py @@ -10,6 +10,7 @@ from __future__ import annotations import os +import shlex import stat import subprocess # ruff: ignore[suspicious-subprocess-import] - fixed Makefile targets. import typing as typ @@ -83,7 +84,13 @@ class _LintJob(typ.TypedDict, total=False): steps: list[_Step] -def _write_fake_tool(directory: pth.Path, tool: str, *, exit_code: int = 0) -> None: +def _write_fake_tool( + directory: pth.Path, + tool: str, + *, + exit_code: int = 0, + output: str = "", +) -> None: """Write an executable that records its name and arguments.""" executable = directory / tool executable.write_text( @@ -93,6 +100,7 @@ def _write_fake_tool(directory: pth.Path, tool: str, *, exit_code: int = 0) -> N ' printf \'\\t%s\' "${argument}" >> "${LINT_INVOCATION_LOG}"\n' "done\n" "printf '\\n' >> \"${LINT_INVOCATION_LOG}\"\n" + f"printf '%s' {shlex.quote(output)}\n" f"exit {exit_code}\n", encoding="utf-8", ) @@ -104,17 +112,20 @@ def _make_environment( *, tools: cabc.Iterable[str], exit_codes: cabc.Mapping[str, int] | None = None, + outputs: cabc.Mapping[str, str] | None = None, ) -> tuple[dict[str, str], pth.Path]: """Create a Makefile environment with only the requested fake tools.""" tool_directory = tmp_path / "tools" tool_directory.mkdir() invocation_log = tmp_path / "invocations.log" expected_exit_codes = exit_codes or {} + expected_outputs = outputs or {} for tool in tools: _write_fake_tool( tool_directory, tool, exit_code=expected_exit_codes.get(tool, 0), + output=expected_outputs.get(tool, ""), ) environment = { **os.environ, @@ -256,7 +267,12 @@ def test_the_workflow_lint_target_runs_both_linters(tmp_path: pth.Path) -> None: def test_the_lint_target_runs_the_workflow_linters(tmp_path: pth.Path) -> None: """The aggregate lint target reaches both GitHub Actions linters.""" environment, invocation_log = _make_environment( - tmp_path, tools=("uv", "yamllint", "actionlint") + tmp_path, + tools=("uv", "yamllint", "actionlint", "mold", "rustup"), + outputs={ + "mold": "mold 2.41.0\n", + "rustup": "rustc-codegen-cranelift-x86_64-unknown-linux-gnu (installed)\n", + }, ) overrides = tmp_path / "lint-target-overrides.mk" overrides.write_text( @@ -273,6 +289,8 @@ def test_the_lint_target_runs_the_workflow_linters(tmp_path: pth.Path) -> None: assert completed.returncode == 0, completed.stderr assert invocation_log.read_text(encoding="utf-8").splitlines() == [ "uv\trun\twhich\truff", + "mold\t--version", + "rustup\tcomponent\tlist\t--installed\t--toolchain\tnightly-2026-08-23", f"yamllint\t--strict\t--config-file\t.yamllint.yml\t{_WORKFLOW_DIRECTORY}", "actionlint\t-config-file\t.github/actionlint.yaml", ] diff --git a/docs/developers-guide.md b/docs/developers-guide.md index ee08a066d..5719b100f 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -395,6 +395,47 @@ When updating Rust dependencies, keep the requested version aligned to the patch baseline already present in `Cargo.lock`. This keeps lockfile updates focused, small, and easy to review. +## Linux debug-build acceleration + +Cuprum's Rust `1.85.0` pin remains the compatibility, release, coverage, +verification, and Whitaker toolchain. On Linux only, ordinary debug Rust work +uses the separately pinned `nightly-2026-08-23` and the +`rustc-codegen-cranelift` component through `tools/dev-fast/config.toml`. The +fragment is an approved byte-for-byte local extension from +`leynos/netsuke@924cb215d3841048dbf6649767a510d2a5dfb1b7`, recorded by +Concordat issue 157; its SHA-256 is +`d8fcc29ce680ecf43e96f024bd8d0e1a378d5998efdafc8221902735f9ff4ec4`. + +Run `make dev-fast-check` before the first Linux accelerated build. It requires +the nightly component and the pinned `mold` 2.41.0 linker. The approved binary +route is the checksum-verified upstream release archive; it never permits a +source-build fallback. `make develop`, `make test-rust`, and the Cargo doc and +Clippy portion of `make rust-lint` select the fragment on a compatible Linux +host. `make dev-build` and `make dev-test` expose the same route explicitly. +Direct Cargo invocations retain the repository-selected Rust toolchain unless +the caller explicitly supplies the fragment. + +Every workspace package inherits `rust-version = "1.85.0"` from the root +manifest. Cargo treats that as the publication and compatibility contract, and +MSRV-aware Clippy lints use it while the Linux debug lint route runs on the +maintenance nightly. `make msrv-check` separately compiles every workspace +target on Rust `1.85.0` without the fragment, so the accelerated lint compiler +cannot replace compatibility verification. + +Maturin accepts a Cargo executable but cannot pass Cargo's configuration-file +flag itself. The Linux-only `tools/dev-fast/cargo` adapter receives the injected +`CARGO` executable, adds exactly one approved `--config` path, and then +replaces itself with Cargo. It rejects a second configuration argument. This +small POSIX adapter is intentionally outside the normal Python scripting +standard because it must preserve Cargo's exact argv and exit status. + +macOS and Windows use the stable backend as the approved alternative. The +Windows cross-target lint, wheel builds, benchmark release builds, coverage, +verification, and Whitaker never select the fragment. The canonical fragment's +provenance comment refers to a repository-owned toolchain; Cuprum deliberately +uses the separately pinned maintenance nightly above to preserve its stable +support promise. + ## Tar and rsync builder helpers `TarCreateOptions.compression` in `cuprum/builders/tar.py` selects one member diff --git a/docs/repository-layout.md b/docs/repository-layout.md index 4b77c83ce..1e47a1853 100644 --- a/docs/repository-layout.md +++ b/docs/repository-layout.md @@ -40,6 +40,8 @@ _Figure 1: Simplified repository tree for contributor orientation._ | `rust/cuprum-native-io/` | Audited native descriptor/handle ownership, single-call I/O, progress kernels, and platform test fixtures. | | `test-wheelhouse/` | Local wheel artefacts used by validation workflows and compatibility tests. | | `tests/` | Behavioural, integration, and regression tests for the Python package and user-visible workflows. | +| `tools/dev-fast/` | Linux-only explicit Cargo adapter and approved Cranelift configuration for debug work. | +| `tools/mold/` | Pinned checksums for the prebuilt Linux linker used by the dev-fast route. | | `AGENTS.md` | Repository-specific assistant and contributor instructions. | | `Makefile` | Canonical entry point for build, format, lint, typecheck, test, documentation lint, and diagram validation gates. | | `pyproject.toml` | Python project metadata, dependency declarations, and tool configuration. | diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 5ac168553..12c5596fb 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -2,6 +2,11 @@ resolver = "2" members = ["cuprum-rust", "cuprum-streams", "cuprum-native-io"] +[workspace.package] +# This public compatibility contract also supplies Clippy's MSRV-aware lints +# when Linux debug linting uses the separately pinned dev-fast nightly. +rust-version = "1.85.0" + [workspace.lints.clippy] pedantic = { level = "warn", priority = -1 } diff --git a/rust/cuprum-native-io/Cargo.toml b/rust/cuprum-native-io/Cargo.toml index 553cc2534..0fcf40a0b 100644 --- a/rust/cuprum-native-io/Cargo.toml +++ b/rust/cuprum-native-io/Cargo.toml @@ -2,6 +2,7 @@ name = "cuprum-native-io" version = "0.1.0" edition = "2024" +rust-version.workspace = true license = "ISC" description = "Audited native resource and syscall boundary for Cuprum" publish = false diff --git a/rust/cuprum-rust/Cargo.toml b/rust/cuprum-rust/Cargo.toml index 56082b10d..6e13f878b 100644 --- a/rust/cuprum-rust/Cargo.toml +++ b/rust/cuprum-rust/Cargo.toml @@ -2,6 +2,7 @@ name = "cuprum-rust" version = "0.1.0" edition = "2024" +rust-version.workspace = true license = "ISC" description = "Optional Rust extension for cuprum stream operations" publish = false diff --git a/rust/cuprum-streams/Cargo.toml b/rust/cuprum-streams/Cargo.toml index 8f7283591..83250543c 100644 --- a/rust/cuprum-streams/Cargo.toml +++ b/rust/cuprum-streams/Cargo.toml @@ -2,6 +2,7 @@ name = "cuprum-streams" version = "0.1.0" edition = "2024" +rust-version.workspace = true license = "ISC" description = "Safe stream orchestration for Cuprum" publish = false diff --git a/rust/cuprum-streams/tests/ui/fail/replace_scoped_writer.stderr b/rust/cuprum-streams/tests/ui/fail/replace_scoped_writer.stderr index dd9951218..d7342fc90 100644 --- a/rust/cuprum-streams/tests/ui/fail/replace_scoped_writer.stderr +++ b/rust/cuprum-streams/tests/ui/fail/replace_scoped_writer.stderr @@ -10,3 +10,6 @@ error[E0308]: mismatched types found reference `&W` note: function defined here --> $RUST/core/src/mem/mod.rs + | + | pub const fn replace(dest: &mut T, src: T) -> T { + | ^^^^^^^ diff --git a/tools/dev-fast/cargo b/tools/dev-fast/cargo new file mode 100755 index 000000000..95a2bbe9a --- /dev/null +++ b/tools/dev-fast/cargo @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Cargo executable adapter used only by Linux `maturin develop` invocations. +# +# Maturin accepts the Cargo executable through CARGO but cannot itself pass +# Cargo's configuration-file form of --config. This is intentionally a small +# POSIX adapter, rather than a project automation script: it must replace +# itself with Cargo so the selected executable's argv and exit status survive +# unchanged. Make confines it to the Linux dev-fast route; unsupported hosts +# keep the stable backend and never execute this file. + +set -euo pipefail + +if [ -z "${DEV_FAST_CARGO:-}" ]; then + printf '%s\n' 'dev-fast: DEV_FAST_CARGO must name one Cargo executable' >&2 + exit 2 +fi + +if [ -z "${DEV_FAST_CONFIG:-}" ] || [ ! -f "$DEV_FAST_CONFIG" ]; then + printf '%s\n' 'dev-fast: DEV_FAST_CONFIG must name the approved configuration file' >&2 + exit 2 +fi + +for argument in "$@"; do + case "$argument" in + --config | --config=*) + printf '%s\n' 'dev-fast: Cargo configuration is selected by this adapter' >&2 + exit 2 + ;; + esac +done + +# Cargo-invoked build scripts inherit CARGO. Point them to the injected real +# executable so they cannot recursively enter this adapter. +export CARGO="$DEV_FAST_CARGO" +exec "$DEV_FAST_CARGO" --config "$DEV_FAST_CONFIG" "$@" diff --git a/tools/dev-fast/config.toml b/tools/dev-fast/config.toml new file mode 100644 index 000000000..0bc4ec1f9 --- /dev/null +++ b/tools/dev-fast/config.toml @@ -0,0 +1,30 @@ +# Opt-in Cargo configuration fragment for accelerated local debug builds. +# +# This file is deliberately kept out of `.cargo/config.toml`, which Cargo +# auto-discovers. Cranelift and a Linux-only linker must not apply to release +# packaging, coverage, and the formal-verification toolchains, which must keep +# the supported LLVM backend and platform linker. This fragment is instead +# passed explicitly with `cargo --config tools/dev-fast/config.toml ...` from +# the `make dev-*` targets. +# +# The toolchain is the repository's own, from `rust-toolchain.toml`; only the +# mold release is pinned here, in `tools/mold/VERSION`. See +# `docs/developers-guide.md` for the ownership boundary and composition rules. + +[unstable] +codegen-backend = true + +# Cranelift trades runtime performance for compile speed, so it is applied to +# the dev profile only. `make dev-*` never builds release artefacts. +[profile.dev] +codegen-backend = "cranelift" + +# mold ships for Linux only, so the flag is gated behind a target `cfg`. On +# macOS and Windows the table simply does not apply and the platform default +# linker is used; `make dev-fast-check` reports that fallback explicitly rather +# than leaving it implicit. +# +# Cargo picks a single rustflags source rather than merging them, so anything +# this table must carry has to be named here in full; nothing is inherited. +[target.'cfg(target_os = "linux")'] +rustflags = ["-Clink-arg=-fuse-ld=mold"] diff --git a/tools/mold/SHA256SUMS b/tools/mold/SHA256SUMS new file mode 100644 index 000000000..0e50c4470 --- /dev/null +++ b/tools/mold/SHA256SUMS @@ -0,0 +1,2 @@ +a3696680d99e692970590a178bc3a33d78d60d1c6dc9db7a11b557b02b751f5d mold-2.41.0-x86_64-linux.tar.gz +946de2774b06a71346bd59b55fddba610b65b8d93c3a4a1559cc84e103472710 mold-2.41.0-aarch64-linux.tar.gz diff --git a/tools/mold/VERSION b/tools/mold/VERSION new file mode 100644 index 000000000..2d4c52ede --- /dev/null +++ b/tools/mold/VERSION @@ -0,0 +1 @@ +2.41.0 diff --git a/typos.local.toml b/typos.local.toml index 3d77459a8..9d7cf9627 100644 --- a/typos.local.toml +++ b/typos.local.toml @@ -42,6 +42,15 @@ ignore = [ '--artifact-server-path(?:[=\s"`]|$)', '`color`', '\brust-analyzer\b', + # mold is the fixed name of the approved external linker and its release + # archive. These patterns retain ordinary en-GB prose checking. + '`mold`', + '\bmissing_mold\b', + '"mold"', + '\bmold 2\.41\.0\b', + '"mold\\t--version"', + '\bmold-\$\{version\}-\$\{architecture\}-linux\.tar\.gz\b', + '\btools/mold/', ] # ADR 009 requires en-GB-oxendict spelling in source identifiers, so the fenced diff --git a/typos.toml b/typos.toml index 13605be2f..808957cc6 100644 --- a/typos.toml +++ b/typos.toml @@ -33,6 +33,8 @@ locale = "en-gb" extend-ignore-re = [ "\"artifact\"", "\"artifacts\"", + "\"mold\"", + "\"mold\\\\t--version\"", "--artifact-name(?:[=\\s\"`]|$)", "--artifact-server-path(?:[=\\s\"`]|$)", "/artifacts(?:[/?#\"`]|$)", @@ -45,8 +47,13 @@ extend-ignore-re = [ "\\bartifact-name\\b", "\\bartifact_dir\\b", "\\bbuild_native_wheel_artifact\\b", + "\\bmissing_mold\\b", + "\\bmold 2\\.41\\.0\\b", + "\\bmold-\\$\\{version\\}-\\$\\{architecture\\}-linux\\.tar\\.gz\\b", "\\brust-analyzer\\b", + "\\btools/mold/", "`color`", + "`mold`", "b\"ond\\\\n\"", "baseline-artifact", "mappings\\[\"organise\"\\]", From 13c33539291b54b20beda92eec7d842acecc2000 Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 16 Sep 2026 20:15:48 +0200 Subject: [PATCH 02/12] Fix dev-fast hosted verification Install Clippy with the pinned Linux development toolchain so the routed lint gate can execute. Keep the scoped-writer compile-fail contract stable on Rust 1.85 and the development nightly by using a local diagnostic anchor. --- .github/actions/setup-dev-fast/action.yml | 2 +- cuprum/unittests/test_dev_fast_contract.py | 1 + .../tests/ui/fail/replace_scoped_writer.rs | 17 +++++++----- .../ui/fail/replace_scoped_writer.stderr | 26 +++++++++---------- 4 files changed, 26 insertions(+), 20 deletions(-) diff --git a/.github/actions/setup-dev-fast/action.yml b/.github/actions/setup-dev-fast/action.yml index 18cd582ee..1287abde0 100644 --- a/.github/actions/setup-dev-fast/action.yml +++ b/.github/actions/setup-dev-fast/action.yml @@ -20,7 +20,7 @@ runs: expected="$(awk -v archive="${archive}" '$2 == archive { print $1 }' tools/mold/SHA256SUMS)" test "$(printf '%s\n' "${expected}" | sed '/^$/d' | wc -l)" -eq 1 rustup toolchain install "${toolchain}" --profile minimal - rustup component add "${component}" --toolchain "${toolchain}" + rustup component add "${component}" clippy --toolchain "${toolchain}" curl --fail --location --silent --show-error \ --output "${RUNNER_TEMP}/${archive}" \ "https://github.com/rui314/mold/releases/download/v${version}/${archive}" diff --git a/cuprum/unittests/test_dev_fast_contract.py b/cuprum/unittests/test_dev_fast_contract.py index 82b0f72b6..4df86d128 100644 --- a/cuprum/unittests/test_dev_fast_contract.py +++ b/cuprum/unittests/test_dev_fast_contract.py @@ -281,6 +281,7 @@ def test_ci_provisions_only_the_pinned_linux_prerequisites() -> None: workflow = (repo_root() / ".github/workflows/ci.yml").read_text(encoding="utf-8") assert "nightly-2026-08-23" in action assert "rustc-codegen-cranelift" in action + assert 'rustup component add "${component}" clippy' in action assert 'archive="mold-${version}-${architecture}-linux.tar.gz"' in action assert "sha256sum --check --status" in action assert "cargo install" not in action diff --git a/rust/cuprum-streams/tests/ui/fail/replace_scoped_writer.rs b/rust/cuprum-streams/tests/ui/fail/replace_scoped_writer.rs index 42f740bc5..a834393f1 100644 --- a/rust/cuprum-streams/tests/ui/fail/replace_scoped_writer.rs +++ b/rust/cuprum-streams/tests/ui/fail/replace_scoped_writer.rs @@ -2,15 +2,20 @@ use cuprum_native_io::with_owned_writer; -fn escape_writer(writer: W, replacement: W) -> W { - with_owned_writer(&(), writer, |_, scoped_writer| { - std::mem::replace(scoped_writer, replacement) - }) -} - fn main() -> Result<(), std::io::Error> { let (_, writer) = cuprum_native_io::pipe()?; let (_, replacement) = cuprum_native_io::pipe()?; drop(escape_writer(writer, replacement)); Ok(()) } + +fn replace_scoped_writer(destination: &mut T, replacement: T) -> T { + std::mem::replace(destination, replacement) +} + +fn escape_writer(writer: W, replacement: W) -> W { + with_owned_writer(&(), writer, |_, scoped_writer| { + let escaped = replace_scoped_writer(scoped_writer, replacement); + escaped + }) +} diff --git a/rust/cuprum-streams/tests/ui/fail/replace_scoped_writer.stderr b/rust/cuprum-streams/tests/ui/fail/replace_scoped_writer.stderr index d7342fc90..880936ce0 100644 --- a/rust/cuprum-streams/tests/ui/fail/replace_scoped_writer.stderr +++ b/rust/cuprum-streams/tests/ui/fail/replace_scoped_writer.stderr @@ -1,15 +1,15 @@ error[E0308]: mismatched types - --> tests/ui/fail/replace_scoped_writer.rs:7:27 - | -7 | std::mem::replace(scoped_writer, replacement) - | ----------------- ^^^^^^^^^^^^^ types differ in mutability - | | - | arguments to this function are incorrect - | - = note: expected mutable reference `&mut W` - found reference `&W` + --> tests/ui/fail/replace_scoped_writer.rs:18:45 + | +18 | let escaped = replace_scoped_writer(scoped_writer, replacement); + | --------------------- ^^^^^^^^^^^^^ types differ in mutability + | | + | arguments to this function are incorrect + | + = note: expected mutable reference `&mut W` + found reference `&W` note: function defined here - --> $RUST/core/src/mem/mod.rs - | - | pub const fn replace(dest: &mut T, src: T) -> T { - | ^^^^^^^ + --> tests/ui/fail/replace_scoped_writer.rs:12:4 + | +12 | fn replace_scoped_writer(destination: &mut T, replacement: T) -> T { + | ^^^^^^^^^^^^^^^^^^^^^ ------------------- From ce835f8a5bf3e44f5729d8950a971e8f91131276 Mon Sep 17 00:00:00 2001 From: leynos Date: Thu, 17 Sep 2026 13:39:26 +0200 Subject: [PATCH 03/12] Strengthen dev-fast routing contracts Exercise the composite action and adapter boundaries, preserve mold flags when Make sets RUSTFLAGS, and record the Linux-only routing decision. --- AGENTS.md | 2 +- Makefile | 14 +- cuprum/unittests/test_dev_fast_adapter.py | 170 +++++++++++ cuprum/unittests/test_dev_fast_contract.py | 267 +++++++++--------- .../unittests/test_dev_fast_prerequisites.py | 144 ++++++++++ docs/adr-012-linux-dev-fast-routing.md | 67 +++++ docs/contents.md | 3 + docs/developers-guide.md | 19 +- tests/test_dev_fast_action.py | 228 +++++++++++++++ tools/dev-fast/config.toml | 4 +- 10 files changed, 776 insertions(+), 142 deletions(-) create mode 100644 cuprum/unittests/test_dev_fast_adapter.py create mode 100644 cuprum/unittests/test_dev_fast_prerequisites.py create mode 100644 docs/adr-012-linux-dev-fast-routing.md create mode 100644 tests/test_dev_fast_action.py diff --git a/AGENTS.md b/AGENTS.md index 8e950c0cc..6e7e39228 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -233,7 +233,7 @@ working on the Rust portions of the project: explicit `make msrv-check` verification target, and the macOS and Windows platform alternatives. All Rust packages inherit Cargo's `rust-version = "1.85.0"` contract, which also keeps MSRV-aware Clippy - findings valid when debug linting uses the maintenance nightly. + findings valid when debug linting uses the dev-fast nightly. - Clippy warnings MUST be disallowed. - Fix any warnings emitted during tests in code instead of silencing them. - Where a function is too long, extract meaningfully named helper functions diff --git a/Makefile b/Makefile index b66bbf7cd..2a9da334b 100644 --- a/Makefile +++ b/Makefile @@ -22,6 +22,9 @@ MSRV_TOOLCHAIN ?= 1.85.0 DEV_FAST_TOOLCHAIN ?= nightly-2026-08-23 DEV_FAST_CRANELIFT_COMPONENT ?= rustc-codegen-cranelift DEV_FAST_CONFIG ?= tools/dev-fast/config.toml +DEV_FAST_MOLD_VERSION_FILE ?= tools/mold/VERSION +DEV_FAST_MOLD_VERSION := $(strip $(shell tr -d '\r\n' < $(DEV_FAST_MOLD_VERSION_FILE))) +DEV_FAST_MOLD_VERSION_PATTERN = $(subst .,\.,$(DEV_FAST_MOLD_VERSION)) DEV_FAST_RUST_CONFIG ?= ../$(DEV_FAST_CONFIG) DEV_FAST_CARGO_BRIDGE ?= tools/dev-fast/cargo DEV_FAST_ABSOLUTE_CONFIG := $(abspath $(DEV_FAST_CONFIG)) @@ -38,8 +41,9 @@ MATURIN_DEVELOP_IS_RELEASE := $(strip $(filter --release -r --profile=release,$( DEVELOP_DEV_FAST_ENABLED := $(if $(DEV_FAST_HOST_IS_LINUX),$(if $(MATURIN_DEVELOP_IS_RELEASE),,yes)) DEVELOP_DEV_FAST_PREREQUISITE = $(if $(DEVELOP_DEV_FAST_ENABLED),dev-fast-check) DEVELOP_DEV_FAST_ENV = $(if $(DEVELOP_DEV_FAST_ENABLED),RUSTUP_TOOLCHAIN=$(DEV_FAST_TOOLCHAIN) DEV_FAST_CARGO=$(CARGO) DEV_FAST_CONFIG=$(DEV_FAST_ABSOLUTE_CONFIG) CARGO=$(DEV_FAST_CARGO_BRIDGE)) -DEV_FAST_CHECK_COMMAND = test "$(DEV_FAST_HOST_IS_LINUX)" = yes || { printf '%s\n' 'dev-fast is supported only on Linux; use the stable backend on this host' >&2; exit 1; }; test -f "$(DEV_FAST_CONFIG)" || { printf 'dev-fast configuration is missing: %s\n' "$(DEV_FAST_CONFIG)" >&2; exit 1; }; command -v mold >/dev/null 2>&1 || { printf '%s\n' 'mold 2.41.0 is required for Linux dev-fast builds' >&2; exit 1; }; mold --version | grep -q '^mold 2\.41\.0' || { printf '%s\n' 'mold 2.41.0 is required for Linux dev-fast builds' >&2; exit 1; }; rustup component list --installed --toolchain "$(DEV_FAST_TOOLCHAIN)" | grep -q '^$(DEV_FAST_CRANELIFT_COMPONENT)' || { printf 'install %s for %s before using dev-fast\n' "$(DEV_FAST_CRANELIFT_COMPONENT)" "$(DEV_FAST_TOOLCHAIN)" >&2; exit 1; } -DEV_FAST_TEST_COMMAND = if $(LOCAL_TOOL_ENV) command -v cargo-nextest >/dev/null 2>&1; then cd $(RUST_DIR) && CARGO_BUILD_JOBS="$(TEST_CARGO_BUILD_JOBS)" RUSTFLAGS="$(TEST_RUSTFLAGS)" $(DEV_FAST_CARGO_COMMAND) nextest run $(TEST_FLAGS) $(BUILD_JOBS); else echo "cargo-nextest not found; falling back to cargo test." >&2; cd $(RUST_DIR) && CARGO_BUILD_JOBS="$(TEST_CARGO_BUILD_JOBS)" RUSTFLAGS="$(TEST_RUSTFLAGS)" $(DEV_FAST_CARGO_COMMAND) test $(TEST_FLAGS) $(BUILD_JOBS); fi +DEV_FAST_CHECK_COMMAND = test "$(DEV_FAST_HOST_IS_LINUX)" = yes || { printf '%s\n' 'dev-fast is supported only on Linux; use the stable backend on this host' >&2; exit 1; }; test -f "$(DEV_FAST_CONFIG)" || { printf 'dev-fast configuration is missing: %s\n' "$(DEV_FAST_CONFIG)" >&2; exit 1; }; command -v mold >/dev/null 2>&1 || { printf 'mold %s is required for Linux dev-fast builds\n' "$(DEV_FAST_MOLD_VERSION)" >&2; exit 1; }; mold --version | grep -q '^mold $(DEV_FAST_MOLD_VERSION_PATTERN)\($$\|[[:space:]]\)' || { printf 'mold %s is required for Linux dev-fast builds\n' "$(DEV_FAST_MOLD_VERSION)" >&2; exit 1; }; rustup component list --installed --toolchain "$(DEV_FAST_TOOLCHAIN)" | grep -q '^$(DEV_FAST_CRANELIFT_COMPONENT)' || { printf 'install %s for %s before using dev-fast\n' "$(DEV_FAST_CRANELIFT_COMPONENT)" "$(DEV_FAST_TOOLCHAIN)" >&2; exit 1; } +DEV_FAST_TEST_RUSTFLAGS = $(TEST_RUSTFLAGS) $(if $(DEV_FAST_HOST_IS_LINUX),-Clink-arg=-fuse-ld=mold) +DEV_FAST_TEST_COMMAND = if $(LOCAL_TOOL_ENV) command -v cargo-nextest >/dev/null 2>&1; then cd $(RUST_DIR) && CARGO_BUILD_JOBS="$(TEST_CARGO_BUILD_JOBS)" RUSTFLAGS="$(DEV_FAST_TEST_RUSTFLAGS)" $(DEV_FAST_CARGO_COMMAND) nextest run $(TEST_FLAGS) $(BUILD_JOBS); else echo "cargo-nextest not found; falling back to cargo test." >&2; cd $(RUST_DIR) && CARGO_BUILD_JOBS="$(TEST_CARGO_BUILD_JOBS)" RUSTFLAGS="$(DEV_FAST_TEST_RUSTFLAGS)" $(DEV_FAST_CARGO_COMMAND) test $(TEST_FLAGS) $(BUILD_JOBS); fi WHITAKER ?= whitaker BUILD_JOBS ?= RUST_FLAGS ?= -D warnings @@ -338,10 +342,10 @@ test-python: build uv $(VENV_TOOLS) makeutil ## Run the Python suite test-rust: $(RUST_DEBUG_PREREQUISITE) ## Run the Rust suite @if $(LOCAL_TOOL_ENV) command -v cargo-nextest >/dev/null 2>&1; then \ - cd $(RUST_DIR) && CARGO_BUILD_JOBS="$(TEST_CARGO_BUILD_JOBS)" RUSTFLAGS="$(TEST_RUSTFLAGS)" $(RUST_DEBUG_CARGO) nextest run $(TEST_FLAGS) $(BUILD_JOBS); \ + cd $(RUST_DIR) && CARGO_BUILD_JOBS="$(TEST_CARGO_BUILD_JOBS)" RUSTFLAGS="$(DEV_FAST_TEST_RUSTFLAGS)" $(RUST_DEBUG_CARGO) nextest run $(TEST_FLAGS) $(BUILD_JOBS); \ else \ echo "cargo-nextest not found; falling back to cargo test." >&2; \ - cd $(RUST_DIR) && CARGO_BUILD_JOBS="$(TEST_CARGO_BUILD_JOBS)" RUSTFLAGS="$(TEST_RUSTFLAGS)" $(RUST_DEBUG_CARGO) test $(TEST_FLAGS) $(BUILD_JOBS); \ + cd $(RUST_DIR) && CARGO_BUILD_JOBS="$(TEST_CARGO_BUILD_JOBS)" RUSTFLAGS="$(DEV_FAST_TEST_RUSTFLAGS)" $(RUST_DEBUG_CARGO) test $(TEST_FLAGS) $(BUILD_JOBS); \ fi msrv-check: ## Verify every Rust target compiles on the published MSRV @@ -362,7 +366,7 @@ test-extension: build uv $(VENV_TOOLS) ## Run the extension-gated tests, requiri CUPRUM_REQUIRE_RUST_EXTENSION=1 $(PYTEST) -v $(EXTENSION_TEST_TARGETS) test-dev-fast-contract: build uv $(VENV_TOOLS) ## Validate dev-fast routing and adapter contracts - $(PYTEST) -v cuprum/unittests/test_dev_fast_contract.py + $(PYTEST) -v cuprum/unittests/test_dev_fast_contract.py cuprum/unittests/test_dev_fast_adapter.py cuprum/unittests/test_dev_fast_prerequisites.py tests/test_dev_fast_action.py benchmark-micro: build uv ## Run pytest-benchmark microbenchmarks mkdir -p dist/benchmarks diff --git a/cuprum/unittests/test_dev_fast_adapter.py b/cuprum/unittests/test_dev_fast_adapter.py new file mode 100644 index 000000000..d37d46db6 --- /dev/null +++ b/cuprum/unittests/test_dev_fast_adapter.py @@ -0,0 +1,170 @@ +"""Validate the Maturin Cargo adapter's process and configuration boundary.""" + +from __future__ import annotations + +import os +import subprocess # ruff: ignore[suspicious-subprocess-import] - controlled adapter argv. +import tempfile +from pathlib import Path + +import pytest +from hypothesis import given, settings +from hypothesis import strategies as st + +from tests.helpers.docs import repo_root + +FRAGMENT = "tools/dev-fast/config.toml" +SAFE_CARGO_ARGUMENTS = st.sampled_from(( + "rustc", + "--lib", + "--package", + "cuprum-streams", + "--quiet", +)) + + +def _bridge_environment(tmp_path: Path) -> tuple[dict[str, str], Path, Path]: + """Create a recording Cargo child for the adapter's process contract.""" + argv_path = tmp_path / "argv" + cargo_path = tmp_path / "cargo" + child = tmp_path / "cargo-child" + child.write_text( + "#!/usr/bin/env bash\n" + 'printf \'%s\\n\' "$@" > "$DEV_FAST_ARGV"\n' + 'printf \'%s\\n\' "$CARGO" > "$DEV_FAST_CARGO_ENV"\n' + "exit 47\n", + encoding="utf-8", + ) + child.chmod(0o755) + return ( + { + "DEV_FAST_ARGV": str(argv_path), + "DEV_FAST_CARGO": str(child), + "DEV_FAST_CARGO_ENV": str(cargo_path), + "DEV_FAST_CONFIG": str(repo_root() / FRAGMENT), + }, + argv_path, + cargo_path, + ) + + +def _run_bridge( + arguments: list[str], environment: dict[str, str] +) -> subprocess.CompletedProcess[str]: + """Run the controlled adapter process with captured diagnostics.""" + return subprocess.run( # ruff: ignore[subprocess-without-shell-equals-true] - fixed adapter argv. + [repo_root() / "tools/dev-fast/cargo", *arguments], + check=False, + capture_output=True, + cwd=repo_root(), + env={**os.environ, **environment}, + text=True, + ) + + +def test_bridge_injects_one_fragment_and_preserves_child_exit(tmp_path: Path) -> None: + """The Maturin adapter preserves Cargo's argv, environment, and status.""" + environment, argv_path, cargo_path = _bridge_environment(tmp_path) + result = _run_bridge(["rustc", "--lib"], environment) + assert result.returncode == 47, "the adapter must preserve the Cargo exit status" + assert argv_path.read_text(encoding="utf-8").splitlines() == [ + "--config", + environment["DEV_FAST_CONFIG"], + "rustc", + "--lib", + ], "the adapter must prepend only its approved fragment" + assert ( + cargo_path.read_text(encoding="utf-8").strip() == environment["DEV_FAST_CARGO"] + ), "Cargo children must receive the real executable, not the adapter" + + +@settings(max_examples=24, deadline=None) +@given(st.lists(SAFE_CARGO_ARGUMENTS, min_size=1, max_size=7)) +def test_bridge_preserves_generated_cargo_arguments(arguments: list[str]) -> None: + """The adapter preserves each safe generated Cargo argv sequence exactly.""" + with tempfile.TemporaryDirectory() as directory: + environment, argv_path, _ = _bridge_environment(Path(directory)) + result = _run_bridge(arguments, environment) + assert result.returncode == 47, f"the child status changed for {arguments!r}" + assert argv_path.read_text(encoding="utf-8").splitlines() == [ + "--config", + environment["DEV_FAST_CONFIG"], + *arguments, + ], f"the adapter changed generated Cargo arguments {arguments!r}" + + +@pytest.mark.parametrize("config_flag", ["--config", "--config=other.toml"]) +def test_bridge_rejects_duplicate_configuration( + tmp_path: Path, config_flag: str +) -> None: + """A caller cannot override or duplicate the approved configuration.""" + environment, argv_path, _ = _bridge_environment(tmp_path) + command = ["rustc", config_flag] + if config_flag == "--config": + command.append("other.toml") + result = _run_bridge(command, environment) + assert result.returncode == 2, "a duplicate configuration must be rejected" + assert "Cargo configuration is selected" in result.stderr, ( + "the duplicate configuration diagnostic must explain the owning boundary" + ) + assert not argv_path.exists(), "the adapter must reject before starting Cargo" + + +@settings(max_examples=24, deadline=None) +@given( + st.lists(SAFE_CARGO_ARGUMENTS, max_size=4), + st.sampled_from(("--config", "--config=other.toml")), + st.lists(SAFE_CARGO_ARGUMENTS, max_size=4), +) +def test_bridge_rejects_generated_configuration_flags( + prefix: list[str], config_flag: str, suffix: list[str] +) -> None: + """Every generated configuration spelling is rejected before Cargo starts.""" + with tempfile.TemporaryDirectory() as directory: + environment, argv_path, _ = _bridge_environment(Path(directory)) + arguments = [ + *prefix, + config_flag, + *(["other.toml"] if config_flag == "--config" else []), + *suffix, + ] + result = _run_bridge(arguments, environment) + assert result.returncode == 2, ( + f"configuration flag escaped rejection: {arguments!r}" + ) + assert not argv_path.exists(), "a rejected invocation must not start Cargo" + + +@pytest.mark.parametrize( + ("missing", "expected_diagnostic"), + [ + pytest.param("DEV_FAST_CARGO", "DEV_FAST_CARGO must name", id="missing_cargo"), + pytest.param( + "DEV_FAST_CONFIG", "DEV_FAST_CONFIG must name", id="missing_config" + ), + ], +) +def test_bridge_rejects_missing_required_environment( + tmp_path: Path, missing: str, expected_diagnostic: str +) -> None: + """The adapter refuses incomplete configuration before executing Cargo.""" + environment, argv_path, _ = _bridge_environment(tmp_path) + del environment[missing] + result = _run_bridge(["rustc"], environment) + assert result.returncode == 2, f"missing {missing} must fail with usage status" + assert expected_diagnostic in result.stderr, ( + f"missing {missing} must name the required environment variable" + ) + assert not argv_path.exists(), "incomplete adapter state must not start Cargo" + + +def test_bridge_rejects_a_nonexistent_configuration(tmp_path: Path) -> None: + """The adapter checks its selected configuration path before executing Cargo.""" + environment, argv_path, _ = _bridge_environment(tmp_path) + environment["DEV_FAST_CONFIG"] = str(tmp_path / "missing.toml") + result = _run_bridge(["rustc"], environment) + assert result.returncode == 2, "a missing fragment must fail with usage status" + assert "DEV_FAST_CONFIG must name" in result.stderr, ( + "the missing fragment diagnostic must name the broken boundary" + ) + assert not argv_path.exists(), "an invalid fragment must not start Cargo" diff --git a/cuprum/unittests/test_dev_fast_contract.py b/cuprum/unittests/test_dev_fast_contract.py index 4df86d128..7e4bd8c46 100644 --- a/cuprum/unittests/test_dev_fast_contract.py +++ b/cuprum/unittests/test_dev_fast_contract.py @@ -5,29 +5,43 @@ import hashlib import os import shutil -import subprocess # ruff: ignore[suspicious-subprocess-import] - tests fixed local build wiring. +import subprocess # ruff: ignore[suspicious-subprocess-import] - controlled test argv. import tomllib import typing as typ import pytest +from hypothesis import given, settings +from hypothesis import strategies as st from tests.helpers.docs import repo_root if typ.TYPE_CHECKING: from collections import abc as cabc - from pathlib import Path -FRAGMENT_SHA256 = "d8fcc29ce680ecf43e96f024bd8d0e1a378d5998efdafc8221902735f9ff4ec4" +if typ.TYPE_CHECKING: + from collections import abc as cabc + + +FRAGMENT_SHA256 = "8619efda5ea1c3232f413ae96ff56869ab6b2b7cd5bdef5a001ad16ebeac23a5" FRAGMENT = "tools/dev-fast/config.toml" RUST_MEMBERS = ("cuprum-rust", "cuprum-streams", "cuprum-native-io") +MOLD_VERSION = (repo_root() / "tools/mold/VERSION").read_text(encoding="utf-8").strip() +SAFE_MATURIN_FLAGS = st.sampled_from(( + "--release", + "-r", + "--profile", + "release", + "--profile=release", + "--skip-install", +)) def _dry_run(*goals: str, variables: dict[str, str] | None = None) -> str: """Return evaluated Make recipes for a controlled caller environment.""" make = shutil.which("make") - assert make is not None - completed = subprocess.run( # ruff: ignore[subprocess-without-shell-equals-true] + assert make is not None, "the routing contract requires GNU Make on PATH" + completed = subprocess.run( # ruff: ignore[subprocess-without-shell-equals-true] - fixed Make argv. [ make, "--dry-run", @@ -55,10 +69,14 @@ def _routing_lines(output: str) -> list[str]: def _assert_fragment_free(route: str) -> None: """Reject any selected Linux development acceleration from a protected route.""" - assert "--config" not in route - assert "tools/dev-fast/cargo" not in route - assert "nightly-2026-08-23" not in route - assert "DEV_FAST_" not in route + assert "--config" not in route, f"protected route selected Cargo config: {route}" + assert "tools/dev-fast/cargo" not in route, ( + f"protected route selected bridge: {route}" + ) + assert "nightly-2026-08-23" not in route, ( + f"protected route selected nightly: {route}" + ) + assert "DEV_FAST_" not in route, f"protected route leaked dev-fast state: {route}" def _assert_fragment_mutation_is_rejected(route: str) -> None: @@ -68,39 +86,96 @@ def _assert_fragment_mutation_is_rejected(route: str) -> None: _assert_fragment_free(mutation) +def _is_release_maturin_flags(flags: list[str]) -> bool: + """Model the documented Makefile spellings that must select a release build.""" + return any( + flag in {"--release", "-r", "--profile=release"} + or (flag == "--profile" and flags[index + 1 : index + 2] == ["release"]) + for index, flag in enumerate(flags) + ) + + +def test_fragment_digest_rejects_content_mutation() -> None: + """A changed fragment cannot satisfy the reviewed immutable digest.""" + fragment = (repo_root() / FRAGMENT).read_bytes() + mutated_digest = hashlib.sha256(fragment + b"\n# mutation\n").hexdigest() + assert mutated_digest != FRAGMENT_SHA256, ( + "the digest contract must reject a changed dev-fast fragment" + ) + + +def test_make_reads_the_mold_version_from_its_pin() -> None: + """Keep prerequisite diagnostics coupled to the checked-in linker pin.""" + makefile = (repo_root() / "Makefile").read_text(encoding="utf-8") + assert "DEV_FAST_MOLD_VERSION_FILE ?= tools/mold/VERSION" in makefile, ( + "the prerequisite check must read the tracked mold version file" + ) + assert "mold 2.41.0 is required" not in makefile, ( + "Make diagnostics must not retain a stale hard-coded mold version" + ) + + def test_fragment_is_the_approved_immutable_extension() -> None: """Keep the checked-in Cargo fragment byte-identical to its approved pin.""" fragment = (repo_root() / FRAGMENT).read_bytes() - assert hashlib.sha256(fragment).hexdigest() == FRAGMENT_SHA256 + assert hashlib.sha256(fragment).hexdigest() == FRAGMENT_SHA256, ( + "the reviewed fragment digest must change with its contents" + ) + developers_guide = (repo_root() / "docs/developers-guide.md").read_text( + encoding="utf-8" + ) + assert FRAGMENT_SHA256 in developers_guide, ( + "the developer guide must publish the same reviewed fragment digest" + ) def test_every_workspace_member_inherits_the_published_msrv() -> None: """Keep Clippy's MSRV-aware diagnostics aligned with Cargo metadata.""" rust_root = repo_root() / "rust" workspace = tomllib.loads((rust_root / "Cargo.toml").read_text(encoding="utf-8")) - assert workspace["workspace"]["package"]["rust-version"] == "1.85.0" - assert tuple(workspace["workspace"]["members"]) == RUST_MEMBERS + assert workspace["workspace"]["package"]["rust-version"] == "1.85.0", ( + "the workspace must publish the agreed Rust compatibility version" + ) + assert tuple(workspace["workspace"]["members"]) == RUST_MEMBERS, ( + "the MSRV contract must enumerate every workspace package" + ) for member in RUST_MEMBERS: manifest = tomllib.loads( (rust_root / member / "Cargo.toml").read_text(encoding="utf-8") ) - assert manifest["package"]["rust-version"] == {"workspace": True} + assert manifest["package"]["rust-version"] == {"workspace": True}, ( + f"{member} must inherit the root MSRV contract" + ) def test_linux_debug_routes_select_the_fragment_and_injected_cargo() -> None: """Every supported debug Cargo invocation explicitly selects the fragment.""" - output = _dry_run("develop", "test-rust", "rust-lint", "dev-build", "dev-test") + output = _dry_run( + "develop", + "test-rust", + "rust-lint", + "dev-build", + "dev-test", + variables={"DEV_FAST_HOST_IS_LINUX": "yes"}, + ) routing = "\n".join(_routing_lines(output)) - assert "DEV_FAST_CARGO=probe-cargo" in routing - assert "CARGO=tools/dev-fast/cargo" in routing + assert "DEV_FAST_CARGO=probe-cargo" in routing, "develop must inject Cargo" + assert "CARGO=tools/dev-fast/cargo" in routing, "develop must use the adapter" direct_cargo = [ line for line in routing.splitlines() if "probe-cargo --config" in line ] - assert sum(line.count("probe-cargo --config") for line in direct_cargo) == 7 + assert sum(line.count("probe-cargo --config") for line in direct_cargo) == 7, ( + "each standard and explicit debug Cargo invocation must select one fragment" + ) assert all( "--config ../tools/dev-fast/config.toml" in line for line in direct_cargo + ), "every debug Cargo command must select the approved relative fragment" + assert "RUSTUP_TOOLCHAIN=nightly-2026-08-23" in routing, ( + "debug routes must select the pinned dev-fast nightly" + ) + assert "-Clink-arg=-fuse-ld=mold" in routing, ( + "explicit RUSTFLAGS must retain the Linux mold linker selection" ) - assert "RUSTUP_TOOLCHAIN=nightly-2026-08-23" in routing @pytest.mark.parametrize( @@ -114,9 +189,22 @@ def test_linux_debug_routes_select_the_fragment_and_injected_cargo() -> None: def test_release_maturin_routes_do_not_select_dev_fast(flags: str) -> None: """Release spellings cannot accidentally route Maturin through Cranelift.""" output = _dry_run("develop", variables={"MATURIN_DEVELOP_FLAGS": flags}) - assert "tools/dev-fast/cargo" not in output - assert "DEV_FAST_CARGO=" not in output - assert "RUSTUP_TOOLCHAIN=nightly-2026-08-23" not in output + assert "tools/dev-fast/cargo" not in output, "release Maturin must skip bridge" + assert "DEV_FAST_CARGO=" not in output, "release Maturin must skip adapter env" + assert "RUSTUP_TOOLCHAIN=nightly-2026-08-23" not in output, ( + "release Maturin must not select the dev-fast nightly" + ) + + +@settings(max_examples=24, deadline=None) +@given(st.lists(SAFE_MATURIN_FLAGS, max_size=7)) +def test_maturin_release_classification_is_order_independent(flags: list[str]) -> None: + """Generated safe flag sequences preserve the documented release boundary.""" + output = _dry_run("develop", variables={"MATURIN_DEVELOP_FLAGS": " ".join(flags)}) + is_release = _is_release_maturin_flags(flags) + assert ("tools/dev-fast/cargo" not in output) is is_release, ( + f"flags {flags!r} must {'skip' if is_release else 'select'} the dev-fast bridge" + ) def test_non_linux_alternative_stays_on_the_stable_cargo_route() -> None: @@ -124,9 +212,11 @@ def test_non_linux_alternative_stays_on_the_stable_cargo_route() -> None: output = _dry_run( "develop", "test-rust", "rust-lint", variables={"DEV_FAST_HOST_IS_LINUX": ""} ) - assert "tools/dev-fast/cargo" not in output - assert "--config ../tools/dev-fast/config.toml" not in output - assert "RUSTUP_TOOLCHAIN=nightly-2026-08-23" not in output + assert "tools/dev-fast/cargo" not in output, "non-Linux must skip bridge" + assert "--config ../tools/dev-fast/config.toml" not in output, ( + "non-Linux must not select the fragment" + ) + assert "nightly-2026-08-23" not in output, "non-Linux must use its stable route" def test_whitaker_and_windows_lint_remain_fragment_free() -> None: @@ -138,19 +228,23 @@ def test_whitaker_and_windows_lint_remain_fragment_free() -> None: for line in output.splitlines() if "--target x86_64-pc-windows-msvc" in line ) - assert "--config" not in whitaker - assert "RUSTUP_TOOLCHAIN=nightly-2026-08-23" not in whitaker - assert "--config" not in windows - assert "RUSTUP_TOOLCHAIN=nightly-2026-08-23" not in windows + assert "--config" not in whitaker, "Whitaker must not select a Cargo fragment" + assert "RUSTUP_TOOLCHAIN=nightly-2026-08-23" not in whitaker, ( + "Whitaker must keep its verifier toolchain" + ) + assert "--config" not in windows, "Windows lint must not select Linux fragment" + assert "RUSTUP_TOOLCHAIN=nightly-2026-08-23" not in windows, ( + "Windows lint must retain its platform route" + ) 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") route = next(line for line in output.splitlines() if "probe-cargo check" in line) - assert "RUSTUP_TOOLCHAIN=1.85.0" in route - assert "--config" not in route - assert "nightly-2026-08-23" not in route + assert "RUSTUP_TOOLCHAIN=1.85.0" in route, "MSRV check must pin Rust 1.85.0" + assert "--config" not in route, "MSRV check must not select the fragment" + assert "nightly-2026-08-23" not in route, "MSRV check must not use nightly" @pytest.mark.parametrize( @@ -186,104 +280,25 @@ def test_protected_routes_reject_dev_fast_contamination( _assert_fragment_mutation_is_rejected(protected_route) -def _bridge_environment(tmp_path: Path) -> tuple[dict[str, str], Path]: - """Create a recording Cargo child for the adapter's process contract.""" - argv_path = tmp_path / "argv" - child = tmp_path / "cargo-child" - child.write_text( - '#!/usr/bin/env bash\nprintf \'%s\\n\' "$@" > "$DEV_FAST_ARGV"\nexit 47\n', - encoding="utf-8", - ) - child.chmod(0o755) - return { - "DEV_FAST_ARGV": str(argv_path), - "DEV_FAST_CARGO": str(child), - "DEV_FAST_CONFIG": str(repo_root() / FRAGMENT), - }, argv_path - - -def test_bridge_injects_one_fragment_and_preserves_child_exit( - tmp_path: Path, -) -> None: - """The Maturin adapter preserves Cargo's argv and status through exec.""" - environment, argv_path = _bridge_environment(tmp_path) - result = subprocess.run( # ruff: ignore[subprocess-without-shell-equals-true] - [repo_root() / "tools/dev-fast/cargo", "rustc", "--lib"], - check=False, - cwd=repo_root(), - env={**os.environ, **environment}, - ) - assert result.returncode == 47 - assert argv_path.read_text(encoding="utf-8").splitlines() == [ - "--config", - environment["DEV_FAST_CONFIG"], - "rustc", - "--lib", - ] - - -@pytest.mark.parametrize("config_flag", ["--config", "--config=other.toml"]) -def test_bridge_rejects_duplicate_configuration( - tmp_path: Path, config_flag: str -) -> None: - """A caller cannot override or duplicate the approved configuration.""" - environment, argv_path = _bridge_environment(tmp_path) - command = [repo_root() / "tools/dev-fast/cargo", "rustc", config_flag] - if config_flag == "--config": - command.append("other.toml") - result = subprocess.run( # ruff: ignore[subprocess-without-shell-equals-true] - command, check=False, cwd=repo_root(), env={**os.environ, **environment} - ) - assert result.returncode == 2 - assert not argv_path.exists() - - -def _write_program(directory: Path, name: str, body: str) -> None: - """Create one controlled executable used to mutate a prerequisite.""" - program = directory / name - program.write_text(f"#!/usr/bin/env bash\n{body}\n", encoding="utf-8") - program.chmod(0o755) - - -@pytest.mark.parametrize( - "programs", - [ - pytest.param({}, id="missing_mold"), - pytest.param( - {"mold": "printf '%s\\n' 'mold 2.41.0'", "rustup": "exit 0"}, - id="missing_component", - ), - ], -) -def test_prerequisite_recipe_fails_closed_for_missing_dependencies( - tmp_path: Path, programs: dict[str, str] -) -> None: - """Missing linker or component is a hard failure, not an optimistic skip.""" - for name, body in programs.items(): - _write_program(tmp_path, name, body) - make = shutil.which("make") - assert make is not None - result = subprocess.run( # ruff: ignore[subprocess-without-shell-equals-true] - [make, "dev-fast-check", "DEV_FAST_HOST_IS_LINUX=yes"], - check=False, - cwd=repo_root(), - env={**os.environ, "PATH": f"{tmp_path}:/usr/bin:/bin"}, - text=True, - ) - assert result.returncode != 0 - - def test_ci_provisions_only_the_pinned_linux_prerequisites() -> None: """CI installs only the approved binary prerequisites for Linux debug work.""" action = (repo_root() / ".github/actions/setup-dev-fast/action.yml").read_text( encoding="utf-8" ) workflow = (repo_root() / ".github/workflows/ci.yml").read_text(encoding="utf-8") - assert "nightly-2026-08-23" in action - assert "rustc-codegen-cranelift" in action - assert 'rustup component add "${component}" clippy' in action - assert 'archive="mold-${version}-${architecture}-linux.tar.gz"' in action - assert "sha256sum --check --status" in action - assert "cargo install" not in action - assert workflow.count("uses: ./.github/actions/setup-dev-fast") == 2 - assert "- name: Verify Rust MSRV\n run: make msrv-check" in workflow + assert "nightly-2026-08-23" in action, "CI must install the pinned nightly" + assert "rustc-codegen-cranelift" in action, "CI must install Cranelift" + assert 'rustup component add "${component}" clippy' in action, ( + "CI must provision the lint component with the backend" + ) + assert 'archive="mold-${version}-${architecture}-linux.tar.gz"' in action, ( + "CI must derive the architecture-specific archive" + ) + assert "sha256sum --check --status" in action, "CI must verify downloaded mold" + assert "cargo install" not in action, "CI must not add a source-build fallback" + assert workflow.count("uses: ./.github/actions/setup-dev-fast") == 2, ( + "only Linux debug jobs may provision dev-fast" + ) + assert "- name: Verify Rust MSRV\n run: make msrv-check" in workflow, ( + "CI must retain independent MSRV verification" + ) diff --git a/cuprum/unittests/test_dev_fast_prerequisites.py b/cuprum/unittests/test_dev_fast_prerequisites.py new file mode 100644 index 000000000..8013283d4 --- /dev/null +++ b/cuprum/unittests/test_dev_fast_prerequisites.py @@ -0,0 +1,144 @@ +"""Exercise dev-fast prerequisites and one routed build command end to end.""" + +from __future__ import annotations + +import os +import shutil +import subprocess # ruff: ignore[suspicious-subprocess-import] - controlled Make argv. +import typing as typ + +import pytest + +from tests.helpers.docs import repo_root + +if typ.TYPE_CHECKING: + from pathlib import Path + + +MOLD_VERSION = (repo_root() / "tools/mold/VERSION").read_text(encoding="utf-8").strip() + + +def _write_program(directory: Path, name: str, body: str) -> None: + """Create one controlled executable used to mutate a prerequisite.""" + program = directory / name + program.write_text(f"#!/usr/bin/env bash\n{body}\n", encoding="utf-8") + program.chmod(0o755) + + +@pytest.mark.parametrize( + ("programs", "variables", "expected_diagnostic"), + [ + pytest.param({}, {}, f"mold {MOLD_VERSION} is required", id="missing_mold"), + pytest.param( + {"mold": "printf '%s\\n' 'mold 2.41.00'", "rustup": "exit 0"}, + {}, + f"mold {MOLD_VERSION} is required", + id="wrong_mold_version", + ), + pytest.param( + {"mold": f"printf '%s\\n' 'mold {MOLD_VERSION}'", "rustup": "exit 0"}, + {}, + "install rustc-codegen-cranelift", + id="missing_component", + ), + pytest.param( + {}, + {"DEV_FAST_CONFIG": "missing-dev-fast.toml"}, + "dev-fast configuration is missing", + id="missing_fragment", + ), + ], +) +def test_prerequisite_recipe_fails_closed_for_missing_dependencies( + tmp_path: Path, + programs: dict[str, str], + variables: dict[str, str], + expected_diagnostic: str, +) -> None: + """Missing or mismatched prerequisites are hard failures with useful output.""" + for name, body in programs.items(): + _write_program(tmp_path, name, body) + make = shutil.which("make") + assert make is not None, "the prerequisite contract requires GNU Make on PATH" + result = subprocess.run( # ruff: ignore[subprocess-without-shell-equals-true] - fixed Make argv. + [ + make, + "dev-fast-check", + "DEV_FAST_HOST_IS_LINUX=yes", + *(f"{key}={value}" for key, value in variables.items()), + ], + check=False, + capture_output=True, + cwd=repo_root(), + env={**os.environ, "PATH": f"{tmp_path}:/usr/bin:/bin"}, + text=True, + ) + assert result.returncode != 0, "missing prerequisites must fail closed" + assert expected_diagnostic in result.stderr, ( + f"the prerequisite failure must explain how to repair it: {result.stderr}" + ) + + +def test_prerequisite_recipe_accepts_the_pinned_dependencies(tmp_path: Path) -> None: + """The closed prerequisite gate accepts exactly the pinned linker and component.""" + _write_program(tmp_path, "mold", f"printf '%s\\n' 'mold {MOLD_VERSION}'") + _write_program( + tmp_path, + "rustup", + "printf '%s\\n' 'rustc-codegen-cranelift (installed)'", + ) + make = shutil.which("make") + assert make is not None, "the prerequisite contract requires GNU Make on PATH" + result = subprocess.run( # ruff: ignore[subprocess-without-shell-equals-true] - fixed Make argv. + [make, "dev-fast-check", "DEV_FAST_HOST_IS_LINUX=yes"], + check=False, + capture_output=True, + cwd=repo_root(), + env={**os.environ, "PATH": f"{tmp_path}:/usr/bin:/bin"}, + text=True, + ) + assert result.returncode == 0, result.stderr + + +def test_dev_build_executes_the_approved_fragment(tmp_path: Path) -> None: + """A Linux debug build invokes Cargo once with the explicit approved fragment.""" + _write_program(tmp_path, "mold", f"printf '%s\n' 'mold {MOLD_VERSION}'") + _write_program( + tmp_path, + "rustup", + "printf '%s\n' 'rustc-codegen-cranelift (installed)'", + ) + invocation = tmp_path / "cargo-invocation" + cargo = tmp_path / "cargo" + cargo.write_text( + '#!/usr/bin/env bash\nprintf \'%s\n\' "$@" > "$DEV_FAST_TEST_CARGO_ARGV"\n', + encoding="utf-8", + ) + cargo.chmod(0o755) + make = shutil.which("make") + assert make is not None, "the routed-build contract requires GNU Make on PATH" + result = subprocess.run( # ruff: ignore[subprocess-without-shell-equals-true] - fixed Make argv. + [ + make, + "dev-build", + f"CARGO={cargo}", + "DEV_FAST_HOST_IS_LINUX=yes", + ], + check=False, + capture_output=True, + cwd=repo_root(), + env={ + **os.environ, + "DEV_FAST_TEST_CARGO_ARGV": str(invocation), + "PATH": f"{tmp_path}:/usr/bin:/bin", + }, + text=True, + ) + assert result.returncode == 0, result.stderr + assert invocation.read_text(encoding="utf-8").splitlines() == [ + "--config", + "../tools/dev-fast/config.toml", + "build", + "--all-targets", + "--all-features", + ], "the executed debug build must select exactly the approved configuration" diff --git a/docs/adr-012-linux-dev-fast-routing.md b/docs/adr-012-linux-dev-fast-routing.md new file mode 100644 index 000000000..cf7b0a29d --- /dev/null +++ b/docs/adr-012-linux-dev-fast-routing.md @@ -0,0 +1,67 @@ +# Architectural decision record (ADR) 012: Linux dev-fast routing + +## Status + +Accepted on 2026-09-17. Cuprum selects the dev-fast Cargo configuration only +for supported Linux debug work through explicit Makefile routes. + +## Date + +2026-09-17. + +## Context and problem statement + +Rust compile latency slows the ordinary development loop, but an accelerated +compiler backend and Linux linker cannot become ambient Cargo configuration. +Release packaging, coverage, formal verification, Whitaker, platform-specific +linting, and published minimum-supported Rust version (MSRV) verification need +the repository's stable backend and their existing toolchain contracts. Cargo +also lets `RUSTFLAGS` replace configured flags, so test routing must preserve +mold selection when it supplies warning and code-generation flags. + +Maturin accepts a Cargo executable through `CARGO`, but cannot supply Cargo's +configuration-file option. The configuration must therefore be injected without +letting build scripts recursively invoke the adapter or callers substitute a +different configuration. + +## Decision + +The Makefile pins a separate dev-fast nightly and requires its +`rustc-codegen-cranelift` component. On Linux, standard debug `develop`, +`test-rust`, and Rust documentation and Clippy invocations select the explicit +`tools/dev-fast/config.toml` fragment. `dev-build` and `dev-test` expose the +same route. The fragment stays outside Cargo's auto-discovered configuration. + +The fragment applies Cranelift only to the dev profile and supplies the Linux +mold linker flag. Makefile test routes repeat that linker flag whenever they set +`RUSTFLAGS`, because Cargo chooses that environment source instead of merging +the fragment's target flags. `make dev-fast-check` validates the selected +fragment, the version from `tools/mold/VERSION`, and the installed component +before an accelerated route runs. + +The Linux-only `tools/dev-fast/cargo` adapter receives the real Cargo +executable and immutable fragment path from Make. It adds exactly one +`--config` option, exports the real executable as `CARGO` for child build +scripts, rejects caller configuration options, and replaces itself with Cargo +to preserve argv and exit status. + +The shared CI action downloads only checksum-pinned upstream mold binaries for +supported Linux architectures. It has no source-build fallback. CI installs the +same nightly and component for debug jobs. The stable route remains mandatory +for release, coverage, verification, Whitaker, MSRV checks, and macOS and +Windows. Every workspace member inherits Rust 1.85.0, and `make msrv-check` +compiles every target at that version without the fragment. + +## Consequences + +Contributors gain faster Linux debug builds after installing the explicit +prerequisites. Direct Cargo use remains unchanged unless the caller selects the +fragment deliberately. Unsupported platforms retain a clear stable route rather +than a partially working linker setup. + +The contract tests execute the composite-action shell step with controlled +commands, test both supported architectures and failure boundaries, model +release flag orderings, and exercise adapter argv and environment behaviour. +They complement Make dry-run tests, which prove routing text but not shell +installer behaviour. Pin changes must update the version metadata, checksums, +configuration digest, tests, and this decision if the routing boundary changes. diff --git a/docs/contents.md b/docs/contents.md index 8c6e8e7a6..bce796ee0 100644 --- a/docs/contents.md +++ b/docs/contents.md @@ -54,6 +54,8 @@ open each document. opt-in executor-hop tracing without extending the pump event channel. - [ADR-011: Audited Rust safety boundaries][adr-011] - accepted decision to isolate native resource operations from safe stream policy. +- [ADR-012: Linux dev-fast routing][adr-012] - accepted decision to confine + accelerated Cargo builds to explicit, supported Linux debug routes. ## Planning and validation references @@ -82,6 +84,7 @@ open each document. [adr-009]: adr-009-enforce-oxford-spelling-in-source.md [adr-010]: adr-010-rust-pump-hop-span.md [adr-011]: adr-011-audited-rust-boundaries.md +[adr-012]: adr-012-linux-dev-fast-routing.md [local-validation]: local-validation-of-github-actions-with-act-and-pytest.md [migration-020]: migration-0.2.0.md [rust-boundary-verification]: rust-boundary-verification.md diff --git a/docs/developers-guide.md b/docs/developers-guide.md index 5719b100f..77e9a19f3 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -397,6 +397,9 @@ focused, small, and easy to review. ## Linux debug-build acceleration +[ADR-012](adr-012-linux-dev-fast-routing.md) records the routing boundary and +its compatibility rationale. + Cuprum's Rust `1.85.0` pin remains the compatibility, release, coverage, verification, and Whitaker toolchain. On Linux only, ordinary debug Rust work uses the separately pinned `nightly-2026-08-23` and the @@ -404,7 +407,7 @@ uses the separately pinned `nightly-2026-08-23` and the fragment is an approved byte-for-byte local extension from `leynos/netsuke@924cb215d3841048dbf6649767a510d2a5dfb1b7`, recorded by Concordat issue 157; its SHA-256 is -`d8fcc29ce680ecf43e96f024bd8d0e1a378d5998efdafc8221902735f9ff4ec4`. +`8619efda5ea1c3232f413ae96ff56869ab6b2b7cd5bdef5a001ad16ebeac23a5`. Run `make dev-fast-check` before the first Linux accelerated build. It requires the nightly component and the pinned `mold` 2.41.0 linker. The approved binary @@ -418,9 +421,9 @@ the caller explicitly supplies the fragment. Every workspace package inherits `rust-version = "1.85.0"` from the root manifest. Cargo treats that as the publication and compatibility contract, and MSRV-aware Clippy lints use it while the Linux debug lint route runs on the -maintenance nightly. `make msrv-check` separately compiles every workspace -target on Rust `1.85.0` without the fragment, so the accelerated lint compiler -cannot replace compatibility verification. +dev-fast nightly. `make msrv-check` separately compiles every workspace target +on Rust `1.85.0` without the fragment, so the accelerated lint compiler cannot +replace compatibility verification. Maturin accepts a Cargo executable but cannot pass Cargo's configuration-file flag itself. The Linux-only `tools/dev-fast/cargo` adapter receives the injected @@ -431,10 +434,10 @@ standard because it must preserve Cargo's exact argv and exit status. macOS and Windows use the stable backend as the approved alternative. The Windows cross-target lint, wheel builds, benchmark release builds, coverage, -verification, and Whitaker never select the fragment. The canonical fragment's -provenance comment refers to a repository-owned toolchain; Cuprum deliberately -uses the separately pinned maintenance nightly above to preserve its stable -support promise. +verification, and Whitaker never select the fragment. The fragment documents +Cuprum's ownership boundary: Makefile pins the dev-fast nightly, while +`tools/mold/VERSION` pins the linker release. The stable support promise +remains independently verified. ## Tar and rsync builder helpers diff --git a/tests/test_dev_fast_action.py b/tests/test_dev_fast_action.py new file mode 100644 index 000000000..2b063b5a6 --- /dev/null +++ b/tests/test_dev_fast_action.py @@ -0,0 +1,228 @@ +"""Execute the Linux dev-fast composite action under controlled shell fixtures.""" + +from __future__ import annotations + +import os +from pathlib import Path + +import pytest + +from tests.helpers.composite_actions import StepResult, run_step, step_script + +ACTION = ".github/actions/setup-dev-fast" +INSTALL_STEP = "Install the pinned backend and linker" +MOLD_VERSION = "2.41.0" +CHECKSUM = "a" * 64 + + +def _write_program(directory: Path, name: str, body: str) -> None: + """Write one controlled shell command for an action-step test.""" + program = directory / name + program.write_text(f"#!/usr/bin/env bash\nset -eu\n{body}\n", encoding="utf-8") + program.chmod(0o755) + + +def _write_mold_metadata(directory: Path, checksum_lines: str) -> None: + """Create the action's repository-relative linker metadata.""" + mold = directory / "tools/mold" + mold.mkdir(parents=True) + (mold / "VERSION").write_text(f"{MOLD_VERSION}\n", encoding="utf-8") + (mold / "SHA256SUMS").write_text(checksum_lines, encoding="utf-8") + + +def _action_environment( + tmp_path: Path, architecture: str, *, sha_status: int = 0 +) -> dict[str, str]: + """Install fake trusted commands and return the emulated runner environment.""" + commands = tmp_path / "bin" + commands.mkdir() + records = tmp_path / "records" + records.mkdir() + _write_program(commands, "uname", f"printf '%s\\n' '{architecture}'") + _write_program( + commands, "rustup", 'printf \'%s\\n\' "$@" >> "$DEV_FAST_RECORDS/rustup"' + ) + _write_program( + commands, + "curl", + "output=''\n" + "while [ $# -gt 0 ]; do\n" + ' if [ "$1" = --output ]; then output="$2"; shift 2; continue; fi\n' + ' printf \'%s\\n\' "$1" >> "$DEV_FAST_RECORDS/curl"\n' + " shift\n" + "done\n" + 'mkdir -p "$(dirname "$output")"\n' + 'printf archive > "$output"', + ) + _write_program( + commands, + "sha256sum", + 'cat > "$DEV_FAST_RECORDS/checksum"\nexit "$DEV_FAST_SHA_STATUS"', + ) + _write_program(commands, "tar", 'printf \'%s\\n\' "$@" > "$DEV_FAST_RECORDS/tar"') + github_path = tmp_path / "github_path" + return { + "PATH": f"{commands}:{os.environ['PATH']}", + "DEV_FAST_RECORDS": str(records), + "DEV_FAST_SHA_STATUS": str(sha_status), + "RUNNER_TEMP": str(tmp_path / "runner-temp"), + "GITHUB_PATH": str(github_path), + } + + +def _run_install(tmp_path: Path, environment: dict[str, str]) -> StepResult: + """Execute the action's installation step with the supplied fake tools.""" + return run_step( + step_script(ACTION, INSTALL_STEP), workdir=tmp_path, environment=environment + ) + + +@pytest.mark.parametrize( + ("architecture", "archive"), + [ + pytest.param("x86_64", "mold-2.41.0-x86_64-linux.tar.gz", id="x86_64"), + pytest.param("aarch64", "mold-2.41.0-aarch64-linux.tar.gz", id="aarch64"), + ], +) +def test_install_selects_the_pinned_archive_and_exports_its_path( + tmp_path: Path, architecture: str, archive: str +) -> None: + """Supported architectures download, check, extract, and expose one archive.""" + _write_mold_metadata(tmp_path, f"{CHECKSUM} {archive}\n") + environment = _action_environment(tmp_path, architecture) + result = _run_install(tmp_path, environment) + records = Path(environment["DEV_FAST_RECORDS"]) + assert result.returncode == 0, result.stderr + assert records.joinpath("rustup").read_text(encoding="utf-8").splitlines() == [ + "toolchain", + "install", + "nightly-2026-08-23", + "--profile", + "minimal", + "component", + "add", + "rustc-codegen-cranelift", + "clippy", + "--toolchain", + "nightly-2026-08-23", + ], "the action must provision only the pinned nightly components" + assert ( + records + .joinpath("curl") + .read_text(encoding="utf-8") + .splitlines()[-1] + .endswith(f"/v{MOLD_VERSION}/{archive}") + ), "the action must fetch the selected upstream archive" + assert records.joinpath("checksum").read_text(encoding="utf-8") == ( + f"{CHECKSUM} {environment['RUNNER_TEMP']}/{archive}\n" + ), "the action must verify the pinned checksum against its downloaded archive" + tar_arguments = records.joinpath("tar").read_text(encoding="utf-8").splitlines() + assert tar_arguments == [ + "--extract", + "--gzip", + "--strip-components=1", + "--directory", + f"{tmp_path}/.local", + "--file", + f"{environment['RUNNER_TEMP']}/{archive}", + ], "the action must extract the verified archive into the local tool path" + assert Path(environment["GITHUB_PATH"]).read_text(encoding="utf-8") == ( + f"{tmp_path}/.local/bin\n" + ), "the action must expose the installed linker to later workflow steps" + + +@pytest.mark.parametrize("architecture", ["amd64", "arm64"]) +def test_install_accepts_github_runner_architecture_aliases( + tmp_path: Path, architecture: str +) -> None: + """GitHub runner architecture aliases map to their canonical mold archive.""" + canonical = "x86_64" if architecture == "amd64" else "aarch64" + archive = f"mold-{MOLD_VERSION}-{canonical}-linux.tar.gz" + _write_mold_metadata(tmp_path, f"{CHECKSUM} {archive}\n") + environment = _action_environment(tmp_path, architecture) + result = _run_install(tmp_path, environment) + assert result.returncode == 0, result.stderr + + +def test_install_rejects_an_unsupported_architecture_before_provisioning( + tmp_path: Path, +) -> None: + """An unsupported runner fails before installing or downloading anything.""" + _write_mold_metadata(tmp_path, "") + environment = _action_environment(tmp_path, "riscv64") + result = _run_install(tmp_path, environment) + records = Path(environment["DEV_FAST_RECORDS"]) + assert result.returncode != 0, "unsupported architecture must fail closed" + assert "unsupported mold architecture: riscv64" in result.stderr + assert not records.joinpath("rustup").exists(), ( + "unsupported hosts must not install nightly" + ) + assert not records.joinpath("curl").exists(), ( + "unsupported hosts must not download mold" + ) + + +@pytest.mark.parametrize( + "checksum_lines", + [ + pytest.param("", id="zero_entries"), + pytest.param( + "\n".join([ + f"{CHECKSUM} mold-2.41.0-x86_64-linux.tar.gz", + f"{'b' * 64} mold-2.41.0-x86_64-linux.tar.gz", + "", + ]), + id="multiple_entries", + ), + ], +) +def test_install_requires_exactly_one_checksum_entry( + tmp_path: Path, checksum_lines: str +) -> None: + """The action refuses missing and ambiguous checksum metadata before download.""" + _write_mold_metadata(tmp_path, checksum_lines) + environment = _action_environment(tmp_path, "x86_64") + result = _run_install(tmp_path, environment) + records = Path(environment["DEV_FAST_RECORDS"]) + assert result.returncode != 0, "checksum metadata must identify exactly one archive" + assert not records.joinpath("curl").exists(), ( + "ambiguous checksums must stop download" + ) + + +def test_install_propagates_a_download_failure(tmp_path: Path) -> None: + """A failed curl command stops before checksum verification or extraction.""" + archive = "mold-2.41.0-x86_64-linux.tar.gz" + _write_mold_metadata(tmp_path, f"{CHECKSUM} {archive}\n") + environment = _action_environment(tmp_path, "x86_64") + commands = Path(environment["PATH"].split(":", maxsplit=1)[0]) + _write_program(commands, "curl", "printf 'download failed\\n' >&2\nexit 22") + result = _run_install(tmp_path, environment) + records = Path(environment["DEV_FAST_RECORDS"]) + assert result.returncode == 22, "curl's failure status must fail the action" + assert "download failed" in result.stderr + assert not records.joinpath("checksum").exists(), ( + "failed downloads must not be checked" + ) + assert not records.joinpath("tar").exists(), ( + "failed downloads must not be extracted" + ) + + +def test_install_propagates_a_checksum_mismatch(tmp_path: Path) -> None: + """A checksum failure stops before archive extraction or path export.""" + archive = "mold-2.41.0-x86_64-linux.tar.gz" + _write_mold_metadata(tmp_path, f"{CHECKSUM} {archive}\n") + environment = _action_environment(tmp_path, "x86_64", sha_status=1) + result = _run_install(tmp_path, environment) + records = Path(environment["DEV_FAST_RECORDS"]) + assert result.returncode == 1, "checksum mismatch must fail the action" + assert records.joinpath("checksum").exists(), ( + "the downloaded archive must be checked" + ) + assert not records.joinpath("tar").exists(), ( + "mismatched archives must not be extracted" + ) + assert not Path(environment["GITHUB_PATH"]).exists(), ( + "a rejected archive must not expose an installation path" + ) diff --git a/tools/dev-fast/config.toml b/tools/dev-fast/config.toml index 0bc4ec1f9..d14a20163 100644 --- a/tools/dev-fast/config.toml +++ b/tools/dev-fast/config.toml @@ -7,8 +7,8 @@ # passed explicitly with `cargo --config tools/dev-fast/config.toml ...` from # the `make dev-*` targets. # -# The toolchain is the repository's own, from `rust-toolchain.toml`; only the -# mold release is pinned here, in `tools/mold/VERSION`. See +# Makefile pins the dev-fast nightly through `DEV_FAST_TOOLCHAIN`; only the +# mold release is pinned in `tools/mold/VERSION`. See # `docs/developers-guide.md` for the ownership boundary and composition rules. [unstable] From 5e47c94b7594fc7fce9e805b0020e357bf0f2b62 Mon Sep 17 00:00:00 2001 From: leynos Date: Thu, 17 Sep 2026 15:03:17 +0200 Subject: [PATCH 04/12] Fix dev-fast gate regressions Snapshot the action installation command contract, add assertion diagnostics, and regenerate the narrow linker spelling exceptions through the floating builder. --- cuprum/unittests/test_dev_fast_contract.py | 8 +-- .../unittests/test_dev_fast_prerequisites.py | 10 ++- tests/__snapshots__/test_dev_fast_action.ambr | 53 +++++++++++++++ tests/test_dev_fast_action.py | 66 ++++++++++--------- typos.local.toml | 29 +++++++- typos.toml | 22 +++++++ 6 files changed, 148 insertions(+), 40 deletions(-) create mode 100644 tests/__snapshots__/test_dev_fast_action.ambr diff --git a/cuprum/unittests/test_dev_fast_contract.py b/cuprum/unittests/test_dev_fast_contract.py index 7e4bd8c46..5d2f825d1 100644 --- a/cuprum/unittests/test_dev_fast_contract.py +++ b/cuprum/unittests/test_dev_fast_contract.py @@ -104,14 +104,14 @@ def test_fragment_digest_rejects_content_mutation() -> None: ) -def test_make_reads_the_mold_version_from_its_pin() -> None: +def test_make_reads_the_linker_version_from_its_pin() -> None: """Keep prerequisite diagnostics coupled to the checked-in linker pin.""" makefile = (repo_root() / "Makefile").read_text(encoding="utf-8") assert "DEV_FAST_MOLD_VERSION_FILE ?= tools/mold/VERSION" in makefile, ( - "the prerequisite check must read the tracked mold version file" + "the prerequisite check must read the tracked linker version file" ) assert "mold 2.41.0 is required" not in makefile, ( - "Make diagnostics must not retain a stale hard-coded mold version" + "Make diagnostics must not retain a stale hard-coded linker version" ) @@ -174,7 +174,7 @@ def test_linux_debug_routes_select_the_fragment_and_injected_cargo() -> None: "debug routes must select the pinned dev-fast nightly" ) assert "-Clink-arg=-fuse-ld=mold" in routing, ( - "explicit RUSTFLAGS must retain the Linux mold linker selection" + "explicit RUSTFLAGS must retain the Linux linker selection" ) diff --git a/cuprum/unittests/test_dev_fast_prerequisites.py b/cuprum/unittests/test_dev_fast_prerequisites.py index 8013283d4..f3344ea60 100644 --- a/cuprum/unittests/test_dev_fast_prerequisites.py +++ b/cuprum/unittests/test_dev_fast_prerequisites.py @@ -30,13 +30,19 @@ def _write_program(directory: Path, name: str, body: str) -> None: [ pytest.param({}, {}, f"mold {MOLD_VERSION} is required", id="missing_mold"), pytest.param( - {"mold": "printf '%s\\n' 'mold 2.41.00'", "rustup": "exit 0"}, + { + "mold": "printf '%s\\n' 'mold 2.41.00'", + "rustup": "exit 0", + }, {}, f"mold {MOLD_VERSION} is required", id="wrong_mold_version", ), pytest.param( - {"mold": f"printf '%s\\n' 'mold {MOLD_VERSION}'", "rustup": "exit 0"}, + { + "mold": f"printf '%s\\n' 'mold {MOLD_VERSION}'", + "rustup": "exit 0", + }, {}, "install rustc-codegen-cranelift", id="missing_component", diff --git a/tests/__snapshots__/test_dev_fast_action.ambr b/tests/__snapshots__/test_dev_fast_action.ambr new file mode 100644 index 000000000..5803c24c3 --- /dev/null +++ b/tests/__snapshots__/test_dev_fast_action.ambr @@ -0,0 +1,53 @@ +# serializer version: 1 +# name: test_install_selects_the_pinned_archive_and_exports_its_path[aarch64] + dict({ + 'rustup': list([ + 'toolchain', + 'install', + 'nightly-2026-08-23', + '--profile', + 'minimal', + 'component', + 'add', + 'rustc-codegen-cranelift', + 'clippy', + '--toolchain', + 'nightly-2026-08-23', + ]), + 'tar': list([ + '--extract', + '--gzip', + '--strip-components=1', + '--directory', + '/.local', + '--file', + '/runner-temp/mold-2.41.0-aarch64-linux.tar.gz', + ]), + }) +# --- +# name: test_install_selects_the_pinned_archive_and_exports_its_path[x86_64] + dict({ + 'rustup': list([ + 'toolchain', + 'install', + 'nightly-2026-08-23', + '--profile', + 'minimal', + 'component', + 'add', + 'rustc-codegen-cranelift', + 'clippy', + '--toolchain', + 'nightly-2026-08-23', + ]), + 'tar': list([ + '--extract', + '--gzip', + '--strip-components=1', + '--directory', + '/.local', + '--file', + '/runner-temp/mold-2.41.0-x86_64-linux.tar.gz', + ]), + }) +# --- diff --git a/tests/test_dev_fast_action.py b/tests/test_dev_fast_action.py index 2b063b5a6..918546521 100644 --- a/tests/test_dev_fast_action.py +++ b/tests/test_dev_fast_action.py @@ -3,12 +3,16 @@ from __future__ import annotations import os +import typing as typ from pathlib import Path import pytest from tests.helpers.composite_actions import StepResult, run_step, step_script +if typ.TYPE_CHECKING: + from syrupy.assertion import SnapshotAssertion + ACTION = ".github/actions/setup-dev-fast" INSTALL_STEP = "Install the pinned backend and linker" MOLD_VERSION = "2.41.0" @@ -24,10 +28,10 @@ def _write_program(directory: Path, name: str, body: str) -> None: def _write_mold_metadata(directory: Path, checksum_lines: str) -> None: """Create the action's repository-relative linker metadata.""" - mold = directory / "tools/mold" - mold.mkdir(parents=True) - (mold / "VERSION").write_text(f"{MOLD_VERSION}\n", encoding="utf-8") - (mold / "SHA256SUMS").write_text(checksum_lines, encoding="utf-8") + metadata_directory = directory / "tools/mold" + metadata_directory.mkdir(parents=True) + (metadata_directory / "VERSION").write_text(f"{MOLD_VERSION}\n", encoding="utf-8") + (metadata_directory / "SHA256SUMS").write_text(checksum_lines, encoding="utf-8") def _action_environment( @@ -85,7 +89,7 @@ def _run_install(tmp_path: Path, environment: dict[str, str]) -> StepResult: ], ) def test_install_selects_the_pinned_archive_and_exports_its_path( - tmp_path: Path, architecture: str, archive: str + tmp_path: Path, architecture: str, archive: str, snapshot: SnapshotAssertion ) -> None: """Supported architectures download, check, extract, and expose one archive.""" _write_mold_metadata(tmp_path, f"{CHECKSUM} {archive}\n") @@ -93,19 +97,15 @@ def test_install_selects_the_pinned_archive_and_exports_its_path( result = _run_install(tmp_path, environment) records = Path(environment["DEV_FAST_RECORDS"]) assert result.returncode == 0, result.stderr - assert records.joinpath("rustup").read_text(encoding="utf-8").splitlines() == [ - "toolchain", - "install", - "nightly-2026-08-23", - "--profile", - "minimal", - "component", - "add", - "rustc-codegen-cranelift", - "clippy", - "--toolchain", - "nightly-2026-08-23", - ], "the action must provision only the pinned nightly components" + rustup_arguments = ( + records.joinpath("rustup").read_text(encoding="utf-8").splitlines() + ) + assert rustup_arguments[:2] == ["toolchain", "install"], ( + "the action must install its dev-fast toolchain" + ) + assert "rustc-codegen-cranelift" in rustup_arguments, ( + "the action must install the Cranelift component" + ) assert ( records .joinpath("curl") @@ -117,15 +117,15 @@ def test_install_selects_the_pinned_archive_and_exports_its_path( f"{CHECKSUM} {environment['RUNNER_TEMP']}/{archive}\n" ), "the action must verify the pinned checksum against its downloaded archive" tar_arguments = records.joinpath("tar").read_text(encoding="utf-8").splitlines() - assert tar_arguments == [ - "--extract", - "--gzip", - "--strip-components=1", - "--directory", - f"{tmp_path}/.local", - "--file", - f"{environment['RUNNER_TEMP']}/{archive}", - ], "the action must extract the verified archive into the local tool path" + installation_commands = { + "rustup": rustup_arguments, + "tar": [ + argument.replace(str(tmp_path), "") for argument in tar_arguments + ], + } + assert installation_commands == snapshot, ( + "the backend installation and extraction command contracts changed" + ) assert Path(environment["GITHUB_PATH"]).read_text(encoding="utf-8") == ( f"{tmp_path}/.local/bin\n" ), "the action must expose the installed linker to later workflow steps" @@ -135,7 +135,7 @@ def test_install_selects_the_pinned_archive_and_exports_its_path( def test_install_accepts_github_runner_architecture_aliases( tmp_path: Path, architecture: str ) -> None: - """GitHub runner architecture aliases map to their canonical mold archive.""" + """GitHub runner architecture aliases map to their canonical linker archive.""" canonical = "x86_64" if architecture == "amd64" else "aarch64" archive = f"mold-{MOLD_VERSION}-{canonical}-linux.tar.gz" _write_mold_metadata(tmp_path, f"{CHECKSUM} {archive}\n") @@ -153,12 +153,14 @@ def test_install_rejects_an_unsupported_architecture_before_provisioning( result = _run_install(tmp_path, environment) records = Path(environment["DEV_FAST_RECORDS"]) assert result.returncode != 0, "unsupported architecture must fail closed" - assert "unsupported mold architecture: riscv64" in result.stderr + assert "unsupported mold architecture: riscv64" in result.stderr, ( + "the unsupported architecture diagnostic must name the rejected architecture" + ) assert not records.joinpath("rustup").exists(), ( "unsupported hosts must not install nightly" ) assert not records.joinpath("curl").exists(), ( - "unsupported hosts must not download mold" + "unsupported hosts must not download the linker" ) @@ -200,7 +202,9 @@ def test_install_propagates_a_download_failure(tmp_path: Path) -> None: result = _run_install(tmp_path, environment) records = Path(environment["DEV_FAST_RECORDS"]) assert result.returncode == 22, "curl's failure status must fail the action" - assert "download failed" in result.stderr + assert "download failed" in result.stderr, ( + "the action must preserve curl's download diagnostic" + ) assert not records.joinpath("checksum").exists(), ( "failed downloads must not be checked" ) diff --git a/typos.local.toml b/typos.local.toml index 9d7cf9627..6bbf7c92d 100644 --- a/typos.local.toml +++ b/typos.local.toml @@ -42,14 +42,37 @@ ignore = [ '--artifact-server-path(?:[=\s"`]|$)', '`color`', '\brust-analyzer\b', - # mold is the fixed name of the approved external linker and its release - # archive. These patterns retain ordinary en-GB prose checking. + # `mold` is the fixed external linker name. Typos tokenizes words before + # applying this list, so each expression carries enough linker-specific + # syntax to retain ordinary-prose checks for the word on its own. '`mold`', - '\bmissing_mold\b', '"mold"', + 'checksum-verified mold binary', + 'unsupported mold architecture', + 'Only the Linux debug route selects Cranelift and mold', + '\bDEV_FAST_MOLD_VERSION(?:_(?:FILE|PATTERN))?\b', + '\bMOLD_VERSION\b', + '_write_mold_metadata', + '\bmissing_mold\b', + '\bwrong_mold_version\b', + 'command -v mold', + 'mold --version', + '\^mold \$', + 'mold %s is required', + '-fuse-ld=mold', '\bmold 2\.41\.0\b', + '\bmold 2\.41\.00\b', '"mold\\t--version"', '\bmold-\$\{version\}-\$\{architecture\}-linux\.tar\.gz\b', + '\bmold-\{MOLD_VERSION\}-\{canonical\}-linux\.tar\.gz\b', + '\bmold-\d+\.\d+\.\d+-(?:x86_64|aarch64)-linux\.tar\.gz\b', + '\bmold \{MOLD_VERSION\}', + 'mold selection', + 'mold linker flag', + 'mold release', + 'mold ships', + 'upstream mold binaries', + 'downloaded mold', '\btools/mold/', ] diff --git a/typos.toml b/typos.toml index 808957cc6..13ac61e02 100644 --- a/typos.toml +++ b/typos.toml @@ -37,27 +37,49 @@ extend-ignore-re = [ "\"mold\\\\t--version\"", "--artifact-name(?:[=\\s\"`]|$)", "--artifact-server-path(?:[=\\s\"`]|$)", + "-fuse-ld=mold", "/artifacts(?:[/?#\"`]|$)", "Azure\\s+Architecture\\s+Center\\s*\\|\\s*Microsoft\\s+Learn\\b", "GitHub\\s+Flavored\\s+Markdown\\b(?:\\s\\(GFM\\))?", + "Only the Linux debug route selects Cranelift and mold", "Upload main benchmark baseline artifact", "\\(\"ises\", \"izes\"\\)", "\\(\"teh\", \"(?:the|ten)\"\\)", + "\\^mold \\$", + "\\bDEV_FAST_MOLD_VERSION(?:_(?:FILE|PATTERN))?\\b", "\\bFORCE_COLOR\\b", + "\\bMOLD_VERSION\\b", "\\bartifact-name\\b", "\\bartifact_dir\\b", "\\bbuild_native_wheel_artifact\\b", "\\bmissing_mold\\b", + "\\bmold 2\\.41\\.00\\b", "\\bmold 2\\.41\\.0\\b", + "\\bmold \\{MOLD_VERSION\\}", "\\bmold-\\$\\{version\\}-\\$\\{architecture\\}-linux\\.tar\\.gz\\b", + "\\bmold-\\d+\\.\\d+\\.\\d+-(?:x86_64|aarch64)-linux\\.tar\\.gz\\b", + "\\bmold-\\{MOLD_VERSION\\}-\\{canonical\\}-linux\\.tar\\.gz\\b", "\\brust-analyzer\\b", "\\btools/mold/", + "\\bwrong_mold_version\\b", + "_write_mold_metadata", "`color`", "`mold`", "b\"ond\\\\n\"", "baseline-artifact", + "checksum-verified mold binary", + "command -v mold", + "downloaded mold", "mappings\\[\"organise\"\\]", + "mold %s is required", + "mold --version", + "mold linker flag", + "mold release", + "mold selection", + "mold ships", "types: \\[opened, reopened, synchronize, labeled, ready_for_review\\]", + "unsupported mold architecture", + "upstream mold binaries", ] [type.markdown] From 20123640ae9bbf4293209c072abeede23defdaf3 Mon Sep 17 00:00:00 2001 From: leynos Date: Sat, 19 Sep 2026 01:47:21 +0200 Subject: [PATCH 05/12] Require the dev-fast nightly Clippy in the prerequisite gate `rust-lint` runs `cargo doc` and `cargo clippy` under the dev-fast nightly, but Clippy is a separate rustup component from `rustc-codegen-cranelift`. `make dev-fast-check` verified only the backend, so a host provisioned for builds but not linting passed the gate and then failed inside `make rust-lint`. Enumerate the required components in the Makefile and check every one of them, and cover the missing-Clippy case in the prerequisite contract tests. --- Makefile | 6 ++- cuprum/unittests/test_dev_fast_contract.py | 15 ++++++ .../unittests/test_dev_fast_prerequisites.py | 49 ++++++++++++++----- cuprum/unittests/test_workflow_lint.py | 5 +- 4 files changed, 60 insertions(+), 15 deletions(-) diff --git a/Makefile b/Makefile index 2a9da334b..02457cc6b 100644 --- a/Makefile +++ b/Makefile @@ -21,6 +21,10 @@ CARGO ?= cargo MSRV_TOOLCHAIN ?= 1.85.0 DEV_FAST_TOOLCHAIN ?= nightly-2026-08-23 DEV_FAST_CRANELIFT_COMPONENT ?= rustc-codegen-cranelift +# Clippy is a separate rustup component from the backend, but `rust-lint` runs +# it through this same nightly, so the prerequisite gate must require both. +DEV_FAST_LINT_COMPONENT ?= clippy +DEV_FAST_REQUIRED_COMPONENTS ?= $(DEV_FAST_CRANELIFT_COMPONENT) $(DEV_FAST_LINT_COMPONENT) DEV_FAST_CONFIG ?= tools/dev-fast/config.toml DEV_FAST_MOLD_VERSION_FILE ?= tools/mold/VERSION DEV_FAST_MOLD_VERSION := $(strip $(shell tr -d '\r\n' < $(DEV_FAST_MOLD_VERSION_FILE))) @@ -41,7 +45,7 @@ MATURIN_DEVELOP_IS_RELEASE := $(strip $(filter --release -r --profile=release,$( DEVELOP_DEV_FAST_ENABLED := $(if $(DEV_FAST_HOST_IS_LINUX),$(if $(MATURIN_DEVELOP_IS_RELEASE),,yes)) DEVELOP_DEV_FAST_PREREQUISITE = $(if $(DEVELOP_DEV_FAST_ENABLED),dev-fast-check) DEVELOP_DEV_FAST_ENV = $(if $(DEVELOP_DEV_FAST_ENABLED),RUSTUP_TOOLCHAIN=$(DEV_FAST_TOOLCHAIN) DEV_FAST_CARGO=$(CARGO) DEV_FAST_CONFIG=$(DEV_FAST_ABSOLUTE_CONFIG) CARGO=$(DEV_FAST_CARGO_BRIDGE)) -DEV_FAST_CHECK_COMMAND = test "$(DEV_FAST_HOST_IS_LINUX)" = yes || { printf '%s\n' 'dev-fast is supported only on Linux; use the stable backend on this host' >&2; exit 1; }; test -f "$(DEV_FAST_CONFIG)" || { printf 'dev-fast configuration is missing: %s\n' "$(DEV_FAST_CONFIG)" >&2; exit 1; }; command -v mold >/dev/null 2>&1 || { printf 'mold %s is required for Linux dev-fast builds\n' "$(DEV_FAST_MOLD_VERSION)" >&2; exit 1; }; mold --version | grep -q '^mold $(DEV_FAST_MOLD_VERSION_PATTERN)\($$\|[[:space:]]\)' || { printf 'mold %s is required for Linux dev-fast builds\n' "$(DEV_FAST_MOLD_VERSION)" >&2; exit 1; }; rustup component list --installed --toolchain "$(DEV_FAST_TOOLCHAIN)" | grep -q '^$(DEV_FAST_CRANELIFT_COMPONENT)' || { printf 'install %s for %s before using dev-fast\n' "$(DEV_FAST_CRANELIFT_COMPONENT)" "$(DEV_FAST_TOOLCHAIN)" >&2; exit 1; } +DEV_FAST_CHECK_COMMAND = test "$(DEV_FAST_HOST_IS_LINUX)" = yes || { printf '%s\n' 'dev-fast is supported only on Linux; use the stable backend on this host' >&2; exit 1; }; test -f "$(DEV_FAST_CONFIG)" || { printf 'dev-fast configuration is missing: %s\n' "$(DEV_FAST_CONFIG)" >&2; exit 1; }; command -v mold >/dev/null 2>&1 || { printf 'mold %s is required for Linux dev-fast builds\n' "$(DEV_FAST_MOLD_VERSION)" >&2; exit 1; }; mold --version | grep -q '^mold $(DEV_FAST_MOLD_VERSION_PATTERN)\($$\|[[:space:]]\)' || { printf 'mold %s is required for Linux dev-fast builds\n' "$(DEV_FAST_MOLD_VERSION)" >&2; exit 1; }; components="$$(rustup component list --installed --toolchain "$(DEV_FAST_TOOLCHAIN)")" || { printf 'cannot inspect the components installed for %s\n' "$(DEV_FAST_TOOLCHAIN)" >&2; exit 1; }; for component in $(DEV_FAST_REQUIRED_COMPONENTS); do printf '%s\n' "$$components" | grep -q "^$$component" || { printf 'install %s for %s before using dev-fast\n' "$$component" "$(DEV_FAST_TOOLCHAIN)" >&2; exit 1; }; done DEV_FAST_TEST_RUSTFLAGS = $(TEST_RUSTFLAGS) $(if $(DEV_FAST_HOST_IS_LINUX),-Clink-arg=-fuse-ld=mold) DEV_FAST_TEST_COMMAND = if $(LOCAL_TOOL_ENV) command -v cargo-nextest >/dev/null 2>&1; then cd $(RUST_DIR) && CARGO_BUILD_JOBS="$(TEST_CARGO_BUILD_JOBS)" RUSTFLAGS="$(DEV_FAST_TEST_RUSTFLAGS)" $(DEV_FAST_CARGO_COMMAND) nextest run $(TEST_FLAGS) $(BUILD_JOBS); else echo "cargo-nextest not found; falling back to cargo test." >&2; cd $(RUST_DIR) && CARGO_BUILD_JOBS="$(TEST_CARGO_BUILD_JOBS)" RUSTFLAGS="$(DEV_FAST_TEST_RUSTFLAGS)" $(DEV_FAST_CARGO_COMMAND) test $(TEST_FLAGS) $(BUILD_JOBS); fi WHITAKER ?= whitaker diff --git a/cuprum/unittests/test_dev_fast_contract.py b/cuprum/unittests/test_dev_fast_contract.py index 5d2f825d1..9296ee68b 100644 --- a/cuprum/unittests/test_dev_fast_contract.py +++ b/cuprum/unittests/test_dev_fast_contract.py @@ -115,6 +115,21 @@ def test_make_reads_the_linker_version_from_its_pin() -> None: ) +def test_the_prerequisite_gate_requires_both_nightly_components() -> None: + """Clippy runs through the dev-fast nightly, so the gate must require it.""" + makefile = (repo_root() / "Makefile").read_text(encoding="utf-8") + assert "DEV_FAST_LINT_COMPONENT ?= clippy" in makefile, ( + "the dev-fast lint route depends on a separately installed Clippy" + ) + assert ( + "DEV_FAST_REQUIRED_COMPONENTS ?= $(DEV_FAST_CRANELIFT_COMPONENT) " + "$(DEV_FAST_LINT_COMPONENT)" in makefile + ), "the prerequisite gate must enumerate every component it requires" + assert "$(DEV_FAST_REQUIRED_COMPONENTS)" in makefile, ( + "the prerequisite gate must check every required component" + ) + + def test_fragment_is_the_approved_immutable_extension() -> None: """Keep the checked-in Cargo fragment byte-identical to its approved pin.""" fragment = (repo_root() / FRAGMENT).read_bytes() diff --git a/cuprum/unittests/test_dev_fast_prerequisites.py b/cuprum/unittests/test_dev_fast_prerequisites.py index f3344ea60..e96f20557 100644 --- a/cuprum/unittests/test_dev_fast_prerequisites.py +++ b/cuprum/unittests/test_dev_fast_prerequisites.py @@ -15,7 +15,16 @@ from pathlib import Path -MOLD_VERSION = (repo_root() / "tools/mold/VERSION").read_text(encoding="utf-8").strip() +@pytest.fixture +def mold_version() -> str: + """Return the linker version recorded by the checked-in pin.""" + return (repo_root() / "tools/mold/VERSION").read_text(encoding="utf-8").strip() + + +def _installed_components(*components: str) -> str: + """Emit the component listing a provisioned toolchain reports.""" + listed = " ".join(f"'{component}'" for component in components) + return f"printf '%s\\n' {listed}" def _write_program(directory: Path, name: str, body: str) -> None: @@ -28,25 +37,34 @@ def _write_program(directory: Path, name: str, body: str) -> None: @pytest.mark.parametrize( ("programs", "variables", "expected_diagnostic"), [ - pytest.param({}, {}, f"mold {MOLD_VERSION} is required", id="missing_mold"), + pytest.param({}, {}, "mold {version} is required", id="missing_mold"), pytest.param( { "mold": "printf '%s\\n' 'mold 2.41.00'", "rustup": "exit 0", }, {}, - f"mold {MOLD_VERSION} is required", + "mold {version} is required", id="wrong_mold_version", ), pytest.param( { - "mold": f"printf '%s\\n' 'mold {MOLD_VERSION}'", + "mold": "printf '%s\\n' 'mold {version}'", "rustup": "exit 0", }, {}, "install rustc-codegen-cranelift", id="missing_component", ), + pytest.param( + { + "mold": "printf '%s\\n' 'mold {version}'", + "rustup": _installed_components("rustc-codegen-cranelift"), + }, + {}, + "install clippy", + id="missing_clippy", + ), pytest.param( {}, {"DEV_FAST_CONFIG": "missing-dev-fast.toml"}, @@ -60,10 +78,11 @@ def test_prerequisite_recipe_fails_closed_for_missing_dependencies( programs: dict[str, str], variables: dict[str, str], expected_diagnostic: str, + mold_version: str, ) -> None: """Missing or mismatched prerequisites are hard failures with useful output.""" for name, body in programs.items(): - _write_program(tmp_path, name, body) + _write_program(tmp_path, name, body.format(version=mold_version)) make = shutil.which("make") assert make is not None, "the prerequisite contract requires GNU Make on PATH" result = subprocess.run( # ruff: ignore[subprocess-without-shell-equals-true] - fixed Make argv. @@ -80,18 +99,20 @@ def test_prerequisite_recipe_fails_closed_for_missing_dependencies( text=True, ) assert result.returncode != 0, "missing prerequisites must fail closed" - assert expected_diagnostic in result.stderr, ( + assert expected_diagnostic.format(version=mold_version) in result.stderr, ( f"the prerequisite failure must explain how to repair it: {result.stderr}" ) -def test_prerequisite_recipe_accepts_the_pinned_dependencies(tmp_path: Path) -> None: - """The closed prerequisite gate accepts exactly the pinned linker and component.""" - _write_program(tmp_path, "mold", f"printf '%s\\n' 'mold {MOLD_VERSION}'") +def test_prerequisite_recipe_accepts_the_pinned_dependencies( + tmp_path: Path, mold_version: str +) -> None: + """The closed prerequisite gate accepts exactly the pinned linker and components.""" + _write_program(tmp_path, "mold", f"printf '%s\\n' 'mold {mold_version}'") _write_program( tmp_path, "rustup", - "printf '%s\\n' 'rustc-codegen-cranelift (installed)'", + _installed_components("rustc-codegen-cranelift", "clippy"), ) make = shutil.which("make") assert make is not None, "the prerequisite contract requires GNU Make on PATH" @@ -106,13 +127,15 @@ def test_prerequisite_recipe_accepts_the_pinned_dependencies(tmp_path: Path) -> assert result.returncode == 0, result.stderr -def test_dev_build_executes_the_approved_fragment(tmp_path: Path) -> None: +def test_dev_build_executes_the_approved_fragment( + tmp_path: Path, mold_version: str +) -> None: """A Linux debug build invokes Cargo once with the explicit approved fragment.""" - _write_program(tmp_path, "mold", f"printf '%s\n' 'mold {MOLD_VERSION}'") + _write_program(tmp_path, "mold", f"printf '%s\\n' 'mold {mold_version}'") _write_program( tmp_path, "rustup", - "printf '%s\n' 'rustc-codegen-cranelift (installed)'", + _installed_components("rustc-codegen-cranelift", "clippy"), ) invocation = tmp_path / "cargo-invocation" cargo = tmp_path / "cargo" diff --git a/cuprum/unittests/test_workflow_lint.py b/cuprum/unittests/test_workflow_lint.py index 7490156ea..a1fb51231 100644 --- a/cuprum/unittests/test_workflow_lint.py +++ b/cuprum/unittests/test_workflow_lint.py @@ -271,7 +271,10 @@ def test_the_lint_target_runs_the_workflow_linters(tmp_path: pth.Path) -> None: tools=("uv", "yamllint", "actionlint", "mold", "rustup"), outputs={ "mold": "mold 2.41.0\n", - "rustup": "rustc-codegen-cranelift-x86_64-unknown-linux-gnu (installed)\n", + "rustup": ( + "rustc-codegen-cranelift-x86_64-unknown-linux-gnu (installed)\n" + "clippy-x86_64-unknown-linux-gnu (installed)\n" + ), }, ) overrides = tmp_path / "lint-target-overrides.mk" From d376683ee206a31432e358ee72436d62591503ba Mon Sep 17 00:00:00 2001 From: leynos Date: Sat, 19 Sep 2026 01:47:53 +0200 Subject: [PATCH 06/12] Drop vacuous dev-fast test assertions and dead test state Three pieces of review feedback on the dev-fast contract tests, all verified before acting: * `test_fragment_digest_rejects_content_mutation` could only fail on a SHA-256 second preimage, so it asserted nothing about the fragment contract. The surviving digest test hashes the on-disk fragment against the reviewed pin, which is the real check. * The module-level `MOLD_VERSION` read in the contract tests was unused; the prerequisite tests already read the same pin through a fixture. * A duplicated `if typ.TYPE_CHECKING:` block re-imported `collections.abc` a second time. Also add the `msrv-check` target to `.PHONY`: it was the only one of the Makefile's targets missing from the list, so a stray `msrv-check` file in the working tree could suppress the recipe. --- Makefile | 2 +- cuprum/unittests/test_dev_fast_contract.py | 14 -------------- 2 files changed, 1 insertion(+), 15 deletions(-) diff --git a/Makefile b/Makefile index 02457cc6b..7f39b9f49 100644 --- a/Makefile +++ b/Makefile @@ -192,7 +192,7 @@ MDLINT_CHECK_COMMAND = unset FORCE_COLOR; $(LOCAL_TOOL_ENV) xargs -0 -r $(MDLINT lint-windows fmt check-fmt \ markdownlint spelling nixie test test-python test-rust typecheck \ test-extension test-markdown-format develop makeutil skylos-allow \ - test-dev-fast-contract dev-fast-check dev-build dev-test \ + test-dev-fast-contract dev-fast-check dev-build dev-test msrv-check \ benchmark-micro benchmark-e2e \ $(TOOLS) $(VENV_TOOLS) .NOTPARALLEL: lint diff --git a/cuprum/unittests/test_dev_fast_contract.py b/cuprum/unittests/test_dev_fast_contract.py index 9296ee68b..aa8239912 100644 --- a/cuprum/unittests/test_dev_fast_contract.py +++ b/cuprum/unittests/test_dev_fast_contract.py @@ -19,14 +19,9 @@ from collections import abc as cabc -if typ.TYPE_CHECKING: - from collections import abc as cabc - - FRAGMENT_SHA256 = "8619efda5ea1c3232f413ae96ff56869ab6b2b7cd5bdef5a001ad16ebeac23a5" FRAGMENT = "tools/dev-fast/config.toml" RUST_MEMBERS = ("cuprum-rust", "cuprum-streams", "cuprum-native-io") -MOLD_VERSION = (repo_root() / "tools/mold/VERSION").read_text(encoding="utf-8").strip() SAFE_MATURIN_FLAGS = st.sampled_from(( "--release", "-r", @@ -95,15 +90,6 @@ def _is_release_maturin_flags(flags: list[str]) -> bool: ) -def test_fragment_digest_rejects_content_mutation() -> None: - """A changed fragment cannot satisfy the reviewed immutable digest.""" - fragment = (repo_root() / FRAGMENT).read_bytes() - mutated_digest = hashlib.sha256(fragment + b"\n# mutation\n").hexdigest() - assert mutated_digest != FRAGMENT_SHA256, ( - "the digest contract must reject a changed dev-fast fragment" - ) - - def test_make_reads_the_linker_version_from_its_pin() -> None: """Keep prerequisite diagnostics coupled to the checked-in linker pin.""" makefile = (repo_root() / "Makefile").read_text(encoding="utf-8") From 03479583c7a044342d28c63e598fda8e4edd5ea8 Mon Sep 17 00:00:00 2001 From: leynos Date: Sat, 19 Sep 2026 01:47:59 +0200 Subject: [PATCH 07/12] Document the dev-fast nightly Clippy prerequisite `make dev-fast-check` now requires the `clippy` component alongside `rustc-codegen-cranelift`, and the developer guide described direct Cargo invocations as retaining a repository-selected toolchain, which the fragment cannot do: it is not auto-discovered, so passing `--config` does not select a toolchain. Correct both statements in the developer guide and the AGENTS.md toolchain bullet. --- AGENTS.md | 3 ++- docs/developers-guide.md | 20 +++++++++++--------- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6e7e39228..f9fb3ac23 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -228,7 +228,8 @@ working on the Rust portions of the project: the maintenance formatter, then restores the project toolchain. The current `fmt` and `check-fmt` recipes still use the project toolchain. Linux-only debug Rust work additionally uses the separately pinned dev-fast nightly - through `tools/dev-fast/config.toml`; run `make dev-fast-check` first. The + through `tools/dev-fast/config.toml`; run `make dev-fast-check` first, since + it requires both the `rustc-codegen-cranelift` and `clippy` components. The stable toolchain remains mandatory for release, coverage, Whitaker, the explicit `make msrv-check` verification target, and the macOS and Windows platform alternatives. All Rust packages inherit Cargo's diff --git a/docs/developers-guide.md b/docs/developers-guide.md index 77e9a19f3..e6bf718db 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -403,20 +403,22 @@ its compatibility rationale. Cuprum's Rust `1.85.0` pin remains the compatibility, release, coverage, verification, and Whitaker toolchain. On Linux only, ordinary debug Rust work uses the separately pinned `nightly-2026-08-23` and the -`rustc-codegen-cranelift` component through `tools/dev-fast/config.toml`. The -fragment is an approved byte-for-byte local extension from +`rustc-codegen-cranelift` component through `tools/dev-fast/config.toml`; the +Clippy portion of `make rust-lint` additionally requires the `clippy` component +on that nightly. The fragment is an approved byte-for-byte local extension from `leynos/netsuke@924cb215d3841048dbf6649767a510d2a5dfb1b7`, recorded by Concordat issue 157; its SHA-256 is `8619efda5ea1c3232f413ae96ff56869ab6b2b7cd5bdef5a001ad16ebeac23a5`. Run `make dev-fast-check` before the first Linux accelerated build. It requires -the nightly component and the pinned `mold` 2.41.0 linker. The approved binary -route is the checksum-verified upstream release archive; it never permits a -source-build fallback. `make develop`, `make test-rust`, and the Cargo doc and -Clippy portion of `make rust-lint` select the fragment on a compatible Linux -host. `make dev-build` and `make dev-test` expose the same route explicitly. -Direct Cargo invocations retain the repository-selected Rust toolchain unless -the caller explicitly supplies the fragment. +both the `rustc-codegen-cranelift` and `clippy` components on the dev-fast +nightly, plus the pinned `mold` 2.41.0 linker. The approved binary route is the +checksum-verified upstream release archive; it never permits a source-build +fallback. `make develop`, `make test-rust`, and the Cargo doc and Clippy +portion of `make rust-lint` select the fragment on a compatible Linux host. +`make dev-build` and `make dev-test` expose the same route explicitly. Direct +Cargo invocations leave toolchain selection to the caller; supplying the +fragment does not select a toolchain. Every workspace package inherits `rust-version = "1.85.0"` from the root manifest. Cargo treats that as the publication and compatibility contract, and From 9ff60a95dd40f968716a33ab49329d778c8f9973 Mon Sep 17 00:00:00 2001 From: leynos Date: Sat, 19 Sep 2026 02:37:58 +0200 Subject: [PATCH 08/12] Fix the nightly Clippy needless_continue in fd_is_open Routing rust-lint through the dev-fast nightly promotes Clippy's pedantic group to an error under the existing -D warnings. The newer Clippy reports the redundant `continue` in cuprum-native-io's fd_is_open retry arm, which the 1.85 toolchain accepted; CI runs make lint after installing the dev-fast prerequisites, so the branch's own route is what surfaces it. Drop the `continue`: the match is the last expression of the loop body, so the EINTR arm falls through to the next observation exactly as before. The empty arm matches the existing Interrupted idiom in cuprum-streams. Co-Authored-By: Claude Code --- rust/cuprum-native-io/src/lib.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/rust/cuprum-native-io/src/lib.rs b/rust/cuprum-native-io/src/lib.rs index 7d15cd03a..befa1cf0b 100644 --- a/rust/cuprum-native-io/src/lib.rs +++ b/rust/cuprum-native-io/src/lib.rs @@ -195,8 +195,9 @@ pub(crate) fn fd_is_open(fd: i32) -> io::Result { } let error = io::Error::last_os_error(); match error.raw_os_error() { - // A delivered signal interrupted the call; observe again. - Some(libc::EINTR) => continue, + // A delivered signal interrupted the call. The match ends the loop + // body, so falling through here is the retry the `continue` named. + Some(libc::EINTR) => {} Some(libc::EBADF) => return Ok(false), _ => return Err(error), } From 327e86c13f0186f44d0b7330e7c4af220e499bb3 Mon Sep 17 00:00:00 2001 From: leynos Date: Sat, 19 Sep 2026 03:25:25 +0200 Subject: [PATCH 09/12] Cover the prerequisite tests' new linker fixture forms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dev-fast prerequisite gate now names its version fixture `mold_version` and templates diagnostics as `mold {version}` / `mold {mold_version}`. Those forms entered after the allow-list was written, and the typos gate flags the bare word each time, so `make lint` failed at its spelling step. Add the three narrow, content-context patterns the doctrine here asks for — each carries identifier or template syntax — and regenerate the tracked config through the pinned builder. Ordinary prose still fails: a scratch file containing "mold growing on the bread" is flagged, so the word is not accepted as a dictionary entry. Co-Authored-By: Claude Code --- typos.local.toml | 6 ++++++ typos.toml | 3 +++ 2 files changed, 9 insertions(+) diff --git a/typos.local.toml b/typos.local.toml index 6bbf7c92d..6c5d7a6e5 100644 --- a/typos.local.toml +++ b/typos.local.toml @@ -55,6 +55,12 @@ ignore = [ '_write_mold_metadata', '\bmissing_mold\b', '\bwrong_mold_version\b', + # The prerequisite tests name their fixture and template placeholders after + # the linker; each form carries identifier or template syntax, so prose uses + # of the word on its own remain flagged. + '\bmold_version\b', + '\bmold \{version\}', + '\bmold \{mold_version\}', 'command -v mold', 'mold --version', '\^mold \$', diff --git a/typos.toml b/typos.toml index 13ac61e02..a35018209 100644 --- a/typos.toml +++ b/typos.toml @@ -56,9 +56,12 @@ extend-ignore-re = [ "\\bmold 2\\.41\\.00\\b", "\\bmold 2\\.41\\.0\\b", "\\bmold \\{MOLD_VERSION\\}", + "\\bmold \\{mold_version\\}", + "\\bmold \\{version\\}", "\\bmold-\\$\\{version\\}-\\$\\{architecture\\}-linux\\.tar\\.gz\\b", "\\bmold-\\d+\\.\\d+\\.\\d+-(?:x86_64|aarch64)-linux\\.tar\\.gz\\b", "\\bmold-\\{MOLD_VERSION\\}-\\{canonical\\}-linux\\.tar\\.gz\\b", + "\\bmold_version\\b", "\\brust-analyzer\\b", "\\btools/mold/", "\\bwrong_mold_version\\b", From 8223a660979cf533ec755975089cdc56c3198910 Mon Sep 17 00:00:00 2001 From: leynos Date: Sat, 19 Sep 2026 04:15:22 +0200 Subject: [PATCH 10/12] Group the prerequisite failure scenarios into one case object MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeScene scores `test_dev_fast_prerequisites.py` as new code — `main` does not carry the file — and its `Excess Number of Function Arguments` biomarker fires on the parametrized failure test. The last review round added the `mold_version` fixture as a fifth parameter, taking the method one past the threshold the check tolerated at four. Fold the three cohesive scenario fields into a frozen `_FailureCase` `NamedTuple`, the same shape the generated-invariant tests already use for grouped case data. The test keeps five ids and five scenario bodies unchanged; only the grouping moves. The parameter list drops to three, so the biomarker no longer matches while the parametrize table still reads as the specification it is. The fixture parameter stays: it is function-scoped setup the case data cannot supply without re-reading the pin inline. Co-Authored-By: Claude Code --- .../unittests/test_dev_fast_prerequisites.py | 77 +++++++++++-------- 1 file changed, 46 insertions(+), 31 deletions(-) diff --git a/cuprum/unittests/test_dev_fast_prerequisites.py b/cuprum/unittests/test_dev_fast_prerequisites.py index e96f20557..7cfaafccc 100644 --- a/cuprum/unittests/test_dev_fast_prerequisites.py +++ b/cuprum/unittests/test_dev_fast_prerequisites.py @@ -34,54 +34,69 @@ def _write_program(directory: Path, name: str, body: str) -> None: program.chmod(0o755) +class _FailureCase(typ.NamedTuple): + """One prerequisite mutation and the diagnostic its failure must report.""" + + programs: dict[str, str] + variables: dict[str, str] + expected_diagnostic: str + + @pytest.mark.parametrize( - ("programs", "variables", "expected_diagnostic"), + "case", [ - pytest.param({}, {}, "mold {version} is required", id="missing_mold"), pytest.param( - { - "mold": "printf '%s\\n' 'mold 2.41.00'", - "rustup": "exit 0", - }, - {}, - "mold {version} is required", + _FailureCase({}, {}, "mold {version} is required"), + id="missing_mold", + ), + pytest.param( + _FailureCase( + { + "mold": "printf '%s\\n' 'mold 2.41.00'", + "rustup": "exit 0", + }, + {}, + "mold {version} is required", + ), id="wrong_mold_version", ), pytest.param( - { - "mold": "printf '%s\\n' 'mold {version}'", - "rustup": "exit 0", - }, - {}, - "install rustc-codegen-cranelift", + _FailureCase( + { + "mold": "printf '%s\\n' 'mold {version}'", + "rustup": "exit 0", + }, + {}, + "install rustc-codegen-cranelift", + ), id="missing_component", ), pytest.param( - { - "mold": "printf '%s\\n' 'mold {version}'", - "rustup": _installed_components("rustc-codegen-cranelift"), - }, - {}, - "install clippy", + _FailureCase( + { + "mold": "printf '%s\\n' 'mold {version}'", + "rustup": _installed_components("rustc-codegen-cranelift"), + }, + {}, + "install clippy", + ), id="missing_clippy", ), pytest.param( - {}, - {"DEV_FAST_CONFIG": "missing-dev-fast.toml"}, - "dev-fast configuration is missing", + _FailureCase( + {}, + {"DEV_FAST_CONFIG": "missing-dev-fast.toml"}, + "dev-fast configuration is missing", + ), id="missing_fragment", ), ], ) def test_prerequisite_recipe_fails_closed_for_missing_dependencies( - tmp_path: Path, - programs: dict[str, str], - variables: dict[str, str], - expected_diagnostic: str, - mold_version: str, + tmp_path: Path, case: _FailureCase, mold_version: str ) -> None: """Missing or mismatched prerequisites are hard failures with useful output.""" - for name, body in programs.items(): + for name, body in case.programs.items(): _write_program(tmp_path, name, body.format(version=mold_version)) make = shutil.which("make") assert make is not None, "the prerequisite contract requires GNU Make on PATH" @@ -90,7 +105,7 @@ def test_prerequisite_recipe_fails_closed_for_missing_dependencies( make, "dev-fast-check", "DEV_FAST_HOST_IS_LINUX=yes", - *(f"{key}={value}" for key, value in variables.items()), + *(f"{key}={value}" for key, value in case.variables.items()), ], check=False, capture_output=True, @@ -99,7 +114,7 @@ def test_prerequisite_recipe_fails_closed_for_missing_dependencies( text=True, ) assert result.returncode != 0, "missing prerequisites must fail closed" - assert expected_diagnostic.format(version=mold_version) in result.stderr, ( + assert case.expected_diagnostic.format(version=mold_version) in result.stderr, ( f"the prerequisite failure must explain how to repair it: {result.stderr}" ) From 9613e814b6fd74312311d65a845084b83f3ae563 Mon Sep 17 00:00:00 2001 From: leynos Date: Sat, 19 Sep 2026 13:43:42 +0200 Subject: [PATCH 11/12] Fix the dev-fast Cargo fragment as repository state The routed dev-fast configuration was published as a caller-settable make variable. `DEV_FAST_CONFIG ?= tools/dev-fast/config.toml` only supplies a default, so an ordinary environment variable or a command-line assignment replaced it, and `dev-fast-check` validated only that the replacement path existed. A caller could therefore route the accelerated debug builds through an unreviewed Cargo configuration while every routing test still passed, because those tests exercise the default invocation. The Maturin adapter had the same property: it honoured a caller-supplied path and described it as "approved" without establishing any such thing. This is a correctness and reproducibility defect rather than a security boundary: a caller who controls the local `make` invocation can also change the checkout. The project nonetheless claimed one fixed configuration for the supported route while permitting another. Make the fragment repository-fixed state: - The Makefile derives it from one constant assigned with GNU Make's `override` directive. Both the constant and the value derived from it are overridden, because a plain or `?=` definition still yields to an environment variable and any non-override definition yields to a command-line variable. - `dev-fast-check` validates that fixed constant. - `DEV_FAST_CARGO_COMMAND` and the direct Cargo routes consume the derived value, so no caller-settable name reaches Cargo. - The adapter derives the fragment from its own location instead of the environment, and `DEVELOP_DEV_FAST_ENV` no longer passes it one. Its missing-fragment diagnostic no longer asserts approval it cannot check. The tests that asserted the adapter honours the variable are replaced by tests asserting the opposite, including both delivery mechanisms for the Make-level override and a relative-path invocation matching how Make actually calls the adapter. A vacuous `missing_fragment` scenario is dropped: it could only fail by redirecting a path that is no longer redirectable. ADRs 012 and the developer guide record the boundary, and the guide no longer implies the fragment is configurable. Co-Authored-By: Claude Code --- Makefile | 18 +++- cuprum/unittests/test_dev_fast_adapter.py | 94 +++++++++++++------ cuprum/unittests/test_dev_fast_contract.py | 33 +++++++ .../unittests/test_dev_fast_prerequisites.py | 60 ++++++++++-- docs/adr-012-linux-dev-fast-routing.md | 20 +++- docs/developers-guide.md | 10 ++ tools/dev-fast/cargo | 13 ++- 7 files changed, 199 insertions(+), 49 deletions(-) diff --git a/Makefile b/Makefile index 7f39b9f49..81bae9fc0 100644 --- a/Makefile +++ b/Makefile @@ -25,13 +25,21 @@ DEV_FAST_CRANELIFT_COMPONENT ?= rustc-codegen-cranelift # it through this same nightly, so the prerequisite gate must require both. DEV_FAST_LINT_COMPONENT ?= clippy DEV_FAST_REQUIRED_COMPONENTS ?= $(DEV_FAST_CRANELIFT_COMPONENT) $(DEV_FAST_LINT_COMPONENT) -DEV_FAST_CONFIG ?= tools/dev-fast/config.toml +# The selected Cargo fragment is repository-fixed state, not a caller input. +# `override` is load-bearing, not decoration: a plain or `?=` definition still +# yields to an ordinary environment variable, and any non-override definition +# yields to a command-line variable. With either, `make dev-build +# DEV_FAST_RUST_CONFIG=/tmp/other.toml` would run the accelerated route under a +# configuration the project never approved while `dev-fast-check` still passed. +# Both names need it: the second because a caller could otherwise replace the +# derived value directly. Anything needing a different Cargo configuration is +# outside this route and does not use it. +override DEV_FAST_CONFIG_RELATIVE := tools/dev-fast/config.toml +override DEV_FAST_RUST_CONFIG := ../$(DEV_FAST_CONFIG_RELATIVE) DEV_FAST_MOLD_VERSION_FILE ?= tools/mold/VERSION DEV_FAST_MOLD_VERSION := $(strip $(shell tr -d '\r\n' < $(DEV_FAST_MOLD_VERSION_FILE))) DEV_FAST_MOLD_VERSION_PATTERN = $(subst .,\.,$(DEV_FAST_MOLD_VERSION)) -DEV_FAST_RUST_CONFIG ?= ../$(DEV_FAST_CONFIG) DEV_FAST_CARGO_BRIDGE ?= tools/dev-fast/cargo -DEV_FAST_ABSOLUTE_CONFIG := $(abspath $(DEV_FAST_CONFIG)) DEV_FAST_HOST_IS_LINUX := $(if $(filter Linux,$(shell uname -s)),yes) # `CARGO` remains a caller-injectable single executable. Maturin can select # that executable but cannot pass Cargo's configuration-file `--config` form. @@ -44,8 +52,8 @@ maturin_release_profile = $(strip $(if $(1),$(if $(and $(filter --profile,$(firs MATURIN_DEVELOP_IS_RELEASE := $(strip $(filter --release -r --profile=release,$(MATURIN_DEVELOP_FLAGS)) $(call maturin_release_profile,$(MATURIN_DEVELOP_FLAGS))) DEVELOP_DEV_FAST_ENABLED := $(if $(DEV_FAST_HOST_IS_LINUX),$(if $(MATURIN_DEVELOP_IS_RELEASE),,yes)) DEVELOP_DEV_FAST_PREREQUISITE = $(if $(DEVELOP_DEV_FAST_ENABLED),dev-fast-check) -DEVELOP_DEV_FAST_ENV = $(if $(DEVELOP_DEV_FAST_ENABLED),RUSTUP_TOOLCHAIN=$(DEV_FAST_TOOLCHAIN) DEV_FAST_CARGO=$(CARGO) DEV_FAST_CONFIG=$(DEV_FAST_ABSOLUTE_CONFIG) CARGO=$(DEV_FAST_CARGO_BRIDGE)) -DEV_FAST_CHECK_COMMAND = test "$(DEV_FAST_HOST_IS_LINUX)" = yes || { printf '%s\n' 'dev-fast is supported only on Linux; use the stable backend on this host' >&2; exit 1; }; test -f "$(DEV_FAST_CONFIG)" || { printf 'dev-fast configuration is missing: %s\n' "$(DEV_FAST_CONFIG)" >&2; exit 1; }; command -v mold >/dev/null 2>&1 || { printf 'mold %s is required for Linux dev-fast builds\n' "$(DEV_FAST_MOLD_VERSION)" >&2; exit 1; }; mold --version | grep -q '^mold $(DEV_FAST_MOLD_VERSION_PATTERN)\($$\|[[:space:]]\)' || { printf 'mold %s is required for Linux dev-fast builds\n' "$(DEV_FAST_MOLD_VERSION)" >&2; exit 1; }; components="$$(rustup component list --installed --toolchain "$(DEV_FAST_TOOLCHAIN)")" || { printf 'cannot inspect the components installed for %s\n' "$(DEV_FAST_TOOLCHAIN)" >&2; exit 1; }; for component in $(DEV_FAST_REQUIRED_COMPONENTS); do printf '%s\n' "$$components" | grep -q "^$$component" || { printf 'install %s for %s before using dev-fast\n' "$$component" "$(DEV_FAST_TOOLCHAIN)" >&2; exit 1; }; done +DEVELOP_DEV_FAST_ENV = $(if $(DEVELOP_DEV_FAST_ENABLED),RUSTUP_TOOLCHAIN=$(DEV_FAST_TOOLCHAIN) DEV_FAST_CARGO=$(CARGO) CARGO=$(DEV_FAST_CARGO_BRIDGE)) +DEV_FAST_CHECK_COMMAND = test "$(DEV_FAST_HOST_IS_LINUX)" = yes || { printf '%s\n' 'dev-fast is supported only on Linux; use the stable backend on this host' >&2; exit 1; }; test -f "$(DEV_FAST_CONFIG_RELATIVE)" || { printf 'dev-fast configuration is missing: %s\n' "$(DEV_FAST_CONFIG_RELATIVE)" >&2; exit 1; }; command -v mold >/dev/null 2>&1 || { printf 'mold %s is required for Linux dev-fast builds\n' "$(DEV_FAST_MOLD_VERSION)" >&2; exit 1; }; mold --version | grep -q '^mold $(DEV_FAST_MOLD_VERSION_PATTERN)\($$\|[[:space:]]\)' || { printf 'mold %s is required for Linux dev-fast builds\n' "$(DEV_FAST_MOLD_VERSION)" >&2; exit 1; }; components="$$(rustup component list --installed --toolchain "$(DEV_FAST_TOOLCHAIN)")" || { printf 'cannot inspect the components installed for %s\n' "$(DEV_FAST_TOOLCHAIN)" >&2; exit 1; }; for component in $(DEV_FAST_REQUIRED_COMPONENTS); do printf '%s\n' "$$components" | grep -q "^$$component" || { printf 'install %s for %s before using dev-fast\n' "$$component" "$(DEV_FAST_TOOLCHAIN)" >&2; exit 1; }; done DEV_FAST_TEST_RUSTFLAGS = $(TEST_RUSTFLAGS) $(if $(DEV_FAST_HOST_IS_LINUX),-Clink-arg=-fuse-ld=mold) DEV_FAST_TEST_COMMAND = if $(LOCAL_TOOL_ENV) command -v cargo-nextest >/dev/null 2>&1; then cd $(RUST_DIR) && CARGO_BUILD_JOBS="$(TEST_CARGO_BUILD_JOBS)" RUSTFLAGS="$(DEV_FAST_TEST_RUSTFLAGS)" $(DEV_FAST_CARGO_COMMAND) nextest run $(TEST_FLAGS) $(BUILD_JOBS); else echo "cargo-nextest not found; falling back to cargo test." >&2; cd $(RUST_DIR) && CARGO_BUILD_JOBS="$(TEST_CARGO_BUILD_JOBS)" RUSTFLAGS="$(DEV_FAST_TEST_RUSTFLAGS)" $(DEV_FAST_CARGO_COMMAND) test $(TEST_FLAGS) $(BUILD_JOBS); fi WHITAKER ?= whitaker diff --git a/cuprum/unittests/test_dev_fast_adapter.py b/cuprum/unittests/test_dev_fast_adapter.py index d37d46db6..f44fd80f7 100644 --- a/cuprum/unittests/test_dev_fast_adapter.py +++ b/cuprum/unittests/test_dev_fast_adapter.py @@ -23,6 +23,11 @@ )) +def _adapter_fragment() -> str: + """Return the fragment path the adapter resolves from its own location.""" + return str((repo_root() / FRAGMENT).resolve()) + + def _bridge_environment(tmp_path: Path) -> tuple[dict[str, str], Path, Path]: """Create a recording Cargo child for the adapter's process contract.""" argv_path = tmp_path / "argv" @@ -36,12 +41,13 @@ def _bridge_environment(tmp_path: Path) -> tuple[dict[str, str], Path, Path]: encoding="utf-8", ) child.chmod(0o755) + # DEV_FAST_CONFIG is deliberately absent: the adapter derives the fragment + # from its own location, so the variable is not part of its interface. return ( { "DEV_FAST_ARGV": str(argv_path), "DEV_FAST_CARGO": str(child), "DEV_FAST_CARGO_ENV": str(cargo_path), - "DEV_FAST_CONFIG": str(repo_root() / FRAGMENT), }, argv_path, cargo_path, @@ -62,6 +68,35 @@ def _run_bridge( ) +def _run_bridge_relatively( + arguments: list[str], environment: dict[str, str] +) -> subprocess.CompletedProcess[str]: + """Run the adapter exactly as the Makefile does: by relative path.""" + return subprocess.run( # ruff: ignore[subprocess-without-shell-equals-true] - fixed adapter argv. + [f"./{FRAGMENT.rsplit('/', 1)[0]}/cargo", *arguments], + check=False, + capture_output=True, + cwd=repo_root(), + env={**os.environ, **environment}, + text=True, + ) + + +def test_bridge_resolves_the_fragment_from_a_relative_invocation( + tmp_path: Path, +) -> None: + """A relative adapter path resolves to the repository fragment, not cwd.""" + environment, argv_path, _ = _bridge_environment(tmp_path) + result = _run_bridge_relatively(["rustc", "--lib"], environment) + assert result.returncode == 47, "the relative invocation must reach Cargo" + assert argv_path.read_text(encoding="utf-8").splitlines() == [ + "--config", + _adapter_fragment(), + "rustc", + "--lib", + ], "the adapter must resolve its fragment independently of the invocation form" + + def test_bridge_injects_one_fragment_and_preserves_child_exit(tmp_path: Path) -> None: """The Maturin adapter preserves Cargo's argv, environment, and status.""" environment, argv_path, cargo_path = _bridge_environment(tmp_path) @@ -69,7 +104,7 @@ def test_bridge_injects_one_fragment_and_preserves_child_exit(tmp_path: Path) -> assert result.returncode == 47, "the adapter must preserve the Cargo exit status" assert argv_path.read_text(encoding="utf-8").splitlines() == [ "--config", - environment["DEV_FAST_CONFIG"], + _adapter_fragment(), "rustc", "--lib", ], "the adapter must prepend only its approved fragment" @@ -88,7 +123,7 @@ def test_bridge_preserves_generated_cargo_arguments(arguments: list[str]) -> Non assert result.returncode == 47, f"the child status changed for {arguments!r}" assert argv_path.read_text(encoding="utf-8").splitlines() == [ "--config", - environment["DEV_FAST_CONFIG"], + _adapter_fragment(), *arguments, ], f"the adapter changed generated Cargo arguments {arguments!r}" @@ -135,36 +170,37 @@ def test_bridge_rejects_generated_configuration_flags( assert not argv_path.exists(), "a rejected invocation must not start Cargo" -@pytest.mark.parametrize( - ("missing", "expected_diagnostic"), - [ - pytest.param("DEV_FAST_CARGO", "DEV_FAST_CARGO must name", id="missing_cargo"), - pytest.param( - "DEV_FAST_CONFIG", "DEV_FAST_CONFIG must name", id="missing_config" - ), - ], -) -def test_bridge_rejects_missing_required_environment( - tmp_path: Path, missing: str, expected_diagnostic: str -) -> None: +def test_bridge_rejects_missing_required_environment(tmp_path: Path) -> None: """The adapter refuses incomplete configuration before executing Cargo.""" environment, argv_path, _ = _bridge_environment(tmp_path) - del environment[missing] + del environment["DEV_FAST_CARGO"] result = _run_bridge(["rustc"], environment) - assert result.returncode == 2, f"missing {missing} must fail with usage status" - assert expected_diagnostic in result.stderr, ( - f"missing {missing} must name the required environment variable" + assert result.returncode == 2, "missing DEV_FAST_CARGO must fail with usage status" + assert "DEV_FAST_CARGO must name" in result.stderr, ( + "missing DEV_FAST_CARGO must name the required environment variable" ) assert not argv_path.exists(), "incomplete adapter state must not start Cargo" -def test_bridge_rejects_a_nonexistent_configuration(tmp_path: Path) -> None: - """The adapter checks its selected configuration path before executing Cargo.""" - environment, argv_path, _ = _bridge_environment(tmp_path) - environment["DEV_FAST_CONFIG"] = str(tmp_path / "missing.toml") - result = _run_bridge(["rustc"], environment) - assert result.returncode == 2, "a missing fragment must fail with usage status" - assert "DEV_FAST_CONFIG must name" in result.stderr, ( - "the missing fragment diagnostic must name the broken boundary" - ) - assert not argv_path.exists(), "an invalid fragment must not start Cargo" +def test_bridge_ignores_a_caller_supplied_configuration(tmp_path: Path) -> None: + """The adapter's fragment follows from its location, not the environment. + + Both candidates are offered, because they fail differently: an existing + file outside the repository defeats a bare `-f` existence guard, and a + path inside the repository defeats an attempt to shadow the fragment + without leaving the checkout. + """ + outside = tmp_path / "other-config.toml" + outside.write_text("[profile.dev]\n", encoding="utf-8") + for redirect in (outside, repo_root() / "other-config.toml"): + environment, argv_path, _ = _bridge_environment(tmp_path) + environment["DEV_FAST_CONFIG"] = str(redirect) + result = _run_bridge(["rustc"], environment) + assert result.returncode == 47, ( + f"a caller-supplied DEV_FAST_CONFIG must not change behaviour: {redirect}" + ) + assert argv_path.read_text(encoding="utf-8").splitlines() == [ + "--config", + _adapter_fragment(), + "rustc", + ], f"the adapter must ignore {redirect} and select its own fragment" diff --git a/cuprum/unittests/test_dev_fast_contract.py b/cuprum/unittests/test_dev_fast_contract.py index aa8239912..7404b082f 100644 --- a/cuprum/unittests/test_dev_fast_contract.py +++ b/cuprum/unittests/test_dev_fast_contract.py @@ -130,6 +130,39 @@ def test_fragment_is_the_approved_immutable_extension() -> None: ) +def test_the_routed_fragment_path_cannot_be_overridden() -> None: + """The selected fragment is Makefile-fixed, not a caller-supplied variable. + + A `?=` or plain assignment here would let an environment variable or a + command-line variable select an unreviewed Cargo configuration while every + routing test still passed, because those tests exercise the default + invocation. GNU Make's `override` directive is the only definition form that + outranks both, so it is required rather than stylistic. + """ + makefile = (repo_root() / "Makefile").read_text(encoding="utf-8") + assert "override DEV_FAST_CONFIG_RELATIVE := tools/dev-fast/config.toml" in ( + makefile + ), "the fragment path must be a Makefile-owned constant no caller can replace" + assert "DEV_FAST_RUST_CONFIG ?=" not in makefile, ( + "a `?=` definition still yields to an ordinary environment variable" + ) + assert "\nDEV_FAST_CONFIG ?=" not in makefile, ( + "the fragment path must not remain caller-overridable" + ) + + +def test_the_adapter_environment_does_not_expose_the_configuration() -> None: + """The Maturin adapter derives its fragment, so nothing passes it one.""" + makefile = (repo_root() / "Makefile").read_text(encoding="utf-8") + assert "DEV_FAST_CONFIG=" not in makefile, ( + "the routed adapter environment must not carry a caller-settable fragment" + ) + output = _dry_run("develop", variables={"DEV_FAST_HOST_IS_LINUX": "yes"}) + assert "DEV_FAST_CONFIG=" not in output, ( + "develop must not hand the adapter a configuration path to trust" + ) + + def test_every_workspace_member_inherits_the_published_msrv() -> None: """Keep Clippy's MSRV-aware diagnostics aligned with Cargo metadata.""" rust_root = repo_root() / "rust" diff --git a/cuprum/unittests/test_dev_fast_prerequisites.py b/cuprum/unittests/test_dev_fast_prerequisites.py index 7cfaafccc..6a31e842b 100644 --- a/cuprum/unittests/test_dev_fast_prerequisites.py +++ b/cuprum/unittests/test_dev_fast_prerequisites.py @@ -15,6 +15,9 @@ from pathlib import Path +FRAGMENT = "tools/dev-fast/config.toml" + + @pytest.fixture def mold_version() -> str: """Return the linker version recorded by the checked-in pin.""" @@ -82,14 +85,6 @@ class _FailureCase(typ.NamedTuple): ), id="missing_clippy", ), - pytest.param( - _FailureCase( - {}, - {"DEV_FAST_CONFIG": "missing-dev-fast.toml"}, - "dev-fast configuration is missing", - ), - id="missing_fragment", - ), ], ) def test_prerequisite_recipe_fails_closed_for_missing_dependencies( @@ -142,6 +137,55 @@ def test_prerequisite_recipe_accepts_the_pinned_dependencies( assert result.returncode == 0, result.stderr +@pytest.mark.parametrize( + "delivery", + [ + pytest.param("command_line", id="command_line"), + pytest.param("environment", id="environment"), + ], +) +def test_recipe_ignores_an_overridden_configuration( + tmp_path: Path, delivery: str +) -> None: + """The evaluated route keeps the repository fragment whatever the caller sets. + + Both delivery mechanisms are exercised. A definition that merely looks safe + can still yield to an environment variable or to a command-line variable, + so proving only one of them leaves the other as an open redirect. + """ + redirection = str(tmp_path / "other-config.toml") + variables = [f"DEV_FAST_RUST_CONFIG={redirection}"] + environment = {**os.environ, "PATH": f"{tmp_path}:/usr/bin:/bin"} + if delivery == "command_line": + extra_arguments = variables + else: + extra_arguments = [] + environment["DEV_FAST_RUST_CONFIG"] = redirection + make = shutil.which("make") + assert make is not None, "the override contract requires GNU Make on PATH" + result = subprocess.run( # ruff: ignore[subprocess-without-shell-equals-true] - fixed Make argv. + [ + make, + "--dry-run", + "dev-build", + "DEV_FAST_HOST_IS_LINUX=yes", + *extra_arguments, + ], + check=False, + capture_output=True, + cwd=repo_root(), + env=environment, + text=True, + ) + assert result.returncode == 0, result.stderr + assert redirection not in result.stdout, ( + "an overridden configuration path must not reach the evaluated command" + ) + assert f"--config ../{FRAGMENT}" in result.stdout, ( + "the route must select the repository fragment despite the override" + ) + + def test_dev_build_executes_the_approved_fragment( tmp_path: Path, mold_version: str ) -> None: diff --git a/docs/adr-012-linux-dev-fast-routing.md b/docs/adr-012-linux-dev-fast-routing.md index cf7b0a29d..c737a1c34 100644 --- a/docs/adr-012-linux-dev-fast-routing.md +++ b/docs/adr-012-linux-dev-fast-routing.md @@ -39,11 +39,23 @@ the fragment's target flags. `make dev-fast-check` validates the selected fragment, the version from `tools/mold/VERSION`, and the installed component before an accelerated route runs. +The routed fragment path is internal state, not caller input. The Makefile +derives it from one constant assigned with GNU Make's `override` directive, so +neither a make variable on the command line nor an environment variable of the +same name can replace it. A plain or `?=` assignment would not hold: an +ordinary environment variable replaces a `?=` definition, and only `override` +outranks a command-line variable. Without it, a caller could route an +accelerated build through a configuration the project never reviewed while the +prerequisite gate still passed. The prerequisite gate checks that fixed +constant. Anything needing a different Cargo configuration is outside this +route and does not use it. + The Linux-only `tools/dev-fast/cargo` adapter receives the real Cargo -executable and immutable fragment path from Make. It adds exactly one -`--config` option, exports the real executable as `CARGO` for child build -scripts, rejects caller configuration options, and replaces itself with Cargo -to preserve argv and exit status. +executable from Make and derives the fragment from its own location, so the +configuration it passes is a property of the checked-out tree rather than of +its environment. It adds exactly one `--config` option, exports the real +executable as `CARGO` for child build scripts, rejects caller configuration +options, and replaces itself with Cargo to preserve argv and exit status. The shared CI action downloads only checksum-pinned upstream mold binaries for supported Linux architectures. It has no source-build fallback. CI installs the diff --git a/docs/developers-guide.md b/docs/developers-guide.md index e6bf718db..eb9562634 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -420,6 +420,16 @@ portion of `make rust-lint` select the fragment on a compatible Linux host. Cargo invocations leave toolchain selection to the caller; supplying the fragment does not select a toolchain. +The routed path is fixed, not configurable. The Makefile resolves the fragment +from one internal constant that no make variable or environment variable can +replace, so `make test-rust DEV_FAST_RUST_CONFIG=/tmp/other.toml` cannot +substitute a different configuration and still report success. Anything needing +a different Cargo configuration is outside this route and does not use it. + +Changing the fragment means changing the bytes that digest authorizes. Update +`FRAGMENT_SHA256` in `cuprum/unittests/test_dev_fast_contract.py` and the +digest quoted above in the same commit, or the contract test fails. + Every workspace package inherits `rust-version = "1.85.0"` from the root manifest. Cargo treats that as the publication and compatibility contract, and MSRV-aware Clippy lints use it while the Linux debug lint route runs on the diff --git a/tools/dev-fast/cargo b/tools/dev-fast/cargo index 95a2bbe9a..4a693834a 100755 --- a/tools/dev-fast/cargo +++ b/tools/dev-fast/cargo @@ -15,8 +15,15 @@ if [ -z "${DEV_FAST_CARGO:-}" ]; then exit 2 fi -if [ -z "${DEV_FAST_CONFIG:-}" ] || [ ! -f "$DEV_FAST_CONFIG" ]; then - printf '%s\n' 'dev-fast: DEV_FAST_CONFIG must name the approved configuration file' >&2 +# Derived from this adapter's own location, never from the environment. A +# caller-supplied DEV_FAST_CONFIG is ignored: this adapter is the process that +# must actually use the reviewed fragment, so a variable that redirected it +# would make the guarantee below false rather than merely configurable. +adapter_directory=$(cd -- "$(dirname -- "$0")" && pwd) +fragment="$adapter_directory/config.toml" + +if [ ! -f "$fragment" ]; then + printf 'dev-fast: the repository configuration is missing: %s\n' "$fragment" >&2 exit 2 fi @@ -32,4 +39,4 @@ done # Cargo-invoked build scripts inherit CARGO. Point them to the injected real # executable so they cannot recursively enter this adapter. export CARGO="$DEV_FAST_CARGO" -exec "$DEV_FAST_CARGO" --config "$DEV_FAST_CONFIG" "$@" +exec "$DEV_FAST_CARGO" --config "$fragment" "$@" From cd7c851a8208e24bbc064ff60faaf7ae1ad0aa8f Mon Sep 17 00:00:00 2001 From: leynos Date: Sat, 19 Sep 2026 14:03:23 +0200 Subject: [PATCH 12/12] Collapse the adapter's duplicated invocation helpers CodeScene flagged `_run_bridge` and `_run_bridge_relatively` as two functions with similar structure. The duplication was real: both wrapped the same `subprocess.run` call, differing only in the executable spelling. Give `_run_bridge` a keyword-only `path` argument and fold the relative/absolute choice into the one test that needs both. The parametrized case still launches the adapter through each spelling in turn, so the `$0`-based resolution remains covered in the form Make actually uses and in the absolute form. The refactor did not degrade the coverage it replaced: `./tools/dev-fast/cargo` and the absolute path are distinct strings, and both resolve. Co-Authored-By: Claude Code --- cuprum/unittests/test_dev_fast_adapter.py | 44 ++++++++++++----------- 1 file changed, 23 insertions(+), 21 deletions(-) diff --git a/cuprum/unittests/test_dev_fast_adapter.py b/cuprum/unittests/test_dev_fast_adapter.py index f44fd80f7..dbcd8df53 100644 --- a/cuprum/unittests/test_dev_fast_adapter.py +++ b/cuprum/unittests/test_dev_fast_adapter.py @@ -13,6 +13,7 @@ from tests.helpers.docs import repo_root +ADAPTER = "tools/dev-fast/cargo" FRAGMENT = "tools/dev-fast/config.toml" SAFE_CARGO_ARGUMENTS = st.sampled_from(( "rustc", @@ -55,25 +56,24 @@ def _bridge_environment(tmp_path: Path) -> tuple[dict[str, str], Path, Path]: def _run_bridge( - arguments: list[str], environment: dict[str, str] + arguments: list[str], + environment: dict[str, str], + *, + path: str = f"./{ADAPTER}", ) -> subprocess.CompletedProcess[str]: - """Run the controlled adapter process with captured diagnostics.""" - return subprocess.run( # ruff: ignore[subprocess-without-shell-equals-true] - fixed adapter argv. - [repo_root() / "tools/dev-fast/cargo", *arguments], - check=False, - capture_output=True, - cwd=repo_root(), - env={**os.environ, **environment}, - text=True, - ) + """Run the controlled adapter process with captured diagnostics. + ``path`` defaults to the relative spelling because that is how the + Makefile actually invokes the adapter; pass an absolute path to cover the + other form. The adapter reads its own ``$0``, so neither spelling may + change which fragment it selects. -def _run_bridge_relatively( - arguments: list[str], environment: dict[str, str] -) -> subprocess.CompletedProcess[str]: - """Run the adapter exactly as the Makefile does: by relative path.""" + Returns + ------- + The completed adapter process, with captured output and exit status. + """ return subprocess.run( # ruff: ignore[subprocess-without-shell-equals-true] - fixed adapter argv. - [f"./{FRAGMENT.rsplit('/', 1)[0]}/cargo", *arguments], + [path, *arguments], check=False, capture_output=True, cwd=repo_root(), @@ -82,19 +82,21 @@ def _run_bridge_relatively( ) -def test_bridge_resolves_the_fragment_from_a_relative_invocation( - tmp_path: Path, +@pytest.mark.parametrize("invocation", ["relative", "absolute"]) +def test_bridge_resolves_its_fragment_from_either_invocation_form( + tmp_path: Path, invocation: str ) -> None: - """A relative adapter path resolves to the repository fragment, not cwd.""" + """The adapter finds the repository fragment however it is invoked.""" environment, argv_path, _ = _bridge_environment(tmp_path) - result = _run_bridge_relatively(["rustc", "--lib"], environment) - assert result.returncode == 47, "the relative invocation must reach Cargo" + path = f"./{ADAPTER}" if invocation == "relative" else str(repo_root() / ADAPTER) + result = _run_bridge(["rustc", "--lib"], environment, path=path) + assert result.returncode == 47, f"the {invocation} invocation must reach Cargo" assert argv_path.read_text(encoding="utf-8").splitlines() == [ "--config", _adapter_fragment(), "rustc", "--lib", - ], "the adapter must resolve its fragment independently of the invocation form" + ], f"the {invocation} invocation must select the repository fragment" def test_bridge_injects_one_fragment_and_preserves_child_exit(tmp_path: Path) -> None: