From d15162c9a88e22b1e86fe50c28c620973ae33962 Mon Sep 17 00:00:00 2001 From: leynos Date: Sun, 20 Sep 2026 17:50:06 +0200 Subject: [PATCH 1/6] Bind Whitaker lint gate to every Rust package Split Rust linting into explicit Clippy, Whitaker, and spelling leaves. Keep the Whitaker invocation fail-closed and outside the Linux dev-fast route while forwarding an audited fixed package set to Cargo. --- AGENTS.md | 5 +- Makefile | 21 ++- .../unittests/test_whitaker_make_contract.py | 129 ++++++++++++++++++ docs/developers-guide.md | 16 ++- 4 files changed, 161 insertions(+), 10 deletions(-) create mode 100644 cuprum/unittests/test_whitaker_make_contract.py diff --git a/AGENTS.md b/AGENTS.md index deb40b334..c0bd61a40 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -197,8 +197,9 @@ working on the Rust portions of the project: ``` validating formatting across the Rust workspace without modifying files. - - `make lint` executes its Rustdoc and Clippy steps through - `RUST_DEBUG_CARGO`, then runs Whitaker separately: + - `make lint` runs `lint-clippy`, `lint-whitaker`, and spelling in sequence. + Its Rustdoc and Clippy leaf runs through `RUST_DEBUG_CARGO`; Whitaker is a + separate fragment-free leaf: ```sh cd rust && RUSTDOCFLAGS="$(RUSTDOC_FLAGS)" \ diff --git a/Makefile b/Makefile index c84915bae..d36ec10bb 100644 --- a/Makefile +++ b/Makefile @@ -73,8 +73,13 @@ TEST_JOBS ?= 1 TEST_FLAGS ?= $(CARGO_FLAGS) --jobs $(TEST_JOBS) DOCTEST_FLAGS ?= --workspace --doc --all-features $(BUILD_JOBS) TEST_RUSTFLAGS ?= $(RUST_FLAGS) -C codegen-units=1 -WHITAKER_CARGO_FLAGS ?= $(CARGO_FLAGS) --jobs 1 WHITAKER_RUSTFLAGS ?= $(RUST_FLAGS) -C codegen-units=1 +# Whitaker's `--all` selects its lint libraries, rather than Cargo packages. +# Keep the package boundary explicit and Makefile-owned so every Rust member is +# checked even when the wrapper's library-selection semantics change. +override WHITAKER_PACKAGES := cuprum-rust cuprum-streams cuprum-native-io +WHITAKER_PACKAGE_FLAGS := $(foreach package,$(WHITAKER_PACKAGES),--package $(package)) +override WHITAKER_CARGO_FLAGS := $(WHITAKER_PACKAGE_FLAGS) $(CARGO_FLAGS) --jobs 1 # Extra flags for the `maturin develop` invocation in the `develop` target. # Empty by default: a debug build is what contributors and the extension-tests # job want. The benchmark ratchet needs an optimized build, and an optimized @@ -201,8 +206,8 @@ SKYLOS_WHITELIST_LOCK ?= .skylos-whitelist.lock MDLINT_FILES_FIND = bash -o pipefail -c 'git ls-files -z --cached --others --exclude-standard -- "$$@" | while IFS= read -r -d "" markdown_file; do if [ -f "$$markdown_file" ] && [ ! -L "$$markdown_file" ]; then case "$$markdown_file" in -*) printf "./%s\0" "$$markdown_file" ;; *) printf "%s\0" "$$markdown_file" ;; esac; fi; done' -- $(MARKDOWN_GLOBS) MDLINT_FIX_COMMAND = unset FORCE_COLOR; $(LOCAL_TOOL_ENV) xargs -0 -r $(MDLINT) --fix < "$$markdown_files" MDLINT_CHECK_COMMAND = unset FORCE_COLOR; $(LOCAL_TOOL_ENV) xargs -0 -r $(MDLINT) < "$$markdown_files" -.PHONY: help all clean build build-release lint python-lint rust-lint \ - github-actions-lint \ +.PHONY: help all clean build build-release lint python-lint rust-lint lint-clippy \ + lint-whitaker github-actions-lint \ lint-windows fmt check-fmt \ markdownlint spelling nixie test test-python test-rust loom typecheck \ test-extension test-markdown-format develop makeutil skylos-allow \ @@ -293,7 +298,7 @@ test-markdown-format: ## Validate the Markdown formatting Makefile contract python -m pytest scripts/tests/test_markdown_format_makefile.py -c /dev/null \ --rootdir=. -p no:cacheprovider -lint: python-lint rust-lint github-actions-lint ## Run Python, Rust, and GitHub Actions linters +lint: python-lint .WAIT rust-lint .WAIT github-actions-lint ## Run Python, Rust, and GitHub Actions linters python-lint: ruff uv ## Run Ruff, interrogate, pylint, df12-python-lints, and ambrleaks $(RUFF) check && $(INTERROGATE) && $(PYLINT) $(PYLINT_TARGETS) @@ -301,11 +306,13 @@ python-lint: ruff uv ## Run Ruff, interrogate, pylint, df12-python-lints, and am $(AMBRLEAKS) cuprum/unittests scripts/tests tests $(SKYLOS) $(SKYLOS_PRODUCTION_TARGETS) --exclude $(SKYLOS_EXCLUDE_FOLDERS) --category dead_code --gate --format concise --no-upload --no-provenance --no-grep-verify -rust-lint: $(RUST_DEBUG_PREREQUISITE) ## Run Rust documentation, Clippy, Whitaker, and spelling checks +rust-lint: lint-clippy .WAIT lint-whitaker .WAIT spelling ## Run Rust documentation, Clippy, Whitaker, and spelling checks + +lint-clippy: $(RUST_DEBUG_PREREQUISITE) ## Run Rust documentation and Clippy cd $(RUST_DIR) && RUSTDOCFLAGS="$(RUSTDOC_FLAGS)" $(RUST_DEBUG_CARGO) doc --no-deps $(DOC_FLAGS) && $(RUST_DEBUG_CARGO) clippy $(CLIPPY_FLAGS) - @if ! $(LOCAL_TOOL_ENV) command -v $(WHITAKER) >/dev/null 2>&1; then echo "whitaker is required for linting. Install it before running this target." >&2; exit 1; fi + +lint-whitaker: ## Run Whitaker for every Rust workspace package cd $(RUST_DIR) && $(LOCAL_TOOL_ENV) RUSTFLAGS="$(WHITAKER_RUSTFLAGS)" $(WHITAKER) --all -- $(WHITAKER_CARGO_FLAGS) - +$(MAKE) spelling skylos-allow: export SKYLOS_SYMBOL = $(value SYMBOL) skylos-allow: export SKYLOS_REASON = $(value REASON) diff --git a/cuprum/unittests/test_whitaker_make_contract.py b/cuprum/unittests/test_whitaker_make_contract.py new file mode 100644 index 000000000..1d0f1258b --- /dev/null +++ b/cuprum/unittests/test_whitaker_make_contract.py @@ -0,0 +1,129 @@ +"""Exercise the fail-closed Whitaker Makefile boundary.""" + +from __future__ import annotations + +import os +import shutil +import subprocess # ruff: ignore[suspicious-subprocess-import] - controlled Make argv. +import sys +import typing as typ + +from tests.helpers.docs import repo_root + +if typ.TYPE_CHECKING: + from pathlib import Path + + +_PACKAGES = ("cuprum-rust", "cuprum-streams", "cuprum-native-io") + + +def _make_executable() -> str: + """Return the GNU Make executable required by the routing contract.""" + executable = shutil.which("make") + assert executable is not None, "Whitaker Makefile tests require GNU Make" + return executable + + +def _dry_run( + *, variables: dict[str, str] | None = None, target: str = "lint-whitaker" +) -> str: + """Return the evaluated Whitaker recipe with inert caller tools.""" + completed = subprocess.run( # ruff: ignore[subprocess-without-shell-equals-true] - fixed Make argv. + [ + _make_executable(), + "--dry-run", + "CARGO=probe-cargo", + "WHITAKER=probe-whitaker", + *(f"{key}={value}" for key, value in (variables or {}).items()), + target, + ], + capture_output=True, + check=True, + cwd=repo_root(), + env={**os.environ, "MAKEFLAGS": ""}, + text=True, + ) + return completed.stdout + + +def _whitaker_command(output: str) -> str: + """Extract the one binding invocation from Make's evaluated recipe.""" + return next( + line for line in output.splitlines() if "probe-whitaker --all --" in line + ) + + +def test_whitaker_target_covers_the_exact_workspace_packages() -> None: + """Package selection reaches Cargo after the wrapper separator.""" + command = _whitaker_command(_dry_run()) + wrapper, separator, cargo_arguments = command.partition(" -- ") + + assert separator == " -- ", "Whitaker must receive Cargo flags after `--`" + assert wrapper.endswith("probe-whitaker --all"), ( + "Whitaker must load the full lint suite before forwarding Cargo flags" + ) + assert cargo_arguments.split() == [ + *[argument for package in _PACKAGES for argument in ("--package", package)], + "--all-targets", + "--all-features", + "--jobs", + "1", + ], "the binding must enumerate every Rust workspace package exactly once" + + +def test_whitaker_package_scope_cannot_be_caller_overridden() -> None: + """An arbitrary Make variable cannot remove an audited workspace package.""" + command = _whitaker_command(_dry_run(variables={"WHITAKER_PACKAGES": "other"})) + + assert "--package other" not in command, "caller scope must not reach Whitaker" + assert all(f"--package {package}" in command for package in _PACKAGES), ( + "every audited package must remain in the binding" + ) + + +def test_whitaker_target_is_direct_and_fragment_free() -> None: + """The binding cannot be skipped, masked, or routed through dev-fast.""" + command = _whitaker_command(_dry_run()) + + assert "command -v" not in command, "the binding must not have an existence guard" + assert "||" not in command, "the binding must not ignore a wrapper failure" + assert "|" not in command, "the binding must not mask a wrapper failure" + assert "--config" not in command, "Whitaker must not select a Cargo fragment" + assert "nightly-2026-08-23" not in command, ( + "Whitaker must retain its independently pinned verifier toolchain" + ) + + +def test_rust_lint_runs_clippy_whitaker_and_spelling_in_order() -> None: + """The aggregate Rust gate preserves the intended sequential leaf order.""" + output = _dry_run(target="rust-lint") + clippy = output.index("probe-cargo --config ../tools/dev-fast/config.toml clippy") + whitaker = output.index("probe-whitaker --all --") + spelling = output.index("typos-config-builder gate --repository . --scope all") + + assert clippy < whitaker < spelling, ( + "rust-lint must finish Clippy before Whitaker and spelling" + ) + + +def test_whitaker_failure_propagates_through_make(tmp_path: Path) -> None: + """A failing suite executable must fail the leaf target unchanged by a guard.""" + failing_whitaker = tmp_path / "failing-whitaker" + failing_whitaker.write_text( + f"#!{sys.executable}\nimport sys\nsys.exit(73)\n", encoding="utf-8" + ) + failing_whitaker.chmod(0o755) + + completed = subprocess.run( # ruff: ignore[subprocess-without-shell-equals-true] - fixed Make argv. + [_make_executable(), "lint-whitaker", f"WHITAKER={failing_whitaker}"], + capture_output=True, + check=False, + cwd=repo_root(), + env={**os.environ, "MAKEFLAGS": ""}, + text=True, + ) + + assert completed.returncode != 0, "a failing Whitaker executable must fail Make" + assert "Error 73" in completed.stderr, ( + "Make must report the wrapper's original non-zero status" + ) diff --git a/docs/developers-guide.md b/docs/developers-guide.md index b479bc0aa..2e9e28ce0 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -381,7 +381,21 @@ verification, MSRV, and Whitaker commands, together with macOS and Windows, retain their prescribed fragment-free or separately pinned toolchains. The stable pin declares `rustfmt`, `clippy`, and `rust-analyzer` for local maintenance. The Whitaker action receives `WHITAKER_INSTALLER_VERSION` from the -job environment (`0.2.7`, the workflow's configured installer version). +job environment (`0.2.7`, the workflow's configured installer version). The +Makefile runs `lint-clippy`, `lint-whitaker`, and spelling sequentially; +`lint-whitaker` passes Cargo `--package` arguments for `cuprum-rust`, +`cuprum-streams`, and `cuprum-native-io` after Whitaker's `--` separator. +Whitaker's `--all` chooses lint libraries, not workspace packages. + +The local binding intentionally remains fragment-free and fail-closed, but it +cannot make the suite distribution immutable by itself. The required +binary-only, checksummed suite resolver is tracked by +[shared-actions issue 499](https://github.com/leynos/shared-actions/issues/499), +which depends on Whitaker retaining per-release lint artefacts and provenance +([issues 403](https://github.com/leynos/whitaker/issues/403) and +[425](https://github.com/leynos/whitaker/issues/425)). Until those contracts +land, this gate must remain a draft integration layer rather than a claim of +reproducible binary-only CI. cargo-nextest is no longer installed here at all. The coverage job is the only place it runs, and the shared action installs it from checksummed official From cdd913c08aedf7309e0cddeafd4dc7d6076252df Mon Sep 17 00:00:00 2001 From: leynos Date: Sun, 20 Sep 2026 17:59:03 +0200 Subject: [PATCH 2/6] Update lint contracts for Whitaker leaves Keep aggregate lint and workflow-isolation contracts aligned with the explicit Clippy, Whitaker, and spelling target hierarchy. --- cuprum/unittests/test_skylos_lint_contract.py | 2 ++ cuprum/unittests/test_workflow_lint.py | 6 +++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/cuprum/unittests/test_skylos_lint_contract.py b/cuprum/unittests/test_skylos_lint_contract.py index d623f7bba..db002774c 100644 --- a/cuprum/unittests/test_skylos_lint_contract.py +++ b/cuprum/unittests/test_skylos_lint_contract.py @@ -114,7 +114,9 @@ _SKYLOS_WHITELIST_LOCK_TOKENS: typ.Final = (".skylos-whitelist.lock",) _LINT_PREREQUISITES: typ.Final = ( "python-lint", + ".WAIT", "rust-lint", + ".WAIT", "github-actions-lint", ) _DOCUMENTED_WHITELIST_NAMES: typ.Final = frozenset({"_check_rust_available"}) diff --git a/cuprum/unittests/test_workflow_lint.py b/cuprum/unittests/test_workflow_lint.py index a1fb51231..327f1b65c 100644 --- a/cuprum/unittests/test_workflow_lint.py +++ b/cuprum/unittests/test_workflow_lint.py @@ -279,7 +279,11 @@ def test_the_lint_target_runs_the_workflow_linters(tmp_path: pth.Path) -> None: ) overrides = tmp_path / "lint-target-overrides.mk" overrides.write_text( - ".PHONY: python-lint rust-lint\npython-lint:\n\t@:\nrust-lint:\n\t@:\n", + ".PHONY: python-lint lint-clippy lint-whitaker spelling\n" + "python-lint:\n\t@:\n" + "lint-clippy:\n\t@:\n" + "lint-whitaker:\n\t@:\n" + "spelling:\n\t@:\n", encoding="utf-8", ) From 8a0abce81efd7c6cc62b69da5f7eb7e40e986cd7 Mon Sep 17 00:00:00 2001 From: leynos Date: Sun, 20 Sep 2026 19:01:23 +0200 Subject: [PATCH 3/6] Serialize portable lint leaf targets Replace unsupported Make prerequisite ordering with sequential recursive leaf calls. Preserve caller-supplied Makefiles so test overrides and injected tools remain effective in every lint layer. --- Makefile | 14 +++++++++++-- cuprum/unittests/test_dev_fast_contract.py | 20 +++++++++++++++++++ cuprum/unittests/test_skylos_lint_contract.py | 16 ++++++++------- .../unittests/test_whitaker_make_contract.py | 14 +++++++++++++ 4 files changed, 55 insertions(+), 9 deletions(-) diff --git a/Makefile b/Makefile index d36ec10bb..5d94c603d 100644 --- a/Makefile +++ b/Makefile @@ -141,6 +141,10 @@ LOCAL_TOOL_PATH = $(HOME)/.local/bin:$(HOME)/.bun/bin:$(PATH) LOCAL_TOOL_ENV = PATH="$(LOCAL_TOOL_PATH)" endif UV_RUN_ENV = $(LOCAL_TOOL_ENV) $(UV_ENV) +# Recursive lint leaves preserve every caller-supplied Makefile. This keeps +# contract-test overrides and injected variables available to each leaf without +# depending on GNU Make 4.4-only prerequisite ordering syntax. +RECURSIVE_MAKE = $(MAKE) $(foreach makefile,$(MAKEFILE_LIST),-f $(makefile)) RUFF_ENV = RAYON_NUM_THREADS=1 # Pin Ruff so `make` invokes the same version as the `ruff==` dev dependency # in pyproject.toml and the RUFF_VERSION env in .github/workflows/ci.yml. @@ -298,7 +302,10 @@ test-markdown-format: ## Validate the Markdown formatting Makefile contract python -m pytest scripts/tests/test_markdown_format_makefile.py -c /dev/null \ --rootdir=. -p no:cacheprovider -lint: python-lint .WAIT rust-lint .WAIT github-actions-lint ## Run Python, Rust, and GitHub Actions linters +lint: ## Run Python, Rust, and GitHub Actions linters + +$(RECURSIVE_MAKE) python-lint + +$(RECURSIVE_MAKE) rust-lint + +$(RECURSIVE_MAKE) github-actions-lint python-lint: ruff uv ## Run Ruff, interrogate, pylint, df12-python-lints, and ambrleaks $(RUFF) check && $(INTERROGATE) && $(PYLINT) $(PYLINT_TARGETS) @@ -306,7 +313,10 @@ 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: lint-clippy .WAIT lint-whitaker .WAIT spelling ## Run Rust documentation, Clippy, Whitaker, and spelling checks +rust-lint: ## Run Rust documentation, Clippy, Whitaker, and spelling checks + +$(RECURSIVE_MAKE) lint-clippy + +$(RECURSIVE_MAKE) lint-whitaker + +$(RECURSIVE_MAKE) spelling lint-clippy: $(RUST_DEBUG_PREREQUISITE) ## Run Rust documentation and Clippy cd $(RUST_DIR) && RUSTDOCFLAGS="$(RUSTDOC_FLAGS)" $(RUST_DEBUG_CARGO) doc --no-deps $(DOC_FLAGS) && $(RUST_DEBUG_CARGO) clippy $(CLIPPY_FLAGS) diff --git a/cuprum/unittests/test_dev_fast_contract.py b/cuprum/unittests/test_dev_fast_contract.py index f5e65d134..399043950 100644 --- a/cuprum/unittests/test_dev_fast_contract.py +++ b/cuprum/unittests/test_dev_fast_contract.py @@ -300,6 +300,26 @@ def test_whitaker_and_windows_lint_remain_fragment_free() -> None: ) +def test_portable_lint_orchestration_preserves_leaf_order() -> None: + """The aggregate lint gates avoid GNU Make 4.4-only prerequisite markers.""" + makefile = (repo_root() / "Makefile").read_text(encoding="utf-8") + output = _dry_run("lint") + + assert ".WAIT" not in makefile, "hosted Make must not require GNU Make 4.4" + assert "RECURSIVE_MAKE = $(MAKE) $(foreach makefile,$(MAKEFILE_LIST)" in makefile, ( + "recursive lint leaves must preserve caller-supplied Makefiles" + ) + python_lint = output.index("python-lint") + clippy = output.index("probe-cargo --config ../tools/dev-fast/config.toml clippy") + whitaker = output.index("whitaker --all --") + spelling = output.index("typos-config-builder gate --repository . --scope all") + workflow_lint = output.index("yamllint --strict --config-file") + + assert python_lint < clippy < whitaker < spelling < workflow_lint, ( + "lint must serialize Python, Rust leaves, and GitHub Actions validation" + ) + + def test_msrv_verification_keeps_the_stable_route_fragment_free() -> None: """The accelerated lint compiler cannot replace published-MSRV verification.""" output = _dry_run("msrv-check") diff --git a/cuprum/unittests/test_skylos_lint_contract.py b/cuprum/unittests/test_skylos_lint_contract.py index db002774c..d19aae8d9 100644 --- a/cuprum/unittests/test_skylos_lint_contract.py +++ b/cuprum/unittests/test_skylos_lint_contract.py @@ -112,12 +112,11 @@ "$${SKYLOS_REASON}", ) _SKYLOS_WHITELIST_LOCK_TOKENS: typ.Final = (".skylos-whitelist.lock",) -_LINT_PREREQUISITES: typ.Final = ( - "python-lint", - ".WAIT", - "rust-lint", - ".WAIT", - "github-actions-lint", +_LINT_PREREQUISITES: typ.Final = () +_LINT_ORCHESTRATION: typ.Final = ( + ("+$(RECURSIVE_MAKE)", "python-lint"), + ("+$(RECURSIVE_MAKE)", "rust-lint"), + ("+$(RECURSIVE_MAKE)", "github-actions-lint"), ) _DOCUMENTED_WHITELIST_NAMES: typ.Final = frozenset({"_check_rust_available"}) _RUNTIME_PARAMETER_ENTRY_POINTS: typ.Final = frozenset({ @@ -307,7 +306,10 @@ def test_lint_recipe_runs_the_production_dead_code_gate() -> None: subject="lint target prerequisites", ) assert lint_prerequisites == _LINT_PREREQUISITES, ( - "Skylos lint delegation contract must retain the Python lint target" + "portable lint orchestration must not rely on concurrent prerequisites" + ) + assert _recipe_tokens("lint") == _LINT_ORCHESTRATION, ( + "Skylos lint delegation contract must invoke every leaf in sequence" ) skylos_commands = [ command diff --git a/cuprum/unittests/test_whitaker_make_contract.py b/cuprum/unittests/test_whitaker_make_contract.py index 1d0f1258b..db1f01a17 100644 --- a/cuprum/unittests/test_whitaker_make_contract.py +++ b/cuprum/unittests/test_whitaker_make_contract.py @@ -106,6 +106,20 @@ def test_rust_lint_runs_clippy_whitaker_and_spelling_in_order() -> None: ) +def test_lint_target_runs_the_leaf_hierarchy_without_wait_markers() -> None: + """The hosted Make route serializes leaves without GNU Make 4.4 syntax.""" + makefile = (repo_root() / "Makefile").read_text(encoding="utf-8") + output = _dry_run(target="lint") + + assert ".WAIT" not in makefile, "the binding must support the hosted Make" + assert output.index("python-lint") < output.index("probe-whitaker --all --"), ( + "Python lint must complete before the Whitaker leaf starts" + ) + assert output.index("probe-whitaker --all --") < output.index( + "yamllint --strict --config-file" + ), "GitHub Actions lint must run after the Rust leaves" + + def test_whitaker_failure_propagates_through_make(tmp_path: Path) -> None: """A failing suite executable must fail the leaf target unchanged by a guard.""" failing_whitaker = tmp_path / "failing-whitaker" From a4671b35e8a763f4e215c3763c2e428bbaf37012 Mon Sep 17 00:00:00 2001 From: leynos Date: Mon, 21 Sep 2026 17:14:54 +0200 Subject: [PATCH 4/6] Correct the Whitaker distribution model in the developer guide The guide described the Whitaker lint suite as an artefact the estate could not yet distribute, tracking that as a missing capability. That premise is wrong: the suite is a rolling release, and `rolling-release.yml` publishes prebuilt lint libraries for every commit to `main`, named `whitaker-lints---.tar.zst` beside a per-target manifest naming the same `git_sha`. The installer checksum-verifies each archive against that manifest, because `VerificationPolicy::default()` requires one. The distribution model is deliberate rather than incomplete. What is genuinely missing is narrower, and this rewrite states each part against its evidence: - Retention. Assets live under one `rolling` tag that is moved, not recreated, and each publish deletes the superseded lint archives, so only the tip's suite survives. `--suite-version` therefore forces a source build. This also corrects an earlier draft of this paragraph, which had the tag deleted and recreated per push; Whitaker PR 413 replaced exactly that delete-then-create pair because a downstream consumer resolved `rolling` inside the resulting six-to-seven-second gap and silently fell back to a source build. - Pinning. `--suite-version` and `SuiteRef` arrive in installer 0.2.8, so they are absent from the 0.2.7 this repository pins. - Fail-closed installation. The installer still maps prebuilt-suite failures to `Fallback`, and `--no-source-fallback` is on `main` but in no release yet. The claim that the local binding is "fail-closed" is also disambiguated: it is fail-closed about scope, not about installation, the two readings having sat one sentence apart. Co-Authored-By: Claude Code --- docs/developers-guide.md | 52 +++++++++++++++++++++++++++++++++------- 1 file changed, 43 insertions(+), 9 deletions(-) diff --git a/docs/developers-guide.md b/docs/developers-guide.md index 2e9e28ce0..16b9bd0c0 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -387,15 +387,49 @@ Makefile runs `lint-clippy`, `lint-whitaker`, and spelling sequentially; `cuprum-streams`, and `cuprum-native-io` after Whitaker's `--` separator. Whitaker's `--all` chooses lint libraries, not workspace packages. -The local binding intentionally remains fragment-free and fail-closed, but it -cannot make the suite distribution immutable by itself. The required -binary-only, checksummed suite resolver is tracked by -[shared-actions issue 499](https://github.com/leynos/shared-actions/issues/499), -which depends on Whitaker retaining per-release lint artefacts and provenance -([issues 403](https://github.com/leynos/whitaker/issues/403) and -[425](https://github.com/leynos/whitaker/issues/425)). Until those contracts -land, this gate must remain a draft integration layer rather than a claim of -reproducible binary-only CI. +Whitaker's lint suite is a rolling release, and that is the distribution model +rather than a defect. Its `rolling-release.yml` runs on every push to `main` +and publishes prebuilt lint libraries for that commit as +`whitaker-lints---.tar.zst`, together with a per-target +manifest naming the same `git_sha`. The source revision is therefore part of +the artefact identity: a suite is addressed by commit SHA, not by version +number. The installer verifies each downloaded archive's SHA-256 against that +manifest before extraction, because `VerificationPolicy::default()` requires a +checksum. + +Retention is the limit, and it is narrower than the tag name suggests. All +assets live under one `rolling` tag that is *moved* to the newest commit rather +than recreated: `scripts/publish-rolling-release.sh` uploads the new archives, +replaces the manifests, repoints the tag, and only then deletes the superseded +`whitaker-lints-*` assets. That ordering is deliberate — an earlier +delete-then-recreate pair left the tag and every asset absent for six to seven +seconds per publish, and a downstream consumer resolved `rolling` inside that +gap and silently fell back to a source build +([Whitaker PR 413](https://github.com/leynos/whitaker/pull/413)). By the end +of each publish only the newest commit's lint archives remain, so a `` +names an immutable artefact but not a permanently addressable one. + +The local binding is fragment-free, and it is fail-closed about scope: a +caller-supplied `WHITAKER_PACKAGES` cannot narrow the audited package set. It +cannot by itself guarantee, however, that the gate consumed a prebuilt, +commit-addressed suite. Three upstream contracts are outstanding, and none +argues against the rolling model. +[Whitaker issue 403](https://github.com/leynos/whitaker/issues/403) covers the +cost of pinning: `--suite-version` (added in installer 0.2.8, and so absent +from the 0.2.7 this repository pins) lets a consumer select a commit +(`SuiteRef`), but prebuilt lint archives are published only for the branch tip, +so a pin forces a source build — the CLI documents the trade as install time +against reproducibility. +[shared-actions issue 499](https://github.com/leynos/shared-actions/issues/499) +covers fail-closed installation: the installer maps prebuilt-suite failures to +`Fallback`, and its dependency install falls back to `cargo binstall` and then +`cargo install --locked`, so a job can silently compile rather than fail. The +switch that would forbid this, `--no-source-fallback`, is on Whitaker's `main` +and is in no release yet. +[Whitaker issue 425](https://github.com/leynos/whitaker/issues/425) asks for a +retained provenance record binding the installed library bytes to the source +revision and archive. Until those contracts land, this gate must remain a draft +integration layer rather than a claim of reproducible binary-only CI. cargo-nextest is no longer installed here at all. The coverage job is the only place it runs, and the shared action installs it from checksummed official From 4067455913234f18eac414e4d959f95123e7cec5 Mon Sep 17 00:00:00 2001 From: leynos Date: Mon, 21 Sep 2026 20:38:19 +0200 Subject: [PATCH 5/6] Close both Whitaker gate review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit's two findings were reproduced against the real Makefile before being fixed, and both were worse than reported. Finding 1 (caller `CARGO_FLAGS` reaching Whitaker) was confirmed, and an adjacent hole the review did not mention was found: `WHITAKER_PACKAGES` was `override`n but `WHITAKER_PACKAGE_FLAGS`, derived from it, was not. A caller could pass `WHITAKER_PACKAGE_FLAGS=--package evil` and displace every audited package while the list itself still looked intact. Both are now `override`n and the audited flag list no longer forwards a caller-overridable variable. Finding 2 (an `include`d fragment applied twice) was confirmed and compounds per recursion level: a `CARGO_FLAGS += ...` override appeared once at `lint-clippy`, twice at `rust-lint`, and three times at `lint`. The review's prescribed remedy is not implementable — GNU Make records `-f` inputs and files reached through `include` in the same `MAKEFILE_LIST` with nothing to tell them apart, so "forward only explicit `-f` inputs" cannot be expressed. The recursion is removed instead: the leaves are plain prerequisites of their aggregate, so one Make process reads the caller's `-f` file directly and there is no forwarding to get wrong. The fragment is now applied exactly once at every level. `.NOTPARALLEL: lint rust-lint` serializes both subtrees. The prerequisites on `.NOTPARALLEL` are themselves GNU Make 4.4-only, so this does not depend on them: Make 4.3 ignores them and serializes the whole run, while 4.4 scopes them to the two named subtrees. Both directions were verified against real 4.3 and 4.4 binaries, and the leaves ran in stable order under -j8 on each. As before, `.WAIT` is avoided precisely because it is 4.4-only. Regression tests cover both findings and each was mutation-tested: restoring the injection or the recursion makes the new tests fail. Co-Authored-By: Claude Code --- Makefile | 45 ++++++---- cuprum/unittests/test_dev_fast_contract.py | 20 ++++- cuprum/unittests/test_skylos_lint_contract.py | 18 ++-- .../unittests/test_whitaker_make_contract.py | 88 ++++++++++++++++++- 4 files changed, 142 insertions(+), 29 deletions(-) diff --git a/Makefile b/Makefile index 5d94c603d..2a3f30da6 100644 --- a/Makefile +++ b/Makefile @@ -78,8 +78,18 @@ WHITAKER_RUSTFLAGS ?= $(RUST_FLAGS) -C codegen-units=1 # Keep the package boundary explicit and Makefile-owned so every Rust member is # checked even when the wrapper's library-selection semantics change. override WHITAKER_PACKAGES := cuprum-rust cuprum-streams cuprum-native-io -WHITAKER_PACKAGE_FLAGS := $(foreach package,$(WHITAKER_PACKAGES),--package $(package)) -override WHITAKER_CARGO_FLAGS := $(WHITAKER_PACKAGE_FLAGS) $(CARGO_FLAGS) --jobs 1 +# Both the package list and the flags built from it are `override`n. Marking +# only the list would leave the derived flags caller-writable, and a caller +# could pass `WHITAKER_PACKAGE_FLAGS=--package evil` to displace every audited +# package while the list itself still looked intact. +override WHITAKER_PACKAGE_FLAGS := $(foreach package,$(WHITAKER_PACKAGES),--package $(package)) +# The audited Whitaker command is Makefile-owned end to end. It does not forward +# `CARGO_FLAGS`: that variable is caller-overridable, so a caller could pass +# `--config ../tools/dev-fast/config.toml` after Whitaker's `--` separator and +# quietly acquire the development fragment on a route that must stay +# fragment-free. The flags below are the whole of what Whitaker may receive. +override WHITAKER_CARGO_FLAGS := $(WHITAKER_PACKAGE_FLAGS) \ + --all-targets --all-features --jobs 1 # Extra flags for the `maturin develop` invocation in the `develop` target. # Empty by default: a debug build is what contributors and the extension-tests # job want. The benchmark ratchet needs an optimized build, and an optimized @@ -141,10 +151,6 @@ LOCAL_TOOL_PATH = $(HOME)/.local/bin:$(HOME)/.bun/bin:$(PATH) LOCAL_TOOL_ENV = PATH="$(LOCAL_TOOL_PATH)" endif UV_RUN_ENV = $(LOCAL_TOOL_ENV) $(UV_ENV) -# Recursive lint leaves preserve every caller-supplied Makefile. This keeps -# contract-test overrides and injected variables available to each leaf without -# depending on GNU Make 4.4-only prerequisite ordering syntax. -RECURSIVE_MAKE = $(MAKE) $(foreach makefile,$(MAKEFILE_LIST),-f $(makefile)) RUFF_ENV = RAYON_NUM_THREADS=1 # Pin Ruff so `make` invokes the same version as the `ruff==` dev dependency # in pyproject.toml and the RUFF_VERSION env in .github/workflows/ci.yml. @@ -218,7 +224,22 @@ MDLINT_CHECK_COMMAND = unset FORCE_COLOR; $(LOCAL_TOOL_ENV) xargs -0 -r $(MDLINT test-dev-fast-contract dev-fast-check dev-build dev-test msrv-check \ benchmark-micro benchmark-e2e \ $(TOOLS) $(VENV_TOOLS) -.NOTPARALLEL: lint +# Serialize the lint hierarchy. Each aggregate target lists its leaves as plain +# prerequisites, so a caller's `-f` override file is honoured directly by the +# one Make process that reads it, with no forwarding to get wrong. +# +# `.WAIT` would express the ordering in the prerequisite list itself, but it is +# GNU Make 4.4-only. `.NOTPARALLEL` is not: the prereq-less form dates from Make +# 3.79, and `.NOTPARALLEL` with prerequisites from 4.4. Naming the aggregates +# here therefore works on both, and degrades safely in the direction that +# matters: Make 4.3 ignores the prerequisites and serializes the whole run, +# while Make 4.4 serializes exactly the two named subtrees. Both were verified +# against real 4.3 and 4.4 binaries, so the hosted runners' Make needs no pin. +# +# Leaves are deliberately not named here. Naming `lint` alone would leave +# `rust-lint`'s own prerequisites free to interleave under `-j`; naming both +# subtrees is what fixes the order without serializing unrelated work. +.NOTPARALLEL: lint rust-lint .DEFAULT_GOAL := all @@ -302,10 +323,7 @@ test-markdown-format: ## Validate the Markdown formatting Makefile contract python -m pytest scripts/tests/test_markdown_format_makefile.py -c /dev/null \ --rootdir=. -p no:cacheprovider -lint: ## Run Python, Rust, and GitHub Actions linters - +$(RECURSIVE_MAKE) python-lint - +$(RECURSIVE_MAKE) rust-lint - +$(RECURSIVE_MAKE) github-actions-lint +lint: python-lint rust-lint github-actions-lint ## Run Python, Rust, and GitHub Actions linters python-lint: ruff uv ## Run Ruff, interrogate, pylint, df12-python-lints, and ambrleaks $(RUFF) check && $(INTERROGATE) && $(PYLINT) $(PYLINT_TARGETS) @@ -313,10 +331,7 @@ 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 - +$(RECURSIVE_MAKE) lint-clippy - +$(RECURSIVE_MAKE) lint-whitaker - +$(RECURSIVE_MAKE) spelling +rust-lint: lint-clippy lint-whitaker spelling ## Run Rust documentation, Clippy, Whitaker, and spelling checks lint-clippy: $(RUST_DEBUG_PREREQUISITE) ## Run Rust documentation and Clippy cd $(RUST_DIR) && RUSTDOCFLAGS="$(RUSTDOC_FLAGS)" $(RUST_DEBUG_CARGO) doc --no-deps $(DOC_FLAGS) && $(RUST_DEBUG_CARGO) clippy $(CLIPPY_FLAGS) diff --git a/cuprum/unittests/test_dev_fast_contract.py b/cuprum/unittests/test_dev_fast_contract.py index 399043950..6b8041b19 100644 --- a/cuprum/unittests/test_dev_fast_contract.py +++ b/cuprum/unittests/test_dev_fast_contract.py @@ -305,9 +305,23 @@ def test_portable_lint_orchestration_preserves_leaf_order() -> None: makefile = (repo_root() / "Makefile").read_text(encoding="utf-8") output = _dry_run("lint") - assert ".WAIT" not in makefile, "hosted Make must not require GNU Make 4.4" - assert "RECURSIVE_MAKE = $(MAKE) $(foreach makefile,$(MAKEFILE_LIST)" in makefile, ( - "recursive lint leaves must preserve caller-supplied Makefiles" + # Strip comments before looking for `.WAIT`: the Makefile describes the + # marker in prose precisely to record why it is not used, and a raw text + # search would match that explanation instead of any real prerequisite. + directives = "\n".join( + line for line in makefile.splitlines() if not line.lstrip().startswith("#") + ) + assert ".WAIT" not in directives, "hosted Make must not require GNU Make 4.4" + # `.NOTPARALLEL` with prerequisites is itself GNU Make 4.4-only, so the + # ordering cannot rest on it alone. Make 4.3 ignores the prerequisites and + # serializes the whole run instead, which preserves the order either way. + assert ".NOTPARALLEL: lint rust-lint" in directives, ( + "both lint aggregates must be serialized, present on GNU Make 4.3 and 4.4" + ) + assert "RECURSIVE_MAKE" not in makefile, ( + "lint leaves must run in one Make process so caller `-f` files are " + "honoured without forwarding a `MAKEFILE_LIST` that cannot distinguish " + "`-f` inputs from included files" ) python_lint = output.index("python-lint") clippy = output.index("probe-cargo --config ../tools/dev-fast/config.toml clippy") diff --git a/cuprum/unittests/test_skylos_lint_contract.py b/cuprum/unittests/test_skylos_lint_contract.py index d19aae8d9..d912c3e0d 100644 --- a/cuprum/unittests/test_skylos_lint_contract.py +++ b/cuprum/unittests/test_skylos_lint_contract.py @@ -112,12 +112,11 @@ "$${SKYLOS_REASON}", ) _SKYLOS_WHITELIST_LOCK_TOKENS: typ.Final = (".skylos-whitelist.lock",) -_LINT_PREREQUISITES: typ.Final = () -_LINT_ORCHESTRATION: typ.Final = ( - ("+$(RECURSIVE_MAKE)", "python-lint"), - ("+$(RECURSIVE_MAKE)", "rust-lint"), - ("+$(RECURSIVE_MAKE)", "github-actions-lint"), -) +_LINT_PREREQUISITES: typ.Final = ("python-lint", "rust-lint", "github-actions-lint") +# `lint` carries no recipe: the leaves are prerequisites, and `.NOTPARALLEL` +# serializes them. Running the leaves in the same Make process is what keeps a +# caller's `-f` override file effective without forwarding `MAKEFILE_LIST`, +# which cannot distinguish `-f` inputs from files pulled in by `include`. _DOCUMENTED_WHITELIST_NAMES: typ.Final = frozenset({"_check_rust_available"}) _RUNTIME_PARAMETER_ENTRY_POINTS: typ.Final = frozenset({ "cuprum.adapters.metrics_adapter.InMemoryMetrics.inc_counter.labels", @@ -306,10 +305,11 @@ def test_lint_recipe_runs_the_production_dead_code_gate() -> None: subject="lint target prerequisites", ) assert lint_prerequisites == _LINT_PREREQUISITES, ( - "portable lint orchestration must not rely on concurrent prerequisites" + "lint must sequence its leaves as prerequisites in a single Make process" ) - assert _recipe_tokens("lint") == _LINT_ORCHESTRATION, ( - "Skylos lint delegation contract must invoke every leaf in sequence" + lint_recipes = _sole_recipe_rule("lint", require_recipes=False).get("recipes") + assert _objects(lint_recipes, subject="lint recipes") == [], ( + "lint must delegate to its leaves as prerequisites, not recursive sub-makes" ) skylos_commands = [ command diff --git a/cuprum/unittests/test_whitaker_make_contract.py b/cuprum/unittests/test_whitaker_make_contract.py index db1f01a17..ad2549c9b 100644 --- a/cuprum/unittests/test_whitaker_make_contract.py +++ b/cuprum/unittests/test_whitaker_make_contract.py @@ -8,9 +8,12 @@ import sys 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 @@ -25,13 +28,17 @@ def _make_executable() -> str: def _dry_run( - *, variables: dict[str, str] | None = None, target: str = "lint-whitaker" + *, + variables: dict[str, str] | None = None, + target: str = "lint-whitaker", + makefiles: cabc.Sequence[Path] = (), ) -> str: """Return the evaluated Whitaker recipe with inert caller tools.""" completed = subprocess.run( # ruff: ignore[subprocess-without-shell-equals-true] - fixed Make argv. [ _make_executable(), "--dry-run", + *[argument for path in makefiles for argument in ("-f", str(path))], "CARGO=probe-cargo", "WHITAKER=probe-whitaker", *(f"{key}={value}" for key, value in (variables or {}).items()), @@ -94,6 +101,50 @@ def test_whitaker_target_is_direct_and_fragment_free() -> None: ) +def test_caller_cargo_flags_cannot_reach_whitaker() -> None: + """A caller-supplied `CARGO_FLAGS` must not pass Whitaker's `--` separator. + + `CARGO_FLAGS` is caller-overridable, so forwarding it would let a caller + append `--config ../tools/dev-fast/config.toml` and quietly acquire the + development fragment on a route that must stay fragment-free. + """ + command = _whitaker_command( + _dry_run( + variables={ + "CARGO_FLAGS": "--all-targets --config ../tools/dev-fast/config.toml" + } + ) + ) + + assert "--config" not in command, "caller Cargo flags must not reach Whitaker" + assert command.split(" -- ", 1)[1].split() == [ + *[argument for package in _PACKAGES for argument in ("--package", package)], + "--all-targets", + "--all-features", + "--jobs", + "1", + ], "the audited flag list must be the whole of what Whitaker receives" + + +def test_caller_package_flags_cannot_displace_audited_packages() -> None: + """The derived package flags must be `override`n, not just the package list. + + Marking only `WHITAKER_PACKAGES` leaves the flags built from it writable, so + a caller can pass `WHITAKER_PACKAGE_FLAGS=--package evil` to displace every + audited package while the list itself still looks intact. + """ + command = _whitaker_command( + _dry_run(variables={"WHITAKER_PACKAGE_FLAGS": "--package evil"}) + ) + + assert "--package evil" not in command, ( + "caller-derived package flags must not displace audited packages" + ) + assert all(f"--package {package}" in command for package in _PACKAGES), ( + "every audited package must survive a caller-supplied flag override" + ) + + def test_rust_lint_runs_clippy_whitaker_and_spelling_in_order() -> None: """The aggregate Rust gate preserves the intended sequential leaf order.""" output = _dry_run(target="rust-lint") @@ -111,7 +162,12 @@ def test_lint_target_runs_the_leaf_hierarchy_without_wait_markers() -> None: makefile = (repo_root() / "Makefile").read_text(encoding="utf-8") output = _dry_run(target="lint") - assert ".WAIT" not in makefile, "the binding must support the hosted Make" + # Strip comments first: the Makefile names `.WAIT` in prose to record why it + # is deliberately unused, and a raw search would match that explanation. + directives = "\n".join( + line for line in makefile.splitlines() if not line.lstrip().startswith("#") + ) + assert ".WAIT" not in directives, "the binding must support the hosted Make" assert output.index("python-lint") < output.index("probe-whitaker --all --"), ( "Python lint must complete before the Whitaker leaf starts" ) @@ -120,6 +176,34 @@ def test_lint_target_runs_the_leaf_hierarchy_without_wait_markers() -> None: ), "GitHub Actions lint must run after the Rust leaves" +@pytest.mark.parametrize("target", ["lint-clippy", "rust-lint", "lint"]) +def test_included_override_fragments_apply_exactly_once( + tmp_path: Path, target: str +) -> None: + """A caller fragment reached through `include` must not be applied twice. + + GNU Make records `-f` inputs and files reached through `include` in the same + `MAKEFILE_LIST`, with nothing to tell them apart. Forwarding that list to a + sub-make therefore re-reads an included fragment once per recursion level, + so a non-idempotent `CARGO_FLAGS += ...` compounds: `--fragment-marker` + appeared 3 times at `lint` and twice at `rust-lint` before this regression + test. Sequencing the leaves inside one Make process removes the sub-make + and the second read with it. + """ + fragment = tmp_path / "common.mk" + fragment.write_text("CLIPPY_FLAGS += --fragment-marker\n", encoding="utf-8") + override = tmp_path / "override.mk" + override.write_text(f"include {fragment}\n", encoding="utf-8") + + # The repository Makefile must come first: a bare `-f override.mk` replaces + # the implicit default rather than augmenting it, leaving `lint` undefined. + output = _dry_run(target=target, makefiles=(repo_root() / "Makefile", override)) + + assert output.count("--fragment-marker") == 1, ( + f"{target} must apply a caller's included fragment exactly once" + ) + + def test_whitaker_failure_propagates_through_make(tmp_path: Path) -> None: """A failing suite executable must fail the leaf target unchanged by a guard.""" failing_whitaker = tmp_path / "failing-whitaker" From b3e9d05a6a2b3d3134b83899ff58d23ee513aaea Mon Sep 17 00:00:00 2001 From: leynos Date: Tue, 22 Sep 2026 12:20:41 +0200 Subject: [PATCH 6/6] Pin the Whitaker flag chain and check it against Cargo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three review findings, all verified against the live Makefile first. The lint-order tests anchored on `probe-cargo --config ../tools/dev-fast/config.toml clippy`, which is the Linux spelling only. `RUST_DEBUG_CARGO` prepends that toolchain and fragment on Linux and nothing at all elsewhere, so `index()` raised `ValueError` on macOS and Windows even though the leaf order under test was unaffected. Both sites now locate the platform-neutral `clippy --all-targets` leaf, which a probe confirms appears exactly once on either route. The dev-fast contract gained `test_leaf_order_is_identical_on_both_cargo_routes` so the order is asserted on both selectors rather than only the Linux one. The contract tests duplicated the workspace members as a literal, so the Makefile's list and the test's list could drift together and both look correct. `_workspace_packages` now reads `rust/Cargo.toml` with tomllib, which makes the every-package promise checkable: adding a fourth member fails five assertions in this module. `WHITAKER_CARGO_FLAGS` is the variable the recipe actually expands, and it was already `override`n, but nothing tested it — a caller could replace the whole audited flag list without touching either protected intermediate. `test_caller_cargo_flag_variable_cannot_reach_whitaker` now covers that, the way the package-list and derived-flags tests cover theirs. Each new test was mutation-tested: un-overriding either variable, or adding a workspace member, fails it. The developer guide's scope paragraph named only `WHITAKER_PACKAGES`; it now records the full `override` chain and the manifest-backed check. Co-Authored-By: Claude Code --- cuprum/unittests/test_dev_fast_contract.py | 30 ++++- .../unittests/test_whitaker_make_contract.py | 108 ++++++++++++++---- docs/developers-guide.md | 12 +- 3 files changed, 126 insertions(+), 24 deletions(-) diff --git a/cuprum/unittests/test_dev_fast_contract.py b/cuprum/unittests/test_dev_fast_contract.py index 6b8041b19..dc6c1cbf3 100644 --- a/cuprum/unittests/test_dev_fast_contract.py +++ b/cuprum/unittests/test_dev_fast_contract.py @@ -22,6 +22,12 @@ FRAGMENT_SHA256 = "8619efda5ea1c3232f413ae96ff56869ab6b2b7cd5bdef5a001ad16ebeac23a5" FRAGMENT = "tools/dev-fast/config.toml" RUST_MEMBERS = ("cuprum-rust", "cuprum-streams", "cuprum-native-io") +# The Clippy leaf's own arguments are identical on every host; only the Cargo +# route in front of them varies. `RUST_DEBUG_CARGO` prepends the dev-fast +# toolchain and its `--config` fragment on Linux and nothing at all elsewhere, +# so anchoring an order assertion on that fragment raises ValueError on macOS +# and Windows even though the leaf order under test is unchanged. +_CLIPPY_LEAF = " clippy --all-targets" SAFE_MATURIN_FLAGS = st.sampled_from(( "--release", "-r", @@ -324,7 +330,7 @@ def test_portable_lint_orchestration_preserves_leaf_order() -> None: "`-f` inputs from included files" ) python_lint = output.index("python-lint") - clippy = output.index("probe-cargo --config ../tools/dev-fast/config.toml clippy") + clippy = output.index(_CLIPPY_LEAF) whitaker = output.index("whitaker --all --") spelling = output.index("typos-config-builder gate --repository . --scope all") workflow_lint = output.index("yamllint --strict --config-file") @@ -334,6 +340,28 @@ def test_portable_lint_orchestration_preserves_leaf_order() -> None: ) +def test_leaf_order_is_identical_on_both_cargo_routes() -> None: + """The serialized order must not depend on which Cargo route a host selects. + + The leaf order is a property of the prerequisite graph, not of the toolchain + in front of it. Asserting it on the Linux route alone would let a change + that reorders only the stable route pass unnoticed, so both selectors are + checked here. + """ + for selector in ("yes", ""): + output = _dry_run("lint", variables={"DEV_FAST_HOST_IS_LINUX": selector}) + python_lint = output.index("python-lint") + clippy = output.index(_CLIPPY_LEAF) + whitaker = output.index("whitaker --all --") + spelling = output.index("typos-config-builder gate --repository . --scope all") + workflow_lint = output.index("yamllint --strict --config-file") + + assert python_lint < clippy < whitaker < spelling < workflow_lint, ( + f"lint must serialize its leaves identically with " + f"DEV_FAST_HOST_IS_LINUX={selector!r}" + ) + + def test_msrv_verification_keeps_the_stable_route_fragment_free() -> None: """The accelerated lint compiler cannot replace published-MSRV verification.""" output = _dry_run("msrv-check") diff --git a/cuprum/unittests/test_whitaker_make_contract.py b/cuprum/unittests/test_whitaker_make_contract.py index ad2549c9b..70720a1b7 100644 --- a/cuprum/unittests/test_whitaker_make_contract.py +++ b/cuprum/unittests/test_whitaker_make_contract.py @@ -6,6 +6,7 @@ import shutil import subprocess # ruff: ignore[suspicious-subprocess-import] - controlled Make argv. import sys +import tomllib import typing as typ import pytest @@ -17,7 +18,52 @@ from pathlib import Path -_PACKAGES = ("cuprum-rust", "cuprum-streams", "cuprum-native-io") +# The Clippy leaf's own arguments are the same on every host; the Cargo route in +# front of them is not. `RUST_DEBUG_CARGO` prepends the dev-fast toolchain and +# its `--config` fragment only on Linux, so anchoring an order assertion on that +# fragment raises ValueError wherever the suite runs on macOS or Windows. +_CLIPPY_LEAF = " clippy --all-targets" + + +def _workspace_packages() -> tuple[str, ...]: + """Return the Rust workspace members from the Cargo manifest itself. + + Reading `rust/Cargo.toml` instead of repeating its names here is what makes + the every-package promise checkable. A member added to the manifest changes + this tuple, so the Makefile's own list can no longer drift out of step with + the workspace while a duplicated literal keeps the contract tests passing. + + Returns + ------- + tuple[str, ...] + The workspace member names, in manifest order. + """ + manifest = tomllib.loads( + (repo_root() / "rust" / "Cargo.toml").read_text(encoding="utf-8") + ) + workspace = manifest["workspace"] + assert isinstance(workspace, dict), "rust/Cargo.toml must declare a workspace" + members = workspace["members"] + assert isinstance(members, list), "the workspace must declare its members" + assert all(isinstance(member, str) for member in members), ( + "every workspace member must be a package name" + ) + return tuple(members) + + +def _expected_cargo_arguments() -> list[str]: + """Return the Cargo arguments every audited Whitaker binding must forward.""" + return [ + *[ + argument + for package in _workspace_packages() + for argument in ("--package", package) + ], + "--all-targets", + "--all-features", + "--jobs", + "1", + ] def _make_executable() -> str: @@ -69,13 +115,10 @@ def test_whitaker_target_covers_the_exact_workspace_packages() -> None: assert wrapper.endswith("probe-whitaker --all"), ( "Whitaker must load the full lint suite before forwarding Cargo flags" ) - assert cargo_arguments.split() == [ - *[argument for package in _PACKAGES for argument in ("--package", package)], - "--all-targets", - "--all-features", - "--jobs", - "1", - ], "the binding must enumerate every Rust workspace package exactly once" + assert cargo_arguments.split() == _expected_cargo_arguments(), ( + "the binding must enumerate every Rust workspace package exactly once, " + "matching the members rust/Cargo.toml declares" + ) def test_whitaker_package_scope_cannot_be_caller_overridden() -> None: @@ -83,9 +126,9 @@ def test_whitaker_package_scope_cannot_be_caller_overridden() -> None: command = _whitaker_command(_dry_run(variables={"WHITAKER_PACKAGES": "other"})) assert "--package other" not in command, "caller scope must not reach Whitaker" - assert all(f"--package {package}" in command for package in _PACKAGES), ( - "every audited package must remain in the binding" - ) + assert all( + f"--package {package}" in command for package in _workspace_packages() + ), "every audited package must remain in the binding" def test_whitaker_target_is_direct_and_fragment_free() -> None: @@ -117,13 +160,9 @@ def test_caller_cargo_flags_cannot_reach_whitaker() -> None: ) assert "--config" not in command, "caller Cargo flags must not reach Whitaker" - assert command.split(" -- ", 1)[1].split() == [ - *[argument for package in _PACKAGES for argument in ("--package", package)], - "--all-targets", - "--all-features", - "--jobs", - "1", - ], "the audited flag list must be the whole of what Whitaker receives" + assert command.split(" -- ", 1)[1].split() == _expected_cargo_arguments(), ( + "the audited flag list must be the whole of what Whitaker receives" + ) def test_caller_package_flags_cannot_displace_audited_packages() -> None: @@ -140,15 +179,44 @@ def test_caller_package_flags_cannot_displace_audited_packages() -> None: assert "--package evil" not in command, ( "caller-derived package flags must not displace audited packages" ) - assert all(f"--package {package}" in command for package in _PACKAGES), ( + assert command.split(" -- ", 1)[1].split() == _expected_cargo_arguments(), ( "every audited package must survive a caller-supplied flag override" ) +def test_caller_cargo_flag_variable_cannot_reach_whitaker() -> None: + """`WHITAKER_CARGO_FLAGS` itself is Makefile-owned, not merely its inputs. + + The list and the flags derived from it are both `override`n, but the final + variable the recipe actually expands is a separate name. Without an + `override` there too, a caller could replace the whole audited flag list in + one word and never touch either protected intermediate. + """ + command = _whitaker_command( + _dry_run( + variables={ + "WHITAKER_CARGO_FLAGS": ( + "--package evil --config ../tools/dev-fast/config.toml" + ) + } + ) + ) + + assert "--package evil" not in command, ( + "a caller must not replace the audited Whitaker flag list wholesale" + ) + assert "--config" not in command, ( + "a replacement flag list must not smuggle in the development fragment" + ) + assert command.split(" -- ", 1)[1].split() == _expected_cargo_arguments(), ( + "the Makefile must own the final Whitaker flag variable end to end" + ) + + def test_rust_lint_runs_clippy_whitaker_and_spelling_in_order() -> None: """The aggregate Rust gate preserves the intended sequential leaf order.""" output = _dry_run(target="rust-lint") - clippy = output.index("probe-cargo --config ../tools/dev-fast/config.toml clippy") + clippy = output.index(_CLIPPY_LEAF) whitaker = output.index("probe-whitaker --all --") spelling = output.index("typos-config-builder gate --repository . --scope all") diff --git a/docs/developers-guide.md b/docs/developers-guide.md index 16b9bd0c0..ea3e3112a 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -409,9 +409,15 @@ gap and silently fell back to a source build of each publish only the newest commit's lint archives remain, so a `` names an immutable artefact but not a permanently addressable one. -The local binding is fragment-free, and it is fail-closed about scope: a -caller-supplied `WHITAKER_PACKAGES` cannot narrow the audited package set. It -cannot by itself guarantee, however, that the gate consumed a prebuilt, +The local binding is fragment-free, and it is fail-closed about scope. The +package list, the `--package` flags derived from it, and the final +`WHITAKER_CARGO_FLAGS` the recipe expands are all `override`n, so no +caller-supplied variable can narrow the audited package set or replace the flag +list wholesale. The Makefile's list is checked against the workspace itself: +`cuprum/unittests/test_whitaker_make_contract.py` reads the members from +`rust/Cargo.toml` rather than repeating their names, so adding a member without +extending the list fails the contract instead of silently skipping it. The +binding cannot by itself guarantee, however, that the gate consumed a prebuilt, commit-addressed suite. Three upstream contracts are outstanding, and none argues against the rolling model. [Whitaker issue 403](https://github.com/leynos/whitaker/issues/403) covers the