diff --git a/.config/nextest.toml b/.config/nextest.toml index 845939141..c68dca64f 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -14,10 +14,11 @@ default-filter = "not (binary(behaviour_toolchain) | binary(behaviour_cli) | kin # cancels the run and discards the log that would have named the test. slow-timeout = { period = "300s", terminate-after = 1, grace-period = "5s" } # The whole run's budget. It sits above the largest per-test allowance below, -# so a test may spend its budget in full. It also leaves fifteen minutes beyond -# the prior whole-run timeout, which stopped a nearly complete suite. +# so a test may spend its budget in full. The previous 75-minute run reached +# 1,840 of 1,842 tests before the nested SARIF API contract was still compiling. +# Ten more minutes give that remaining trybuild work room to finish. # See "Test timeouts: four tiers, outermost last" in the developers' guide. -global-timeout = "60m" +global-timeout = "85m" # CI profile — run the full suite including slow installer tests. [profile.ci] @@ -31,7 +32,7 @@ default-filter = "not kind(example)" # budgets that govern CI belong where a reader of this profile finds # them. slow-timeout = { period = "300s", terminate-after = 1, grace-period = "5s" } -global-timeout = "60m" +global-timeout = "85m" [[profile.ci.overrides]] # The toolchain auto-install behavioural tests may download and install a @@ -42,33 +43,40 @@ test-group = "serial-toolchain-installs" [[profile.ci.overrides]] # Dylint UI harnesses build lint libraries; the default profile gives them -# the same allowance below. `trybuild` compile-contract binaries belong -# here too: they invoke a nested cargo build, so they cost what a build -# costs rather than what a test costs. `whitaker-installer`'s binary is +# the same allowance below. Compile-contract tests use the dedicated +# 45-minute override below. +# `whitaker-installer`'s binary is # named `ui` but its test is not, so the `(binary(ui) & test(=ui))` # clause does not reach it; it measured 235 s and timed out at 300 s on # the Windows lane under the base allowance. A full workspace run also -# reached 9m56 in the trybuild harness while compiler admission queued. # Keep that nested build bounded while leaving room to finish under contention. -filter = "test(driver::ui::) | test(tests::ui::) | test(ui::ui) | test(sha2_0_11_pre_migration_patterns_fail_to_compile) | test(trybuild_fixtures_compile_without_diagnostics) | (binary(ui) & test(=ui))" +filter = "test(driver::ui::) | test(tests::ui::) | test(ui::ui) | (binary(ui) & test(=ui))" slow-timeout = { period = "20m", terminate-after = 1, grace-period = "5s" } test-group = "serial-dylint-ui" [[profile.ci.overrides]] -# The macro crate's trybuild contract builds fixture crates too, so it needs a -# build-sized timeout and the same serial target-directory protection. -filter = "test(fixture_expansion_lint_attribute_contract)" -slow-timeout = { period = "20m", terminate-after = 1, grace-period = "5s" } +# Every discovered trybuild test builds fixtures through nested Cargo. Keep +# them under one build-sized allowance and avoid retries that repeat the work. +filter = "test(dependency_check_result_contract_is_public) | test(fixture_expansion_lint_attribute_contract) | test(normalize_settings_is_public_and_the_legacy_name_stays_removed) | test(renamed_common_apis_are_public_and_legacy_names_stay_removed) | test(renamed_sarif_api_is_public_and_legacy_names_stay_removed) | test(sha2_0_11_pre_migration_patterns_fail_to_compile) | test(trybuild_fixtures_compile_without_diagnostics)" +slow-timeout = { period = "45m", terminate-after = 1, grace-period = "5s" } test-group = "serial-dylint-ui" +retries = 0 [[profile.ci.overrides]] -# The parser-pin cases run nested Cargo checks in isolated workspaces and need -# the longer allowance while compiler admission queues. +# The parser-pin fixtures run nested Cargo builds with isolated targets. Their +# longer allowance reflects a full interval spent queued for compiler slots. filter = "binary(build_script_integration)" -slow-timeout = { period = "30m", terminate-after = 1, grace-period = "5s" } -test-group = "serial-build-script-integration" +slow-timeout = { period = "45m", terminate-after = 1, grace-period = "5s" } +test-group = "serial-nested-cargo-builds" + +[[profile.ci.overrides]] +# This feature-disabled build also uses a fresh target directory; allow it to +# finish after nested Cargo waits for shared compiler admission. +filter = "binary(no_default_features_build)" +slow-timeout = { period = "20m", terminate-after = 1, grace-period = "5s" } +test-group = "serial-nested-cargo-builds" -# Serialise dylint UI tests that build lint libraries and use +# Serialize dylint UI tests that build lint libraries and use # `#[serial]`. nextest runs each test in its own process, so the # `serial_test` crate's in-process mutex has no effect. A test-group # with max-threads = 1 prevents concurrent lint-library builds from @@ -76,7 +84,7 @@ test-group = "serial-build-script-integration" [test-groups] serial-dylint-ui = { max-threads = 1 } serial-toolchain-installs = { max-threads = 1 } -serial-build-script-integration = { max-threads = 1 } +serial-nested-cargo-builds = { max-threads = 1 } [[profile.default.overrides]] # Include crate-local UI entrypoints that wrap dylint UI fixtures. @@ -110,29 +118,35 @@ serial-build-script-integration = { max-threads = 1 } # dev-dependency proc-macro hangs during cargo's dev-dependency resolution on # Windows CI regardless of whether any example source uses rstest, so all # Dylint example compilation for that crate is compiled away on Windows. -# The trybuild harness reached 9m56 in a full run while compiler admission -# queued, so allow 20 minutes for the complete serial UI group. -filter = "test(driver::ui::) | test(tests::ui::) | test(ui::ui) | test(sha2_0_11_pre_migration_patterns_fail_to_compile) | test(example_compiles_without_diagnostics) | test(example_harness_collects_call_site_evidence) | test(trybuild_fixtures_compile_without_diagnostics) | test(ui::example_compiles_under_test_harness) | test(ui::hand_written_test_companion_does_not_exempt_parent_function) | test(ui::rstest_unwrap_outside_tests_still_fails_in_non_harness_code) | test(ui::rstest_empty_companion_does_not_exempt_parent_function) | test(ui::aliased_test_crate_non_companion_does_not_exempt_parent_function) | (binary(ui) & test(=ui))" +# The installer UI case measured 235 s and timed out at the 300 s base +# allowance on Windows. Allow nested UI builds to finish while keeping a cap. +filter = "test(driver::ui::) | test(tests::ui::) | test(ui::ui) | test(example_compiles_without_diagnostics) | test(example_harness_collects_call_site_evidence) | test(ui::example_compiles_under_test_harness) | test(ui::hand_written_test_companion_does_not_exempt_parent_function) | test(ui::rstest_unwrap_outside_tests_still_fails_in_non_harness_code) | test(ui::rstest_empty_companion_does_not_exempt_parent_function) | test(ui::aliased_test_crate_non_companion_does_not_exempt_parent_function) | (binary(ui) & test(=ui))" test-group = "serial-dylint-ui" retries = { backoff = "exponential", count = 2, delay = "5s" } slow-timeout = { period = "20m", terminate-after = 1, grace-period = "5s" } [[profile.default.overrides]] -# The macro crate's trybuild contract builds fixture crates too, so it needs a -# build-sized timeout and the same serial target-directory protection. -filter = "test(fixture_expansion_lint_attribute_contract)" -slow-timeout = { period = "20m", terminate-after = 1, grace-period = "5s" } +# Every discovered trybuild test builds fixtures through nested Cargo. Keep +# them under one build-sized allowance and avoid retries that repeat the work. +filter = "test(dependency_check_result_contract_is_public) | test(fixture_expansion_lint_attribute_contract) | test(normalize_settings_is_public_and_the_legacy_name_stays_removed) | test(renamed_common_apis_are_public_and_legacy_names_stay_removed) | test(renamed_sarif_api_is_public_and_legacy_names_stay_removed) | test(sha2_0_11_pre_migration_patterns_fail_to_compile) | test(trybuild_fixtures_compile_without_diagnostics)" +slow-timeout = { period = "45m", terminate-after = 1, grace-period = "5s" } test-group = "serial-dylint-ui" +retries = 0 [[profile.default.overrides]] -# These parser-pin integration cases run nested Cargo checks in isolated -# workspaces; a cold build may queue behind other admitted compiler work. +# These parser-pin and no-default-feature cases run nested Cargo builds in +# isolated targets; a cold build may queue behind admitted compiler work. filter = "binary(build_script_integration)" -slow-timeout = { period = "30m", terminate-after = 1, grace-period = "5s" } -test-group = "serial-build-script-integration" +slow-timeout = { period = "45m", terminate-after = 1, grace-period = "5s" } +test-group = "serial-nested-cargo-builds" + +[[profile.default.overrides]] +filter = "binary(no_default_features_build)" +slow-timeout = { period = "20m", terminate-after = 1, grace-period = "5s" } +test-group = "serial-nested-cargo-builds" [[profile.default.overrides]] -# Serialise ignored exclusion integration tests when they are explicitly run. +# Serialize ignored exclusion integration tests when they are explicitly run. # They build and stage the lint library before invoking `cargo dylint`, so they # share the same target-directory race risk as the UI harnesses above. filter = "binary(integration_exclusion)" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dda46d99a..13ee5788f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -69,11 +69,11 @@ jobs: coverage-check: if: github.event_name == 'pull_request' runs-on: ubicloud-standard-2-ubuntu-2404 - # Above the 60 m nextest global timeout plus a cold build and the work + # Above the 85 m nextest global timeout plus a cold build and the work # either side of the suite. A ceiling merely above the suite's budget # cancels the job before nextest can report the overrun, and a # cancellation discards the log that would have explained it. - timeout-minutes: 100 + timeout-minutes: 120 defaults: run: shell: bash @@ -505,7 +505,8 @@ jobs: # when the `ci` profile is selected, so the named toolchain and UI # tests were bounded; what nothing bounded was every other test, the # `ci` profile having declared no base `slow-timeout` of its own. - timeout-minutes: 100 + # The 120 m job ceiling includes the 85 m suite budget and cold setup. + timeout-minutes: 120 env: # `windows-latest` is a 4 vCPU GitHub-hosted shape, so it does not share # the Ubicloud constant used by the Linux lanes. diff --git a/.github/workflows/coverage-main.yml b/.github/workflows/coverage-main.yml index 2c1ef5c53..305c2403d 100644 --- a/.github/workflows/coverage-main.yml +++ b/.github/workflows/coverage-main.yml @@ -74,9 +74,9 @@ jobs: # admits `main` alone; the codescene environment contract holds it to # this job. environment: codescene - # Above the 60 m nextest global timeout plus a cold build and the work + # Above the 85 m nextest global timeout plus a cold build and the work # either side of the suite; kept in step with ci.yml's coverage lane. - timeout-minutes: 100 + timeout-minutes: 120 defaults: run: shell: bash diff --git a/.gitignore b/.gitignore index f21851e33..79549a118 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,6 @@ htmlcov/ *.swp *.swo *~ + +# Session scratch produced by local agent tooling. +.memories/ diff --git a/.markdownlint-cli2.jsonc b/.markdownlint-cli2.jsonc index 9b9f99aa1..936350783 100644 --- a/.markdownlint-cli2.jsonc +++ b/.markdownlint-cli2.jsonc @@ -18,6 +18,7 @@ ".vtcode/**", ".terraform/**", ".uv-cache/**", + ".memories/**", "memories/**", "CRUSH.md" ] diff --git a/Cargo.lock b/Cargo.lock index ad798b4b8..8e45e2244 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -196,6 +196,7 @@ dependencies = [ "serde", "tempfile", "toml 1.1.7+spec-1.1.0", + "trybuild", "whitaker", "whitaker-common", "whitaker_test_macros", @@ -1724,6 +1725,7 @@ dependencies = [ "tempfile", "whitaker", "whitaker-common", + "whitaker_test_macros", ] [[package]] @@ -1817,6 +1819,7 @@ dependencies = [ "toml 1.1.7+spec-1.1.0", "whitaker", "whitaker-common", + "whitaker_test_macros", ] [[package]] @@ -1843,6 +1846,15 @@ dependencies = [ "whitaker_test_macros", ] +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -2804,6 +2816,15 @@ dependencies = [ "digest", ] +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "shlex" version = "2.0.1" @@ -3007,6 +3028,7 @@ dependencies = [ "serde", "whitaker", "whitaker-common", + "whitaker_test_macros", ] [[package]] @@ -3079,6 +3101,15 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + [[package]] name = "tinystr" version = "0.8.3" @@ -3201,6 +3232,32 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "nu-ansi-term", + "sharded-slab", + "smallvec", + "thread_local", + "tracing-core", + "tracing-log", ] [[package]] @@ -3422,6 +3479,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + [[package]] name = "value-bag" version = "1.13.0" @@ -3554,6 +3617,7 @@ dependencies = [ "toml 1.1.7+spec-1.1.0", "whitaker-common", "whitaker-installer", + "whitaker_test_macros", ] [[package]] @@ -3575,6 +3639,7 @@ dependencies = [ "rstest-bdd-macros", "tempfile", "thiserror 2.0.21", + "trybuild", "unic-langid", "whitaker_test_macros", ] @@ -3593,6 +3658,7 @@ dependencies = [ "log", "mockall", "parking_lot", + "proptest", "rstest", "rstest-bdd", "rstest-bdd-macros", @@ -3605,6 +3671,7 @@ dependencies = [ "thiserror 2.0.21", "toml 1.1.7+spec-1.1.0", "tracing", + "tracing-subscriber", "trybuild", "ureq", "wait-timeout", @@ -3645,6 +3712,7 @@ name = "whitaker_sarif" version = "0.2.9" dependencies = [ "camino", + "proptest", "rstest", "rstest-bdd", "rstest-bdd-macros", @@ -3652,6 +3720,7 @@ dependencies = [ "serde_json", "tempfile", "thiserror 2.0.21", + "trybuild", "whitaker_test_macros", ] diff --git a/Cargo.toml b/Cargo.toml index 1e79d94ef..fb4009ae9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,6 +39,7 @@ toml = "1.1.2" metrics = "0.24.2" metrics-util = "0.20.1" tracing = { version = "0.1.44", default-features = false, features = ["attributes", "std"] } +tracing-subscriber = "0.3.20" thiserror = "2" trybuild = "1.0.116" log = "0.4.28" @@ -117,6 +118,7 @@ rstest = { workspace = true } rstest-bdd = { workspace = true } rstest-bdd-macros = { workspace = true } dylint_testing = { workspace = true } +whitaker_test_macros = { workspace = true } [lints.clippy] pedantic = { level = "warn", priority = -1 } diff --git a/Makefile b/Makefile index 031c634d3..b6903292f 100644 --- a/Makefile +++ b/Makefile @@ -49,6 +49,10 @@ COVERAGE_OUTPUT ?= lcov.info # exact directory. cargo-llvm-cov otherwise passes its target only as a # Nextest argument, which Dylint's nested Cargo build cannot observe. COVERAGE_TARGET_DIR ?= $(CURDIR)/target/llvm-cov-target +# Nextest runs each test case in a separate process. Keep one online-merged +# profile per instrumented binary instead of one raw profile per process, as +# cargo-llvm-cov's default `%p` pattern does. +COVERAGE_PROFILE_FILE_NAME ?= whitaker-%m.profraw RUST_FLAGS ?= -D warnings # The installer MSRV check and the publish check each build in a scratch # tree they discard afterwards. The trees live at fixed paths rather than @@ -80,6 +84,8 @@ SCRATCH_ROOT_CHECK = mkdir -p -m 700 "$(SCRATCH_ROOT)"; \ exit 1; \ fi RUSTDOC_FLAGS ?= --cfg docsrs -D warnings +# `markdownlint` passes MDLINT to xargs as one command word, so overrides must +# name a single executable path rather than include command-line arguments. MDLINT ?= $(shell command -v markdownlint-cli2 2>/dev/null || printf '%s' "$$HOME/.bun/bin/markdownlint-cli2") # `make fmt` and `make check-fmt` call mdtablefix directly. `--git` selects the # Markdown files Git tracks and `--include-untracked` adds the untracked files @@ -147,7 +153,7 @@ WHITAKER_SCRIPT ?= $(HOME)/.local/bin/whitaker MD_FILES_FIND = find . \ \( -type d \( -name .git -o -name target -o -name .venv -o \ -name .uv-cache -o -name .uv-tools -o -name .pytest_cache -o \ - -name .vtcode -o -name memories -o -name node_modules \) -prune \) -o \ + -name .vtcode -o -name memories -o -name .memories -o -name node_modules \) -prune \) -o \ \( -type f -name '*.md' -print0 \) build: target/debug/$(APP) ## Build debug binary @@ -217,9 +223,13 @@ coverage: ## Generate LCOV coverage over the CI-tested crate subset @# three lint crates whose dylint UI tests are excluded) stay excluded, @# so coverage never attempts a bare `--workspace` build the suite @# cannot support. + @# LLVM coverage maps carry source locations without DWARF debug info. + @# Omit optional symbols from this throwaway target to limit runner disk use. @export PATH="$$PATH:$(TOOL_PATH_SUFFIX)"; command -v cargo-llvm-cov >/dev/null || { echo "Install cargo-llvm-cov (cargo install cargo-llvm-cov)"; exit 1; } @CARGO_LLVM_COV_TARGET_DIR="$(COVERAGE_TARGET_DIR)" \ CARGO_TARGET_DIR="$(COVERAGE_TARGET_DIR)" \ + LLVM_PROFILE_FILE_NAME="$(COVERAGE_PROFILE_FILE_NAME)" \ + CARGO_PROFILE_DEV_DEBUG=0 CARGO_PROFILE_TEST_DEBUG=0 \ $(MAKE) test TEST_RUNNER="llvm-cov nextest --lcov --output-path $(COVERAGE_OUTPUT)" test-doc: ## Run the workspace doctests, which no other lane executes @@ -321,7 +331,7 @@ check-fmt: ## Verify formatting $(MDTABLEFIX) --check $(MDTABLEFIX_SELECT) $(MDTABLEFIX_RULES) markdownlint: spelling ## Lint Markdown files and enforce spelling - export PATH="$$PATH:$(TOOL_PATH_SUFFIX)"; $(MDLINT) '**/*.md' '!**/.uv-cache/**' '!**/.uv-tools/**' + export PATH="$$PATH:$(TOOL_PATH_SUFFIX)"; $(MD_FILES_FIND) | xargs -0 -r "$(MDLINT)" spelling: ## Enforce en-GB-oxendict in tracked text $(TYPOS_CONFIG_BUILDER) gate --repository . --scope all diff --git a/common/Cargo.toml b/common/Cargo.toml index 494c635b5..9fb097f83 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -26,6 +26,7 @@ unic-langid = { workspace = true } [dev-dependencies] cap-std = { workspace = true } whitaker_test_macros = { workspace = true } +trybuild = { workspace = true } # Testing: Behavioural specs use rstest-bdd 0.5.x (workspace pin). Capture any # regressions with `make test` and coordinate with the maintainers per # `docs/rstest-bdd-users-guide.md` before gating behaviour tests or backporting diff --git a/common/locales/cy/common.ftl b/common/locales/cy/common.ftl index d5f35a3f0..4f996c64d 100644 --- a/common/locales/cy/common.ftl +++ b/common/locales/cy/common.ftl @@ -6,7 +6,7 @@ -term-branch = cangen -term-test-coverage = cwmpas profion -# Borrowed English nouns typically pluralise with -iau (Modern Welsh, Gareth +# Borrowed English nouns typically pluralize with -iau (Modern Welsh, Gareth # King §2.8), so we render "lint" as "lintiau" in aggregated messaging. common-lint-count = { $lint -> diff --git a/common/locales/en-GB/no_expect_outside_tests.ftl b/common/locales/en-GB/no_expect_outside_tests.ftl index 29ef45aa1..3d478e5f8 100644 --- a/common/locales/en-GB/no_expect_outside_tests.ftl +++ b/common/locales/en-GB/no_expect_outside_tests.ftl @@ -1,7 +1,7 @@ ## Restrict expect calls outside test contexts. no_expect_outside_tests = Avoid calling expect on { $receiver } outside test-only code. - .note = The call originates within { $context } which is not recognised as a test. + .note = The call originates within { $context } which is not recognized as a test. .help = { $handling -> [option] Handle the `None` variant of { $receiver } or move the code into a test. [result] Handle the `Err` variant of { $receiver } or move the code into a test. diff --git a/common/src/brain_trait_metrics/tests.rs b/common/src/brain_trait_metrics/tests.rs index e5e03328c..697147801 100644 --- a/common/src/brain_trait_metrics/tests.rs +++ b/common/src/brain_trait_metrics/tests.rs @@ -91,12 +91,13 @@ fn default_method_count_excludes_required_and_associated_items() { assert_eq!(default_method_count(&mixed_items()), 1); } +/// Verify that the complexity sum includes only default methods. #[rstest] fn default_method_cc_sum_aggregates_only_default_methods() { let items = vec![ TraitItemMetrics::required_method("parse"), TraitItemMetrics::default_method("render", 12), - TraitItemMetrics::default_method("serialise", 8), + TraitItemMetrics::default_method("serialize", 8), TraitItemMetrics::associated_type("Output"), ]; @@ -186,12 +187,13 @@ fn builder_filters_macro_expanded_default_methods() { ); } +/// Check that implementor burden equals the number of required methods. #[rstest] fn implementor_burden_equals_required_method_count() { let mut builder = TraitMetricsBuilder::new("Transformer"); builder.add_required_method("parse"); builder.add_required_method("validate"); - builder.add_default_method("normalise", 7, false); + builder.add_default_method("normalize", 7, false); builder.add_associated_type("Output"); let metrics = builder.build(); diff --git a/common/src/complexity_signal.rs b/common/src/complexity_signal.rs index d71e903c6..29d6caffd 100644 --- a/common/src/complexity_signal.rs +++ b/common/src/complexity_signal.rs @@ -225,15 +225,6 @@ pub fn rasterize_signal( Ok(accumulate_signal_from_diff(diff.as_slice(), len)) } -#[deprecated(note = "Use rasterize_signal instead.")] -#[must_use = "Inspect the signal build result to handle invalid ranges"] -pub fn rasterise_signal( - function_lines: RangeInclusive, - segments: &[LineSegment], -) -> Result, SignalBuildError> { - rasterize_signal(function_lines, segments) -} - /// Errors emitted when smoothing a signal. #[derive(Clone, Copy, Debug, Error, PartialEq, Eq)] pub enum SmoothingError { diff --git a/common/src/i18n/mod.rs b/common/src/i18n/mod.rs index 383a426c2..300db0588 100644 --- a/common/src/i18n/mod.rs +++ b/common/src/i18n/mod.rs @@ -78,7 +78,7 @@ pub use helpers::{ }; pub use loader::{Arguments, I18nError, Localizer}; pub use locales::{available_locales, supports_locale}; -pub use selection::{LocaleSelection, LocaleSource, normalise_locale, resolve_localizer}; +pub use selection::{LocaleSelection, LocaleSource, normalize_locale, resolve_localizer}; #[cfg(test)] mod tests; diff --git a/common/src/i18n/selection.rs b/common/src/i18n/selection.rs index ce7b02434..c6a58b3e7 100644 --- a/common/src/i18n/selection.rs +++ b/common/src/i18n/selection.rs @@ -97,7 +97,7 @@ impl LocaleSelection { /// Attempt to resolve a locale candidate from the given source. fn try_resolve_candidate(source: LocaleSource, raw: Option<&str>) -> Option { - let candidate = normalise_locale(raw)?; + let candidate = normalize_locale(raw)?; if supports_locale(candidate) { return Some(LocaleSelection::new( @@ -143,7 +143,7 @@ pub fn resolve_localizer( /// Trim whitespace and discard empty locale candidates. #[must_use] -pub fn normalise_locale(input: Option<&str>) -> Option<&str> { +pub fn normalize_locale(input: Option<&str>) -> Option<&str> { input.map(str::trim).filter(|value| !value.is_empty()) } @@ -217,13 +217,14 @@ mod tests { assert_eq!(selection.used_fallback(), case.expected_fallback); } + /// Check locale normalization for empty, padded, and valid candidates. #[rstest] #[case(None, None)] #[case(Some(""), None)] #[case(Some(" "), None)] #[case(Some("cy"), Some("cy"))] #[case(Some(" cy "), Some("cy"))] - fn normalises_candidates(#[case] input: Option<&str>, #[case] expected: Option<&str>) { - assert_eq!(normalise_locale(input), expected); + fn normalizes_candidates(#[case] input: Option<&str>, #[case] expected: Option<&str>) { + assert_eq!(normalize_locale(input), expected); } } diff --git a/common/src/lib.rs b/common/src/lib.rs index bb7214a0e..bf7007b63 100644 --- a/common/src/lib.rs +++ b/common/src/lib.rs @@ -50,7 +50,7 @@ pub use expr::{Expr, def_id_of_expr_callee, is_path_to, recv_is_option_or_result pub use i18n::{ Arguments, FALLBACK_LOCALE, I18nError, LocaleSelection, LocaleSource, Localizer, MessageResolution, available_locales, branch_phrase, get_localizer_for_lint, noop_reporter, - normalise_locale, resolve_localizer, safe_resolve_message_set, supports_locale, + normalize_locale, resolve_localizer, safe_resolve_message_set, supports_locale, }; pub use lcom4::{MethodInfo, MethodInfoBuilder, cohesion_components, collect_method_infos}; pub use path::SimplePath; diff --git a/common/tests/public_api_contract.rs b/common/tests/public_api_contract.rs new file mode 100644 index 000000000..9e5ce872f --- /dev/null +++ b/common/tests/public_api_contract.rs @@ -0,0 +1,9 @@ +//! Compile-time contracts for common public APIs changed to Oxford spelling. + +/// Verify that renamed common APIs compile and their legacy names remain removed. +#[test] +fn renamed_common_apis_are_public_and_legacy_names_stay_removed() { + let cases = trybuild::TestCases::new(); + cases.pass("tests/ui/pass_renamed_common_apis.rs"); + cases.compile_fail("tests/ui/legacy_common_apis.rs"); +} diff --git a/common/tests/ui/legacy_common_apis.rs b/common/tests/ui/legacy_common_apis.rs new file mode 100644 index 000000000..6cd941d56 --- /dev/null +++ b/common/tests/ui/legacy_common_apis.rs @@ -0,0 +1,10 @@ +//! Compile-fail contract for removed British-spelled common API names. + +use whitaker_common::complexity_signal::rasterise_signal; +use whitaker_common::normalise_locale; + +/// Exercise removed common API names in the compile-fail fixture. +fn main() { + let _ = normalise_locale(Some("en-GB")); + let _ = rasterise_signal(1..=1, &[]); +} diff --git a/common/tests/ui/legacy_common_apis.stderr b/common/tests/ui/legacy_common_apis.stderr new file mode 100644 index 000000000..f285cc2ee --- /dev/null +++ b/common/tests/ui/legacy_common_apis.stderr @@ -0,0 +1,23 @@ +error[E0432]: unresolved import `whitaker_common::complexity_signal::rasterise_signal` + --> tests/ui/legacy_common_apis.rs:3:5 + | +3 | use whitaker_common::complexity_signal::rasterise_signal; + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ no `rasterise_signal` in `complexity_signal` + | +help: a similar name exists in the module + | +3 - use whitaker_common::complexity_signal::rasterise_signal; +3 + use whitaker_common::complexity_signal::rasterize_signal; + | + +error[E0432]: unresolved import `whitaker_common::normalise_locale` + --> tests/ui/legacy_common_apis.rs:4:5 + | +4 | use whitaker_common::normalise_locale; + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ no `normalise_locale` in the root + | +help: a similar name exists in the module + | +4 - use whitaker_common::normalise_locale; +4 + use whitaker_common::normalize_locale; + | diff --git a/common/tests/ui/pass_renamed_common_apis.rs b/common/tests/ui/pass_renamed_common_apis.rs new file mode 100644 index 000000000..f321fc98d --- /dev/null +++ b/common/tests/ui/pass_renamed_common_apis.rs @@ -0,0 +1,13 @@ +//! Compile contract for Oxford-spelled common public APIs. + +use whitaker_common::complexity_signal::{LineSegment, rasterize_signal}; +use whitaker_common::normalize_locale; + +/// Exercise the renamed common APIs in the compile-pass fixture. +fn main() { + let locale = normalize_locale(Some(" en-GB ")); + let segments: Vec<_> = LineSegment::new(1, 1, 1.0).into_iter().collect(); + let signal = rasterize_signal(1..=1, &segments); + assert_eq!(locale, Some("en-GB")); + assert_eq!(signal.ok(), Some(vec![1.0])); +} diff --git a/crates/bumpy_road_function/Cargo.toml b/crates/bumpy_road_function/Cargo.toml index 3e9bff461..da578544c 100644 --- a/crates/bumpy_road_function/Cargo.toml +++ b/crates/bumpy_road_function/Cargo.toml @@ -52,3 +52,4 @@ whitaker = { workspace = true } whitaker-common = { workspace = true } camino = { workspace = true } tempfile = { workspace = true } +trybuild = { workspace = true } diff --git a/crates/bumpy_road_function/src/analysis.rs b/crates/bumpy_road_function/src/analysis.rs index 72aa96d0c..d7aaabd44 100644 --- a/crates/bumpy_road_function/src/analysis.rs +++ b/crates/bumpy_road_function/src/analysis.rs @@ -69,7 +69,7 @@ impl Default for Settings { /// # Examples /// /// ``` -/// use bumpy_road_function::analysis::{normalise_settings, Settings, Weights}; +/// use bumpy_road_function::analysis::{normalize_settings, Settings, Weights}; /// /// let settings = Settings { /// threshold: -1.0, @@ -82,14 +82,15 @@ impl Default for Settings { /// ..Settings::default() /// }; /// -/// let normalised = normalise_settings(settings); -/// assert_eq!(normalised.threshold, Settings::default().threshold); -/// assert_eq!(normalised.window, Settings::default().window); -/// assert_eq!(normalised.weights, Settings::default().weights); +/// let normalized = normalize_settings(settings); +/// assert_eq!(normalized.threshold, Settings::default().threshold); +/// assert_eq!(normalized.window, Settings::default().window); +/// assert_eq!(normalized.weights, Settings::default().weights); /// ``` #[must_use] -pub fn normalise_settings(settings: Settings) -> Settings { - fn normalise_weight(candidate: f64, fallback: f64) -> f64 { +pub fn normalize_settings(settings: Settings) -> Settings { + /// Keep a finite non-negative weight; otherwise use its default. + fn normalize_weight(candidate: f64, fallback: f64) -> f64 { if candidate.is_finite() && candidate >= 0.0 { candidate } else { @@ -116,9 +117,9 @@ pub fn normalise_settings(settings: Settings) -> Settings { let min_bump_lines = settings.min_bump_lines.max(1); let weights = Weights { - depth: normalise_weight(settings.weights.depth, defaults.weights.depth), - predicate: normalise_weight(settings.weights.predicate, defaults.weights.predicate), - flow: normalise_weight(settings.weights.flow, defaults.weights.flow), + depth: normalize_weight(settings.weights.depth, defaults.weights.depth), + predicate: normalize_weight(settings.weights.predicate, defaults.weights.predicate), + flow: normalize_weight(settings.weights.flow, defaults.weights.flow), }; Settings { diff --git a/crates/bumpy_road_function/src/driver/config.rs b/crates/bumpy_road_function/src/driver/config.rs index 4381ab7a9..4388791bb 100644 --- a/crates/bumpy_road_function/src/driver/config.rs +++ b/crates/bumpy_road_function/src/driver/config.rs @@ -1,7 +1,7 @@ //! Configuration parsing and loading for the bumpy road lint. //! //! The lint reads optional configuration from `dylint.toml`, applies defaults, -//! and relies on `analysis::normalise_settings` to clamp invalid values. +//! and relies on `analysis::normalize_settings` to clamp invalid values. use crate::analysis::{Settings, Weights}; use log::debug; diff --git a/crates/bumpy_road_function/src/driver/diagnostic.rs b/crates/bumpy_road_function/src/driver/diagnostic.rs index 467e43263..b954c5c07 100644 --- a/crates/bumpy_road_function/src/driver/diagnostic.rs +++ b/crates/bumpy_road_function/src/driver/diagnostic.rs @@ -15,6 +15,7 @@ use whitaker_common::{ Arguments, Localizer, MessageResolution, noop_reporter, safe_resolve_message_set, }; +use super::numeric::{diagnostic_integer_argument, line_range_byte_offsets}; use super::{BUMPY_ROAD_FUNCTION, LINT_NAME, MESSAGE_KEY}; /// Payload describing a lint diagnostic to emit. @@ -27,21 +28,16 @@ pub(super) struct DiagnosticInput<'a> { pub(super) settings: &'a Settings, } +/// Emit the localized lint message and labels for the two most severe bumps. +/// +/// Missing source snippets omit the affected labels, and oversized numeric +/// arguments are saturated before they are passed to Fluent. pub(super) fn emit_diagnostic( cx: &LateContext<'_>, input: DiagnosticInput<'_>, localizer: &Localizer, ) { - let mut args: Arguments<'_> = Arguments::default(); - args.insert(Cow::Borrowed("name"), FluentValue::from(input.name)); - args.insert( - Cow::Borrowed("count"), - FluentValue::from(input.bumps.len() as i64), - ); - args.insert( - Cow::Borrowed("threshold"), - FluentValue::from(input.settings.threshold), - ); + let args = diagnostic_arguments(input.name, input.bumps.len(), input.settings.threshold); let resolution = MessageResolution { lint_name: LINT_NAME, @@ -66,8 +62,11 @@ pub(super) fn emit_diagnostic( let Some(span) = bump_spans.get(ordinal).copied().flatten() else { continue; }; - let label = - resolve_bump_label(localizer, (ordinal + 1) as i64, interval.len() as i64); + let label = resolve_bump_label( + localizer, + diagnostic_integer_argument(ordinal.saturating_add(1)), + diagnostic_integer_argument(interval.len()), + ); lint.span_label(span, label); } @@ -76,6 +75,18 @@ pub(super) fn emit_diagnostic( ); } +/// Build the localized arguments passed by the production diagnostic path. +fn diagnostic_arguments(name: &str, bump_count: usize, threshold: f64) -> Arguments<'_> { + let mut args: Arguments<'_> = Arguments::default(); + args.insert(Cow::Borrowed("name"), FluentValue::from(name)); + args.insert( + Cow::Borrowed("count"), + FluentValue::from(diagnostic_integer_argument(bump_count)), + ); + args.insert(Cow::Borrowed("threshold"), FluentValue::from(threshold)); + args +} + fn build_bump_spans( cx: &LateContext<'_>, body_span: Span, @@ -134,26 +145,22 @@ impl LineSpanMapper { } } + /// Map an inclusive source-line range into the snippet's source span. + /// + /// Returns `None` when the start line is outside the snippet, the range is + /// reversed, or a byte offset cannot fit the compiler's `u32` positions. fn span_for_range(&self, start_line: usize, end_line: usize) -> Option { - if start_line < self.base_line || end_line < start_line { - return None; - } - - let start_index = start_line - self.base_line; - let end_index = end_line - self.base_line; - - let start_offset = *self.line_starts.get(start_index)?; - let end_offset = self - .line_starts - .get(end_index + 1) - .copied() - .unwrap_or(self.snippet_len); - let base = self.base_span.shrink_to_lo(); - // `BytePos` is `u32`-backed; the snippet length is expected to fit in - // 4 GiB for any reasonable Rust source file. - let lo = base.lo() + BytePos(start_offset as u32); - let mut hi = base.lo() + BytePos(end_offset as u32); + // `BytePos` is `u32`-backed; offsets beyond 4 GiB cannot be represented + // as spans, so such (pathological) snippets yield no span. + let (start_offset, end_offset) = line_range_byte_offsets( + &self.line_starts, + self.snippet_len, + self.base_line, + start_line..=end_line, + )?; + let lo = base.lo() + BytePos(start_offset); + let mut hi = base.lo() + BytePos(end_offset); if hi <= lo { hi = lo + BytePos(1); } @@ -186,3 +193,35 @@ fn fallback_messages(name: &str, count: usize, threshold: f64) -> DiagnosticMess ), ) } + +#[cfg(test)] +mod tests { + //! Verify diagnostic construction and compiler span bounds. + + use super::{LineSpanMapper, diagnostic_arguments}; + use fluent_templates::fluent_bundle::FluentValue; + use rustc_span::DUMMY_SP; + + /// Verify oversized bump counts saturate in the emitted diagnostic arguments. + #[cfg(target_pointer_width = "64")] + #[test] + fn diagnostic_arguments_saturate_oversized_bump_counts() { + let oversized = usize::try_from(u64::try_from(i64::MAX).expect("i64 max fits u64") + 1) + .expect("oversized bump count fits usize"); + + let args = diagnostic_arguments("example", oversized, 0.5); + + assert_eq!(args.get("count"), Some(&FluentValue::from(i64::MAX))); + } + + /// Verify span construction omits offsets that rustc cannot represent. + #[cfg(target_pointer_width = "64")] + #[test] + fn span_mapping_omits_unrepresentable_offsets() { + let oversized = + usize::try_from(u64::from(u32::MAX) + 1).expect("oversized span offset fits usize"); + let mapper = LineSpanMapper::new(DUMMY_SP, oversized, 1, vec![0, oversized]); + + assert_eq!(mapper.span_for_range(1, 1), None); + } +} diff --git a/crates/bumpy_road_function/src/driver.rs b/crates/bumpy_road_function/src/driver/mod.rs similarity index 89% rename from crates/bumpy_road_function/src/driver.rs rename to crates/bumpy_road_function/src/driver/mod.rs index 41ab1c795..e5b1851f5 100644 --- a/crates/bumpy_road_function/src/driver.rs +++ b/crates/bumpy_road_function/src/driver/mod.rs @@ -5,7 +5,7 @@ //! more separated bumps above a configurable threshold. The warning highlights //! the two largest bump intervals with labelled spans. -use crate::analysis::{Settings, detect_bumps, normalise_settings}; +use crate::analysis::{Settings, detect_bumps, normalize_settings}; use rustc_hir as hir; use rustc_hir::ExprKind; use rustc_lint::{LateContext, LateLintPass}; @@ -22,6 +22,7 @@ const MESSAGE_KEY: MessageKey<'static> = MessageKey::new(LINT_NAME); mod config; mod diagnostic; +mod numeric; mod segment_builder; use self::config::load_configuration; @@ -51,8 +52,12 @@ impl Default for BumpyRoadFunction { } impl<'tcx> LateLintPass<'tcx> for BumpyRoadFunction { + /// Load normalized lint settings and the locale-specific message resolver. + /// + /// This runs once before HIR traversal so every analysed body uses the same + /// configuration and localization context. fn check_crate(&mut self, _cx: &LateContext<'tcx>) { - self.settings = normalise_settings(load_configuration().into_settings()); + self.settings = normalize_settings(load_configuration().into_settings()); let shared_config = SharedConfig::load(); self.localizer = get_localizer_for_lint(LINT_NAME, shared_config.locale()); } @@ -170,6 +175,11 @@ struct AnalysisTarget { body_id: hir::BodyId, } +/// Build the complexity signal for one body and report separated bumps. +/// +/// Macro-expanded bodies, bodies without source line information, and bodies +/// with fewer than two detected bumps produce no diagnostic. Rasterization or +/// smoothing failures are reported as delayed compiler bugs and stop analysis. fn analyse_body( cx: &LateContext<'_>, target: AnalysisTarget, @@ -196,7 +206,7 @@ fn analyse_body( Err(error) => { cx.tcx.sess.dcx().span_delayed_bug( body_span, - format!("bumpy-road signal rasterisation failed: {error}"), + format!("bumpy-road signal rasterization failed: {error}"), ); return; } diff --git a/crates/bumpy_road_function/src/driver/numeric.rs b/crates/bumpy_road_function/src/driver/numeric.rs new file mode 100644 index 000000000..37ae8c9e5 --- /dev/null +++ b/crates/bumpy_road_function/src/driver/numeric.rs @@ -0,0 +1,142 @@ +//! Bounds-checked numeric conversions shared by the bumpy-road diagnostics. + +use std::ops::RangeInclusive; + +/// Clamp a diagnostic number to the largest value supported by Fluent. +pub(super) fn saturating_diagnostic_integer(value: u64) -> i64 { + i64::try_from(value).unwrap_or(i64::MAX) +} + +/// Clamp a branch count to the largest value representable by segment weights. +pub(super) fn saturating_branch_count(branches: u64) -> u32 { + u32::try_from(branches).unwrap_or(u32::MAX) +} + +/// Convert a snippet offset to the compiler's `u32` range, rejecting larger +/// values so the caller can omit an unrepresentable source span. +pub(super) fn span_byte_offset(offset: u64) -> Option { + u32::try_from(offset).ok() +} + +/// Convert a production diagnostic argument to Fluent's signed integer range. +pub(super) fn diagnostic_integer_argument(value: usize) -> i64 { + saturating_diagnostic_integer(u64::try_from(value).unwrap_or(u64::MAX)) +} + +/// Compute the production predicate weight with a bounded branch count. +pub(super) fn predicate_segment_weight(branches: usize, weight: f64) -> f64 { + f64::from(saturating_branch_count( + u64::try_from(branches).unwrap_or(u64::MAX), + )) * weight +} + +/// Convert production snippet offsets to compiler byte positions. +pub(super) fn span_byte_offset_range(start: usize, end: usize) -> Option<(u32, u32)> { + let start = u64::try_from(start).ok()?; + let end = u64::try_from(end).ok()?; + Some((span_byte_offset(start)?, span_byte_offset(end)?)) +} + +/// Map an inclusive line range to checked compiler byte offsets. +pub(super) fn line_range_byte_offsets( + line_starts: &[usize], + snippet_len: usize, + base_line: usize, + lines: RangeInclusive, +) -> Option<(u32, u32)> { + let start_line = *lines.start(); + let end_line = *lines.end(); + if start_line < base_line || end_line < start_line { + return None; + } + + let start_index = start_line - base_line; + let end_index = end_line - base_line; + let start_offset = *line_starts.get(start_index)?; + let end_offset = line_starts + .get(end_index + 1) + .copied() + .unwrap_or(snippet_len); + + span_byte_offset_range(start_offset, end_offset) +} + +#[cfg(test)] +mod tests { + //! Verify conversion behaviour at diagnostic and compiler integer limits. + + use super::{ + diagnostic_integer_argument, line_range_byte_offsets, predicate_segment_weight, + saturating_branch_count, saturating_diagnostic_integer, span_byte_offset, + span_byte_offset_range, + }; + + /// Verify values beyond Fluent's signed limit clamp without wrapping. + #[test] + fn diagnostic_integers_saturate_at_the_signed_boundary() { + let maximum = i64::MAX.unsigned_abs(); + assert_eq!(saturating_diagnostic_integer(maximum - 1), i64::MAX - 1); + assert_eq!(saturating_diagnostic_integer(maximum), i64::MAX); + assert_eq!(saturating_diagnostic_integer(maximum + 1), i64::MAX); + } + + /// Verify branch counts beyond the segment-weight limit clamp to `u32::MAX`. + #[test] + fn branch_counts_saturate_at_the_unsigned_boundary() { + let maximum = u64::from(u32::MAX); + assert_eq!(saturating_branch_count(maximum - 1), u32::MAX - 1); + assert_eq!(saturating_branch_count(maximum), u32::MAX); + assert_eq!(saturating_branch_count(maximum + 1), u32::MAX); + } + + /// Verify compiler span offsets accept `u32::MAX` and reject larger values. + #[test] + fn span_offsets_reject_values_above_the_compiler_boundary() { + let maximum = u64::from(u32::MAX); + assert_eq!(span_byte_offset(maximum - 1), Some(u32::MAX - 1)); + assert_eq!(span_byte_offset(maximum), Some(u32::MAX)); + assert_eq!(span_byte_offset(maximum + 1), None); + } + + /// Verify the diagnostic call-site conversion clamps an oversized count. + #[cfg(target_pointer_width = "64")] + #[test] + fn production_diagnostic_arguments_saturate_at_the_signed_boundary() { + let oversized = usize::try_from(u64::try_from(i64::MAX).expect("i64 max fits u64") + 1) + .expect("oversized diagnostic count fits usize"); + assert_eq!(diagnostic_integer_argument(oversized), i64::MAX); + } + + /// Verify the production segment-weight conversion clamps excess branches. + #[cfg(target_pointer_width = "64")] + #[test] + fn production_predicate_weight_saturates_excess_branches() { + let oversized = + usize::try_from(u64::from(u32::MAX) + 1).expect("oversized branch count fits usize"); + assert_eq!( + predicate_segment_weight(oversized, 0.5), + f64::from(u32::MAX) * 0.5, + ); + } + + /// Verify the production span-offset conversion rejects an unrepresentable end. + #[cfg(target_pointer_width = "64")] + #[test] + fn production_span_offset_ranges_reject_an_unrepresentable_end() { + let oversized = + usize::try_from(u64::from(u32::MAX) + 1).expect("oversized span offset fits usize"); + assert_eq!(span_byte_offset_range(0, oversized), None); + } + + /// Verify the production line-to-span mapping rejects unrepresentable ranges. + #[cfg(target_pointer_width = "64")] + #[test] + fn production_line_range_mapping_rejects_an_unrepresentable_offset() { + let oversized = + usize::try_from(u64::from(u32::MAX) + 1).expect("oversized span offset fits usize"); + assert_eq!( + line_range_byte_offsets(&[0, oversized], oversized, 1, 1..=1), + None, + ); + } +} diff --git a/crates/bumpy_road_function/src/driver/segment_builder.rs b/crates/bumpy_road_function/src/driver/segment_builder.rs index 27992e200..324e17913 100644 --- a/crates/bumpy_road_function/src/driver/segment_builder.rs +++ b/crates/bumpy_road_function/src/driver/segment_builder.rs @@ -13,6 +13,42 @@ use rustc_span::source_map::SourceMap; use rustc_span::{DesugaringKind, Span}; use whitaker_common::complexity_signal::LineSegment; +use super::numeric::predicate_segment_weight; + +/// Describe a fixed or branch-scaled contribution to a line segment. +#[derive(Clone, Copy)] +enum SegmentWeight { + /// Use the supplied value unchanged. + Fixed(f64), + /// Scale by a branch count after clamping it to the supported range. + Predicate { branches: usize, per_branch: f64 }, +} + +impl SegmentWeight { + /// Convert the selected production weight to the value stored in a segment. + fn value(self) -> f64 { + match self { + Self::Fixed(value) => value, + Self::Predicate { + branches, + per_branch, + } => predicate_segment_weight(branches, per_branch), + } + } +} + +/// Construct the line segment after applying the production weight policy. +/// +/// # Errors +/// +/// Returns an error when the inclusive line range is not one-based and ordered. +fn line_segment( + lines: RangeInclusive, + weight: SegmentWeight, +) -> Result { + LineSegment::new(*lines.start(), *lines.end(), weight.value()) +} + pub(super) struct SegmentBuilder<'a, 'tcx> { cx: &'a LateContext<'tcx>, settings: &'a Settings, @@ -126,25 +162,39 @@ impl<'a, 'tcx> SegmentBuilder<'a, 'tcx> { self.visit_expr(expr); } + /// Add the configured nesting-depth weight for `span`. fn push_depth_segment(&mut self, span: Span) { - self.push_segment(span, self.settings.weights.depth); + self.push_segment(span, SegmentWeight::Fixed(self.settings.weights.depth)); } + /// Add the configured control-flow weight for `span`. fn push_flow_segment(&mut self, span: Span) { - self.push_segment(span, self.settings.weights.flow); + self.push_segment(span, SegmentWeight::Fixed(self.settings.weights.flow)); } + /// Add a predicate-weighted segment unless the condition is a `let` test. + /// + /// Branch counts above `u32::MAX` are saturated before conversion to the + /// floating-point weight; spans rejected by `push_segment` are omitted. fn push_predicate_segment(&mut self, expr: &'tcx hir::Expr<'tcx>) { if matches!(expr.kind, ExprKind::Let(..)) { return; } - let branches = count_branches(expr) as f64; - let value = branches * self.settings.weights.predicate; - self.push_segment(expr.span, value); + self.push_segment( + expr.span, + SegmentWeight::Predicate { + branches: count_branches(expr), + per_branch: self.settings.weights.predicate, + }, + ); } - fn push_segment(&mut self, span: Span, value: f64) { + /// Add a weighted segment when `span` maps to lines within this function. + /// + /// Ignore macro-expanded or unmappable spans; report an internal delayed + /// bug for invalid segments or lines outside the function range. + fn push_segment(&mut self, span: Span, weight: SegmentWeight) { // Skip spans from macro expansions; their line numbers can point to // locations outside the function (e.g., the macro definition site). if span.from_expansion() { @@ -170,7 +220,7 @@ impl<'a, 'tcx> SegmentBuilder<'a, 'tcx> { return; } - let segment = match LineSegment::new(*lines.start(), *lines.end(), value) { + let segment = match line_segment(lines, weight) { Ok(segment) => segment, Err(error) => { self.cx @@ -206,22 +256,26 @@ fn extract_while_components<'hir>( } } +/// Count logical branches, treating `&&` and `||` as sums of their operands. +/// +/// Negation, blocks, and `if` expressions are transparent; every other +/// expression contributes one branch. fn count_branches(expr: &hir::Expr<'_>) -> usize { match expr.kind { ExprKind::Binary(op, lhs, rhs) if matches!(op.node, BinOpKind::And | BinOpKind::Or) => { count_branches(lhs) + count_branches(rhs) } - ExprKind::Unary(UnOp::Not, inner) => count_branches(inner), - ExprKind::DropTemps(inner) => count_branches(inner), - ExprKind::Block(block, _) => match block.expr { - Some(inner) => count_branches(inner), - None => 1, - }, + ExprKind::Unary(UnOp::Not, inner) | ExprKind::DropTemps(inner) => count_branches(inner), + ExprKind::Block(block, _) => block.expr.map_or(1, count_branches), ExprKind::If(cond, ..) => count_branches(cond), _ => 1, } } +/// Return the contiguous 1-based source-line range covered by a compiler span. +/// +/// Returns `None` when the span cannot be mapped to source lines or maps to a +/// non-contiguous set of lines. pub(super) fn span_line_range(source_map: &SourceMap, span: Span) -> Option> { let info = source_map.span_to_lines(span).ok()?; let first = info.lines.first()?; @@ -229,11 +283,38 @@ pub(super) fn span_line_range(source_map: &SourceMap, span: Span) -> Option tests/api_ui/legacy_normalize_settings.rs:3:5 + | +3 | use bumpy_road_function::analysis::normalise_settings; + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ no `normalise_settings` in `analysis` + | +help: a similar name exists in the module + | +3 - use bumpy_road_function::analysis::normalise_settings; +3 + use bumpy_road_function::analysis::normalize_settings; + | diff --git a/crates/bumpy_road_function/tests/api_ui/pass_normalize_settings.rs b/crates/bumpy_road_function/tests/api_ui/pass_normalize_settings.rs new file mode 100644 index 000000000..d29224b8d --- /dev/null +++ b/crates/bumpy_road_function/tests/api_ui/pass_normalize_settings.rs @@ -0,0 +1,12 @@ +//! Compile contract for the Oxford-spelled public settings API. + +// `make test` enables the Dylint driver for workspace dependencies. +#![feature(rustc_private)] + +use bumpy_road_function::analysis::{Settings, normalize_settings}; + +/// Exercise the renamed settings API in the compile-pass fixture. +fn main() { + let settings = normalize_settings(Settings::default()); + assert!(settings.window > 0); +} diff --git a/crates/bumpy_road_function/tests/numeric_boundaries.rs b/crates/bumpy_road_function/tests/numeric_boundaries.rs new file mode 100644 index 000000000..3b2d8246f --- /dev/null +++ b/crates/bumpy_road_function/tests/numeric_boundaries.rs @@ -0,0 +1,4 @@ +//! Numeric conversion boundary contracts reused by the bumpy-road driver. + +#[path = "../src/driver/numeric.rs"] +mod numeric; diff --git a/crates/conditional_max_n_branches/src/driver.rs b/crates/conditional_max_n_branches/src/driver.rs index e14199084..410e842fe 100644 --- a/crates/conditional_max_n_branches/src/driver.rs +++ b/crates/conditional_max_n_branches/src/driver.rs @@ -21,6 +21,9 @@ use whitaker_common::{ get_localizer_for_lint, noop_reporter, safe_resolve_message_set, }; +mod numeric; +use self::numeric::diagnostic_integer_argument; + const LINT_NAME: &str = "conditional_max_n_branches"; const MESSAGE_KEY: MessageKey<'static> = MessageKey::new(LINT_NAME); @@ -60,13 +63,27 @@ impl Default for ConditionalMaxNBranches { } } -dylint_linting::impl_late_lint! { - pub CONDITIONAL_MAX_N_BRANCHES, - Warn, - "complex conditionals should be decomposed when they exceed the configured branch limit", - ConditionalMaxNBranches::default() +mod registration { + //! Provides the public Dylint registration for this lint. + //! + //! The `dylint_linting::impl_late_lint!` macro generates undocumented + //! public registration items, so its expansion is scoped to this private + //! module and only the documented lint static is re-exported. + + use super::ConditionalMaxNBranches; + + dylint_linting::impl_late_lint! { + /// Lint flagging conditionals whose predicate exceeds the configured + /// number of short-circuit branches. + pub CONDITIONAL_MAX_N_BRANCHES, + Warn, + "complex conditionals should be decomposed when they exceed the configured branch limit", + ConditionalMaxNBranches::default() + } } +pub use registration::CONDITIONAL_MAX_N_BRANCHES; + impl<'tcx> LateLintPass<'tcx> for ConditionalMaxNBranches { fn check_crate(&mut self, _cx: &LateContext<'tcx>) { self.max_branches = load_configuration().max_branches.max(1); @@ -182,48 +199,33 @@ impl ConditionKind { } } +/// Count logical branches, treating `&&` and `||` as sums of their operands. +/// +/// Negation, blocks, and `if` expressions are transparent; every other +/// expression contributes one branch. fn count_branches(expr: &hir::Expr<'_>) -> usize { match expr.kind { ExprKind::Binary(op, lhs, rhs) if matches!(op.node, BinOpKind::And | BinOpKind::Or) => { count_branches(lhs) + count_branches(rhs) } - ExprKind::Unary(UnOp::Not, inner) => count_branches(inner), - ExprKind::DropTemps(inner) => count_branches(inner), - ExprKind::Block(block, _) => match block.expr { - Some(inner) => count_branches(inner), - None => 1, - }, + ExprKind::Unary(UnOp::Not, inner) | ExprKind::DropTemps(inner) => count_branches(inner), + ExprKind::Block(block, _) => block.expr.map_or(1, count_branches), ExprKind::If(cond, ..) => count_branches(cond), _ => 1, } } +/// Emit the localized branch-count diagnostic for one condition. +/// +/// Counts are saturated to Fluent's signed 64-bit range, and missing +/// translations fall back to the built-in English message set. fn emit_diagnostic( cx: &LateContext<'_>, metadata: &ConditionMetadata, limit: usize, localizer: &Localizer, ) { - let mut args: Arguments<'static> = Arguments::default(); - args.insert( - Cow::Borrowed("name"), - FluentValue::from(metadata.kind.display_name()), - ); - args.insert( - Cow::Borrowed("branches"), - FluentValue::from(metadata.branches as i64), - ); - args.insert(Cow::Borrowed("limit"), FluentValue::from(limit as i64)); - let branch_phrase_text = branch_phrase(localizer.locale(), metadata.branches); - args.insert( - Cow::Borrowed("branch_phrase"), - FluentValue::String(Cow::Owned(branch_phrase_text)), - ); - let limit_phrase_text = branch_phrase(localizer.locale(), limit); - args.insert( - Cow::Borrowed("limit_phrase"), - FluentValue::String(Cow::Owned(limit_phrase_text)), - ); + let args = diagnostic_arguments(metadata.kind, metadata.branches, limit, localizer); let resolution = MessageResolution { lint_name: LINT_NAME, @@ -234,9 +236,9 @@ fn emit_diagnostic( fallback_messages(metadata.kind, metadata.branches, limit) }); - let primary = normalise_isolation_marks(messages.primary()); - let note = normalise_isolation_marks(messages.note()); - let help = normalise_isolation_marks(messages.help()); + let primary = normalize_isolation_marks(messages.primary()); + let note = normalize_isolation_marks(messages.note()); + let help = normalize_isolation_marks(messages.help()); cx.emit_span_lint( CONDITIONAL_MAX_N_BRANCHES, @@ -249,7 +251,44 @@ fn emit_diagnostic( ); } -fn normalise_isolation_marks(text: &str) -> String { +/// Build the production branch-count and limit arguments for a diagnostic. +fn diagnostic_arguments( + kind: ConditionKind, + branches: usize, + limit: usize, + localizer: &Localizer, +) -> Arguments<'static> { + let mut args: Arguments<'static> = Arguments::default(); + args.insert( + Cow::Borrowed("name"), + FluentValue::from(kind.display_name()), + ); + args.insert( + Cow::Borrowed("branches"), + FluentValue::from(diagnostic_integer_argument(branches)), + ); + args.insert( + Cow::Borrowed("limit"), + FluentValue::from(diagnostic_integer_argument(limit)), + ); + let branch_phrase_text = branch_phrase(localizer.locale(), branches); + args.insert( + Cow::Borrowed("branch_phrase"), + FluentValue::String(Cow::Owned(branch_phrase_text)), + ); + let limit_phrase_text = branch_phrase(localizer.locale(), limit); + args.insert( + Cow::Borrowed("limit_phrase"), + FluentValue::String(Cow::Owned(limit_phrase_text)), + ); + args +} + +/// Replace injected bidi isolates and replacement characters in diagnostics. +/// +/// Each such character becomes a double quote; text without these characters +/// is returned unchanged apart from allocating the owned result. +fn normalize_isolation_marks(text: &str) -> String { if text .chars() .any(|character| matches!(character, '\u{2068}' | '\u{2069}' | '\u{FFFD}')) @@ -265,6 +304,10 @@ fn normalise_isolation_marks(text: &str) -> String { } } +/// Build the built-in English diagnostic messages for a condition. +/// +/// The branch and limit values are rendered as locale-aware English phrases +/// when no translated message is available. fn fallback_messages(kind: ConditionKind, branches: usize, limit: usize) -> DiagnosticMessageSet { let branch_phrase_text = branch_phrase(FALLBACK_LOCALE, branches); let limit_phrase_text = branch_phrase(FALLBACK_LOCALE, limit); @@ -287,9 +330,12 @@ fn fallback_messages(kind: ConditionKind, branches: usize, limit: usize) -> Diag #[cfg(test)] mod tests { + //! Verify branch thresholds and the production diagnostic argument path. + use super::*; use rstest::rstest; + /// Verify branch counts below, at, and above the configured limit. #[rstest] #[case(1, 2, ConditionDisposition::WithinLimit)] #[case(2, 2, ConditionDisposition::WithinLimit)] @@ -301,6 +347,20 @@ mod tests { ) { assert_eq!(evaluate_condition(branches, limit), expected); } + + /// Verify oversized branch counts saturate before Fluent argument creation. + #[cfg(target_pointer_width = "64")] + #[test] + fn diagnostic_arguments_saturate_oversized_branch_counts() { + let oversized = usize::try_from(u64::try_from(i64::MAX).expect("i64 max fits u64") + 1) + .expect("oversized branch count fits usize"); + let localizer = Localizer::new(None); + + let args = diagnostic_arguments(ConditionKind::If, oversized, oversized, &localizer); + + assert_eq!(args.get("branches"), Some(&FluentValue::from(i64::MAX))); + assert_eq!(args.get("limit"), Some(&FluentValue::from(i64::MAX))); + } } #[cfg(test)] diff --git a/crates/conditional_max_n_branches/src/driver/numeric.rs b/crates/conditional_max_n_branches/src/driver/numeric.rs new file mode 100644 index 000000000..321bd96d9 --- /dev/null +++ b/crates/conditional_max_n_branches/src/driver/numeric.rs @@ -0,0 +1,36 @@ +//! Bounds-checked numeric conversions for conditional-branch diagnostics. + +/// Clamp diagnostic numbers to Fluent's signed 64-bit argument range. +pub(super) fn saturating_diagnostic_integer(value: u64) -> i64 { + i64::try_from(value).unwrap_or(i64::MAX) +} + +/// Convert a production diagnostic argument to Fluent's signed integer range. +pub(super) fn diagnostic_integer_argument(value: usize) -> i64 { + saturating_diagnostic_integer(u64::try_from(value).unwrap_or(u64::MAX)) +} + +#[cfg(test)] +mod tests { + //! Verify diagnostic integer conversion at Fluent's signed limit. + + use super::{diagnostic_integer_argument, saturating_diagnostic_integer}; + + /// Verify values beyond Fluent's signed limit clamp without wrapping. + #[test] + fn diagnostic_integers_saturate_at_the_signed_boundary() { + let maximum = i64::MAX.unsigned_abs(); + assert_eq!(saturating_diagnostic_integer(maximum - 1), i64::MAX - 1); + assert_eq!(saturating_diagnostic_integer(maximum), i64::MAX); + assert_eq!(saturating_diagnostic_integer(maximum + 1), i64::MAX); + } + + /// Verify the production argument conversion clamps an oversized limit. + #[cfg(target_pointer_width = "64")] + #[test] + fn production_diagnostic_arguments_saturate_at_the_signed_boundary() { + let oversized = usize::try_from(u64::try_from(i64::MAX).expect("i64 max fits u64") + 1) + .expect("oversized diagnostic limit fits usize"); + assert_eq!(diagnostic_integer_argument(oversized), i64::MAX); + } +} diff --git a/crates/conditional_max_n_branches/tests/numeric_boundaries.rs b/crates/conditional_max_n_branches/tests/numeric_boundaries.rs new file mode 100644 index 000000000..48519ecf9 --- /dev/null +++ b/crates/conditional_max_n_branches/tests/numeric_boundaries.rs @@ -0,0 +1,4 @@ +//! Numeric conversion boundary contracts reused by the conditional driver. + +#[path = "../src/driver/numeric.rs"] +mod numeric; diff --git a/crates/function_attrs_follow_docs/src/driver.rs b/crates/function_attrs_follow_docs/src/driver.rs index 1235e3d4b..bd2b81722 100644 --- a/crates/function_attrs_follow_docs/src/driver.rs +++ b/crates/function_attrs_follow_docs/src/driver.rs @@ -331,8 +331,9 @@ const MESSAGE_KEY: MessageKey<'static> = MessageKey::new("function_attrs_follow_ type FunctionAttrsMessages = DiagnosticMessageSet; +/// Build localized diagnostics for an attribute kind using the supplied lookup. #[cfg(test)] -fn localised_messages( +fn localized_messages( lookup: &impl BundleLookup, kind: FunctionKind, attribute: &str, diff --git a/crates/function_attrs_follow_docs/src/tests/localization.rs b/crates/function_attrs_follow_docs/src/tests/localization.rs index 7afb76f67..4443ab603 100644 --- a/crates/function_attrs_follow_docs/src/tests/localization.rs +++ b/crates/function_attrs_follow_docs/src/tests/localization.rs @@ -11,7 +11,7 @@ use whitaker_common::i18n::{I18nError, testing::FailingLookup}; use super::{ FunctionAttrsMessages, FunctionKind, Localizer, MESSAGE_KEY, attribute_fallback, - localised_messages, + localized_messages, }; #[derive(Default)] @@ -111,6 +111,10 @@ fn resolve_attribute(world: &LocalizationWorld, failing: bool) -> String { } } +/// Return localized messages from the configured or failing lookup. +/// +/// # Errors +/// Returns the lookup error when localization fails. fn resolve_localization( world: &LocalizationWorld, kind: FunctionKind, @@ -119,9 +123,9 @@ fn resolve_localization( ) -> Result { if failing { let lookup = failing_lookup(); - localised_messages(&lookup, kind, attribute) + localized_messages(&lookup, kind, attribute) } else { - world.with_localizer(|localizer| localised_messages(localizer, kind, attribute)) + world.with_localizer(|localizer| localized_messages(localizer, kind, attribute)) } } diff --git a/crates/module_max_lines/Cargo.toml b/crates/module_max_lines/Cargo.toml index d4575d288..7de87a660 100644 --- a/crates/module_max_lines/Cargo.toml +++ b/crates/module_max_lines/Cargo.toml @@ -34,6 +34,7 @@ whitaker = { workspace = true, features = ["dylint-driver"], optional = true } fluent-templates = { workspace = true, optional = true } [dev-dependencies] +whitaker_test_macros = { workspace = true } camino = { workspace = true } rstest = { workspace = true } rstest-bdd = { workspace = true } diff --git a/crates/module_max_lines/src/driver.rs b/crates/module_max_lines/src/driver.rs index c67dd8522..797ba851c 100644 --- a/crates/module_max_lines/src/driver.rs +++ b/crates/module_max_lines/src/driver.rs @@ -250,6 +250,8 @@ mod behaviour { } } + /// Create the default module-length behaviour-test world. + #[whitaker_test_macros::allow_fixture_expansion_lints] #[fixture] fn world() -> ModuleWorld { ModuleWorld::default() diff --git a/crates/module_max_lines/src/lib.rs b/crates/module_max_lines/src/lib.rs index 3fbf5a1a9..840470ad6 100644 --- a/crates/module_max_lines/src/lib.rs +++ b/crates/module_max_lines/src/lib.rs @@ -1,3 +1,8 @@ +//! Dylint lint that flags modules exceeding the configured line budget. +//! +//! The lint drives contributors toward smaller, reviewable modules; the +//! `dylint-driver` feature gates the rustc-facing implementation so the crate +//! also builds as an ordinary library. #![cfg_attr(feature = "dylint-driver", feature(rustc_private))] #[cfg(feature = "dylint-driver")] diff --git a/crates/module_must_have_inner_docs/src/driver.rs b/crates/module_must_have_inner_docs/src/driver/mod.rs similarity index 98% rename from crates/module_must_have_inner_docs/src/driver.rs rename to crates/module_must_have_inner_docs/src/driver/mod.rs index 2bba08980..ef19d43ba 100644 --- a/crates/module_must_have_inner_docs/src/driver.rs +++ b/crates/module_must_have_inner_docs/src/driver/mod.rs @@ -326,17 +326,17 @@ fn fallback_messages(module: ModuleName<'_>) -> ModuleDocMessages { } #[cfg(test)] -#[path = "tests/behaviour.rs"] +#[path = "../tests/behaviour.rs"] mod behaviour; #[cfg(test)] -#[path = "tests/ui.rs"] +#[path = "../tests/ui.rs"] mod ui; #[cfg(test)] -#[path = "tests/classifier.rs"] +#[path = "../tests/classifier.rs"] mod classifier; #[cfg(test)] -#[path = "tests/span_to_snippet.rs"] +#[path = "../tests/span_to_snippet.rs"] mod span_to_snippet; diff --git a/crates/no_expect_outside_tests/examples/fail_expect_in_rstest_non_test_module.stderr b/crates/no_expect_outside_tests/examples/fail_expect_in_rstest_non_test_module.stderr index 17b1568df..a9beb0365 100644 --- a/crates/no_expect_outside_tests/examples/fail_expect_in_rstest_non_test_module.stderr +++ b/crates/no_expect_outside_tests/examples/fail_expect_in_rstest_non_test_module.stderr @@ -4,7 +4,7 @@ error: Avoid calling expect on `std::option::Option<&str>` outside test-only cod LL | let _ = parsed.expect("ordinary code must not inherit rstest harness status"); | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ | - = note: The call originates within function `parse` which is not recognised as a test. + = note: The call originates within function `parse` which is not recognized as a test. = help: Handle the `None` variant of `std::option::Option<&str>` or move the code into a test. = note: requested on the command line with `-D no-expect-outside-tests` diff --git a/crates/no_expect_outside_tests/examples/fail_expect_in_tokio_crate_non_test_fn.rs b/crates/no_expect_outside_tests/examples/fail_expect_in_tokio_crate_non_test_fn.rs index 4da9179f3..97f6f537d 100644 --- a/crates/no_expect_outside_tests/examples/fail_expect_in_tokio_crate_non_test_fn.rs +++ b/crates/no_expect_outside_tests/examples/fail_expect_in_tokio_crate_non_test_fn.rs @@ -1,7 +1,7 @@ //! Negative regression ensuring Tokio-specific test configuration does not //! leak into ordinary code. //! -//! The fixture config recognises `#[tokio::test]`, but `parse_config` remains +//! The fixture config recognizes `#[tokio::test]`, but `parse_config` remains //! production code and must still trigger the lint even in the same crate. fn parse_config() { diff --git a/crates/no_expect_outside_tests/examples/fail_expect_in_tokio_crate_non_test_fn.stderr b/crates/no_expect_outside_tests/examples/fail_expect_in_tokio_crate_non_test_fn.stderr index add3a1f4c..1aece484b 100644 --- a/crates/no_expect_outside_tests/examples/fail_expect_in_tokio_crate_non_test_fn.stderr +++ b/crates/no_expect_outside_tests/examples/fail_expect_in_tokio_crate_non_test_fn.stderr @@ -4,7 +4,7 @@ error: Avoid calling expect on `std::option::Option<&str>` outside test-only cod LL | let _ = parsed.expect("ordinary code in a Tokio crate must still lint"); | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ | - = note: The call originates within function `parse_config` which is not recognised as a test. + = note: The call originates within function `parse_config` which is not recognized as a test. = help: Handle the `None` variant of `std::option::Option<&str>` or move the code into a test. = note: requested on the command line with `-D no-expect-outside-tests` diff --git a/crates/no_expect_outside_tests/src/behaviour.rs b/crates/no_expect_outside_tests/src/behaviour.rs index 2c94e0084..66fb575ef 100644 --- a/crates/no_expect_outside_tests/src/behaviour.rs +++ b/crates/no_expect_outside_tests/src/behaviour.rs @@ -1,7 +1,7 @@ //! Behaviour-driven tests covering context summarization for the lint's context //! world and BDD steps. -use crate::context::{ContextSummary, summarise_context}; +use crate::context::{ContextSummary, summarize_context}; use rstest::fixture; use rstest_bdd_macros::{given, scenario, then, when}; use std::cell::RefCell; @@ -53,9 +53,10 @@ impl ContextWorld { *self.is_doctest.borrow_mut() = true; } + /// Classify accumulated context and record whether linting should be skipped. fn evaluate(&self) { let entries = self.entries.borrow(); - let summary = summarise_context( + let summary = summarize_context( entries.as_slice(), *self.cfg_test.borrow(), self.additional.borrow().as_slice(), @@ -115,8 +116,9 @@ fn given_doctest(world: &ContextWorld) { world.mark_doctest(); } -#[when("I summarise the context")] -fn when_summarise(world: &ContextWorld) { +/// Evaluate the current context and store its summary. +#[when("I summarize the context")] +fn when_summarize(world: &ContextWorld) { world.evaluate(); } diff --git a/crates/no_expect_outside_tests/src/context.rs b/crates/no_expect_outside_tests/src/context/mod.rs similarity index 92% rename from crates/no_expect_outside_tests/src/context.rs rename to crates/no_expect_outside_tests/src/context/mod.rs index 5a03d4c51..4b9af71e9 100644 --- a/crates/no_expect_outside_tests/src/context.rs +++ b/crates/no_expect_outside_tests/src/context/mod.rs @@ -87,30 +87,16 @@ fn has_test_ancestry( || (is_function_item && has_test_like_hir_attributes(attrs, additional_test_attributes)) } -/// Summarizes collected ancestor context into the lint's final test decision. +/// Derive whether a call site is test-only and find its innermost function. /// -/// Combines the pre-computed ancestor entries with the carried -/// `has_test_context_ancestry` flag so the lint can determine whether the -/// current call site should be treated as test-only code. -/// -/// # Parameters -/// -/// - `entries`: Simplified ancestor contexts produced by `collect_context`. -/// - `has_test_context_ancestry`: Whether any ancestor already established -/// test-only ancestry via propagation, `cfg(test)`, or a recognized -/// test-marker attribute. -/// - `additional_test_attributes`: Extra user-configured attribute paths that -/// should be considered test markers during the final summary check. -/// -/// # Returns -/// -/// Returns a `ContextSummary` describing the derived test-context status and -/// the innermost enclosing function name, if one was found. +/// The entries are simplified HIR ancestors; the ancestry flag carries test +/// context such as `cfg(test)`, and configured attributes add test markers. +/// The summary includes test-like status and the innermost enclosing function. /// /// # Examples /// /// ```ignore -/// let summary = summarise_context( +/// let summary = summarize_context( /// &entries, /// has_test_context_ancestry, /// additional_test_attributes, @@ -119,7 +105,7 @@ fn has_test_ancestry( /// // `.expect()` is allowed in this context. /// } /// ``` -pub(crate) fn summarise_context( +pub(crate) fn summarize_context( entries: &[ContextEntry], has_test_context_ancestry: bool, additional_test_attributes: &[AttributePath], diff --git a/crates/no_expect_outside_tests/src/diagnostics.rs b/crates/no_expect_outside_tests/src/diagnostics.rs index 48d582e5a..0e285c970 100644 --- a/crates/no_expect_outside_tests/src/diagnostics.rs +++ b/crates/no_expect_outside_tests/src/diagnostics.rs @@ -194,8 +194,9 @@ const MESSAGE_KEY: MessageKey<'static> = MessageKey::new("no_expect_outside_test type NoExpectMessages = DiagnosticMessageSet; +/// Build localized diagnostics for the supplied receiver and context. #[cfg(test)] -fn localised_messages( +fn localized_messages( lookup: &impl BundleLookup, receiver: &ReceiverLabel, context: &ContextLabel, @@ -218,13 +219,17 @@ fn localised_messages( resolve_message_set(lookup, MESSAGE_KEY, &args) } +/// Build the English diagnostic set used when localized resolution fails. +/// +/// The receiver and context labels identify the `expect` call and its enclosing +/// scope; the category selects the matching recovery guidance. fn fallback_messages( receiver: &ReceiverLabel, context: &ContextLabel, category: ReceiverCategory, ) -> NoExpectMessages { let primary = format!("Avoid calling expect on {receiver} outside test-only code."); - let note = format!("The call originates within {context} which is not recognised as a test.",); + let note = format!("The call originates within {context} which is not recognized as a test.",); let help = category.fallback_help(receiver); NoExpectMessages::new(primary, note, help) diff --git a/crates/no_expect_outside_tests/src/driver/mod.rs b/crates/no_expect_outside_tests/src/driver/mod.rs index 25d1485d4..225d8d8fc 100644 --- a/crates/no_expect_outside_tests/src/driver/mod.rs +++ b/crates/no_expect_outside_tests/src/driver/mod.rs @@ -23,7 +23,7 @@ use whitaker::SharedConfig; use whitaker::hir::has_test_like_hir_attributes; use whitaker_common::{AttributePath, Localizer, get_localizer_for_lint}; -use crate::context::{collect_context, is_cfg_test_attribute, summarise_context}; +use crate::context::{collect_context, is_cfg_test_attribute, summarize_context}; use crate::diagnostics::{DiagnosticContext, emit_diagnostic}; dylint_linting::impl_late_lint! { @@ -103,6 +103,11 @@ impl<'tcx> LateLintPass<'tcx> for NoExpectOutsideTests { self.localizer = get_localizer_for_lint("no_expect_outside_tests", shared_config.locale()); } + /// Diagnose `expect` calls on `Option` or `Result` outside test contexts. + /// + /// Doctests, recognized test contexts, and harness-detected test functions + /// are skipped; other method calls and non-`Option`/`Result` receivers are + /// ignored. fn check_expr(&mut self, cx: &LateContext<'tcx>, expr: &'tcx hir::Expr<'tcx>) { if self.is_doctest { return; @@ -122,7 +127,7 @@ impl<'tcx> LateLintPass<'tcx> for NoExpectOutsideTests { let additional = self.additional_test_attributes.as_slice(); let (entries, has_test_context_ancestry) = collect_context(cx, expr.hir_id, additional); - let summary = summarise_context(entries.as_slice(), has_test_context_ancestry, additional); + let summary = summarize_context(entries.as_slice(), has_test_context_ancestry, additional); if summary.is_test { return; diff --git a/crates/no_expect_outside_tests/src/tests.rs b/crates/no_expect_outside_tests/src/tests.rs index f569b8b4c..ff5d9b110 100644 --- a/crates/no_expect_outside_tests/src/tests.rs +++ b/crates/no_expect_outside_tests/src/tests.rs @@ -1,7 +1,7 @@ //! Unit tests validating context summarization outcomes across default and //! configured test attributes. -use crate::context::summarise_context; +use crate::context::summarize_context; use rstest::rstest; use whitaker_common::attributes::{Attribute, AttributeKind, AttributePath}; use whitaker_common::{ContextEntry, ContextKind}; @@ -18,33 +18,37 @@ fn test_attribute() -> Attribute { Attribute::new(AttributePath::from("test"), AttributeKind::Outer) } +/// Check that a plain function context is not classified as test code. #[rstest] -fn summarises_plain_context() { +fn summarizes_plain_context() { let entries = vec![function_entry("handler", Vec::new())]; - let summary = summarise_context(&entries, false, &[]); + let summary = summarize_context(&entries, false, &[]); assert!(!summary.is_test); assert_eq!(summary.function_name.as_deref(), Some("handler")); } +/// Check that the standard test attribute marks a function as test code. #[rstest] -fn recognises_test_attribute() { +fn recognizes_test_attribute() { let entries = vec![function_entry("test_case", vec![test_attribute()])]; - let summary = summarise_context(&entries, false, &[]); + let summary = summarize_context(&entries, false, &[]); assert!(summary.is_test); assert_eq!(summary.function_name.as_deref(), Some("test_case")); } +/// Check that a `cfg(test)` module is classified as test code. #[rstest] fn honours_cfg_test() { let entries = vec![module_entry("tests", Vec::new())]; - let summary = summarise_context(&entries, true, &[]); + let summary = summarize_context(&entries, true, &[]); assert!(summary.is_test); assert_eq!(summary.function_name, None); } +/// Check that configured test attributes mark a function as test code. #[rstest] fn honours_additional_attributes() { let entries = vec![function_entry( @@ -55,7 +59,7 @@ fn honours_additional_attributes() { )], )]; let additional = vec![AttributePath::from("custom::test")]; - let summary = summarise_context(&entries, false, additional.as_slice()); + let summary = summarize_context(&entries, false, additional.as_slice()); assert!(summary.is_test); assert_eq!(summary.function_name.as_deref(), Some("custom")); diff --git a/crates/no_expect_outside_tests/src/tests/localization.rs b/crates/no_expect_outside_tests/src/tests/localization.rs index 5890b799f..1867fd3b5 100644 --- a/crates/no_expect_outside_tests/src/tests/localization.rs +++ b/crates/no_expect_outside_tests/src/tests/localization.rs @@ -13,7 +13,7 @@ use whitaker_common::i18n::{BundleLookup, testing::FailingLookup}; use super::{ I18nError, Localizer, MESSAGE_KEY, NoExpectMessages, ReceiverCategory, ReceiverLabel, - context_label, fallback_messages, localised_messages, + context_label, fallback_messages, localized_messages, }; use crate::context::ContextSummary; @@ -260,6 +260,10 @@ fn then_fallback(world: &LocalizationWorld, snippet: String) { ); } +/// Return localized messages for the receiver and summarized context. +/// +/// # Errors +/// Returns the lookup error when localization fails. fn execute_localization( lookup: &impl BundleLookup, receiver: &ReceiverLabel, @@ -267,7 +271,7 @@ fn execute_localization( ) -> Result { let context = context_label(summary); let category = ReceiverCategory::for_label(receiver); - localised_messages(lookup, receiver, &context, category) + localized_messages(lookup, receiver, &context, category) } fn failing_lookup() -> FailingLookup { diff --git a/crates/no_expect_outside_tests/src/tests/receiver_type_edge_cases.rs b/crates/no_expect_outside_tests/src/tests/receiver_type_edge_cases.rs index b4d40b19f..a991c8865 100644 --- a/crates/no_expect_outside_tests/src/tests/receiver_type_edge_cases.rs +++ b/crates/no_expect_outside_tests/src/tests/receiver_type_edge_cases.rs @@ -3,9 +3,10 @@ use rstest::rstest; use super::{ - ContextLabel, Localizer, NoExpectMessages, ReceiverCategory, ReceiverLabel, localised_messages, + ContextLabel, Localizer, NoExpectMessages, ReceiverCategory, ReceiverLabel, localized_messages, }; +/// Check that unusual receiver labels still produce useful messages. #[rstest] #[case("", "the surrounding scope", |messages: &NoExpectMessages| !messages.primary().is_empty())] #[case( @@ -27,7 +28,7 @@ fn handles_receiver_type_edge_cases( let receiver_label = ReceiverLabel::new(receiver); let context_label = ContextLabel::new(context); let category = ReceiverCategory::for_label(&receiver_label); - let messages = localised_messages(&lookup, &receiver_label, &context_label, category) + let messages = localized_messages(&lookup, &receiver_label, &context_label, category) .expect("localization succeeds"); assert!( assertion(&messages), diff --git a/crates/no_expect_outside_tests/tests/features/context_summary.feature b/crates/no_expect_outside_tests/tests/features/context_summary.feature index 7973fca8e..14469084e 100644 --- a/crates/no_expect_outside_tests/tests/features/context_summary.feature +++ b/crates/no_expect_outside_tests/tests/features/context_summary.feature @@ -1,33 +1,33 @@ -Feature: Summarise traversal context for `.expect(..)` linting +Feature: Summarize traversal context for `.expect(..)` linting Scenario: Plain function without test attributes Given a non-test function named handler - When I summarise the context + When I summarize the context Then the context is marked as production And the function name is handler Scenario: Function marked as a test Given a test function named works - When I summarise the context + When I summarize the context Then the context is marked as test And the function name is works Scenario: Module guarded by cfg(test) Given a module with cfg(test) - When I summarise the context + When I summarize the context Then the context is marked as test And no function name is recorded Scenario: Function recognized via configured attribute Given an additional test attribute custom::test is configured And a function annotated with the additional attribute custom::test - When I summarise the context + When I summarize the context Then the context is marked as test And the function name is custom Scenario: Doctest crate bypasses linting Given a non-test function named handler And the lint is running within a doctest - When I summarise the context + When I summarize the context Then the lint is skipped And the function name is handler diff --git a/crates/no_expect_outside_tests/ui/fail_expect_in_file_backed_non_test_fn.stderr b/crates/no_expect_outside_tests/ui/fail_expect_in_file_backed_non_test_fn.stderr index 85082bd0d..2778af4f8 100644 --- a/crates/no_expect_outside_tests/ui/fail_expect_in_file_backed_non_test_fn.stderr +++ b/crates/no_expect_outside_tests/ui/fail_expect_in_file_backed_non_test_fn.stderr @@ -4,7 +4,7 @@ error: Avoid calling expect on `std::option::Option<&str>` outside test-only cod LL | let _ = value.expect("file-backed cfg(test) ancestry must not leak into main"); | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ | - = note: The call originates within function `main` which is not recognised as a test. + = note: The call originates within function `main` which is not recognized as a test. = help: Handle the `None` variant of `std::option::Option<&str>` or move the code into a test. note: the lint level is defined here --> $DIR/fail_expect_in_file_backed_non_test_fn.rs:6:9 diff --git a/crates/no_expect_outside_tests/ui/fail_expect_in_fn.stderr b/crates/no_expect_outside_tests/ui/fail_expect_in_fn.stderr index 688473104..609da9dc5 100644 --- a/crates/no_expect_outside_tests/ui/fail_expect_in_fn.stderr +++ b/crates/no_expect_outside_tests/ui/fail_expect_in_fn.stderr @@ -4,7 +4,7 @@ error: Avoid calling expect on `std::option::Option` outside test-only code LL | let _result = value.expect("value should exist"); | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ | - = note: The call originates within function `process` which is not recognised as a test. + = note: The call originates within function `process` which is not recognized as a test. = help: Handle the `None` variant of `std::option::Option` or move the code into a test. note: the lint level is defined here --> $DIR/fail_expect_in_fn.rs:2:9 @@ -18,7 +18,7 @@ error: Avoid calling expect on `std::result::Result<(), &str>` outside test-only LL | let _ = result.expect("result should be ok"); | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ | - = note: The call originates within function `fail_result` which is not recognised as a test. + = note: The call originates within function `fail_result` which is not recognized as a test. = help: Handle the `Err` variant of `std::result::Result<(), &str>` or move the code into a test. error: aborting due to 2 previous errors diff --git a/crates/no_expect_outside_tests/ui/fail_expect_with_cfg_attr.stderr b/crates/no_expect_outside_tests/ui/fail_expect_with_cfg_attr.stderr index e5d6c5184..aa650a667 100644 --- a/crates/no_expect_outside_tests/ui/fail_expect_with_cfg_attr.stderr +++ b/crates/no_expect_outside_tests/ui/fail_expect_with_cfg_attr.stderr @@ -4,7 +4,7 @@ error: Avoid calling expect on `std::option::Option` outside test-only code LL | let _ = value.expect("handler should not ignore errors"); | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ | - = note: The call originates within function `handler` which is not recognised as a test. + = note: The call originates within function `handler` which is not recognized as a test. = help: Handle the `None` variant of `std::option::Option` or move the code into a test. note: the lint level is defined here --> $DIR/fail_expect_with_cfg_attr.rs:1:9 diff --git a/crates/no_std_fs_operations/Cargo.toml b/crates/no_std_fs_operations/Cargo.toml index 05528b78d..521cd2e74 100644 --- a/crates/no_std_fs_operations/Cargo.toml +++ b/crates/no_std_fs_operations/Cargo.toml @@ -35,6 +35,7 @@ serde = { workspace = true, optional = true } whitaker = { workspace = true, features = ["dylint-driver"], optional = true } [dev-dependencies] +whitaker_test_macros = { workspace = true } anyhow = "1.0" cargo_metadata = { workspace = true } camino = { workspace = true } diff --git a/crates/no_std_fs_operations/src/behaviour.rs b/crates/no_std_fs_operations/src/behaviour.rs index 4e3cb6174..8061548d2 100644 --- a/crates/no_std_fs_operations/src/behaviour.rs +++ b/crates/no_std_fs_operations/src/behaviour.rs @@ -1,6 +1,6 @@ //! Behaviour-driven localization tests for the `no_std_fs_operations` lint. -use crate::diagnostics::{StdFsMessages, localised_messages}; +use crate::diagnostics::{StdFsMessages, localized_messages}; use rstest::fixture; use rstest_bdd_macros::{given, scenario, then, when}; use std::cell::RefCell; @@ -28,13 +28,14 @@ impl LocalizationWorld { self.failing = true; } + /// Resolve localized messages with the selected locale or configured failure. fn resolve(&mut self) { let op = self.operation.clone(); let result = if self.failing { - localised_messages(&FailingLookup::new("no_std_fs_operations"), &op) + localized_messages(&FailingLookup::new("no_std_fs_operations"), &op) } else { let localizer = self.localizer.as_ref().expect("a locale must be selected"); - localised_messages(localizer, &op) + localized_messages(localizer, &op) }; self.result = Some(result); } @@ -83,8 +84,9 @@ fn given_failure(world: &WorldCell) { world.borrow_mut().mark_failure(); } -#[when("I localise the std::fs diagnostic")] -fn when_localise(world: &WorldCell) { +/// Resolve the current standard-filesystem diagnostic messages. +#[when("I localize the std::fs diagnostic")] +fn when_localize(world: &WorldCell) { world.borrow_mut().resolve(); } diff --git a/crates/no_std_fs_operations/src/diagnostics.rs b/crates/no_std_fs_operations/src/diagnostics.rs index 6ba5a22df..ebb6c981a 100644 --- a/crates/no_std_fs_operations/src/diagnostics.rs +++ b/crates/no_std_fs_operations/src/diagnostics.rs @@ -74,8 +74,9 @@ fn sanitize_message(text: String) -> String { .collect() } +/// Build localized diagnostics for the supplied filesystem operation. #[cfg(test)] -pub(crate) fn localised_messages( +pub(crate) fn localized_messages( lookup: &impl BundleLookup, operation: &str, ) -> Result { diff --git a/crates/no_std_fs_operations/src/driver.rs b/crates/no_std_fs_operations/src/driver.rs index 98515f4de..379e0cb78 100644 --- a/crates/no_std_fs_operations/src/driver.rs +++ b/crates/no_std_fs_operations/src/driver.rs @@ -4,9 +4,7 @@ use crate::config::{LINT_NAME, load_configuration}; use crate::diagnostics::emit_diagnostic; use crate::exclusion::PathExclusions; -use crate::usage::{ - StdFsUsage, UsageCategory, classify_def_id, classify_qpath, classify_res, label_is_std_fs, -}; +use crate::usage::{StdFsUsage, classify_def_id, classify_qpath, classify_res, label_is_std_fs}; use log::{debug, info}; use rustc_hir as hir; use rustc_hir::AmbigArg; @@ -66,6 +64,10 @@ impl<'tcx> LateLintPass<'tcx> for NoStdFsOperations { } } + /// Classify resolved imports and emit diagnostics for `std::fs` usages. + /// + /// Excluded crates are skipped, as are non-`use` items; each resolved item + /// in a use tree is classified at the import's span. fn check_item(&mut self, cx: &LateContext<'tcx>, item: &'tcx hir::Item<'tcx>) { if self.should_skip() { return; @@ -76,12 +78,17 @@ impl<'tcx> LateLintPass<'tcx> for NoStdFsOperations { span: path.span, }; for res in path.res.present_items() { - let usage = classify_res(cx, res, UsageCategory::Import); + let usage = classify_res(cx, res); self.emit_optional(cx, site, usage); } } } + /// Classify path, struct-construction, and method-call expressions. + /// + /// Excluded crates and unrelated expression kinds are ignored. Method + /// calls use their resolved definition first, then inspect the receiver + /// type when resolution did not identify a `std::fs` operation. fn check_expr(&mut self, cx: &LateContext<'tcx>, expr: &'tcx hir::Expr<'tcx>) { if self.should_skip() { return; @@ -92,18 +99,18 @@ impl<'tcx> LateLintPass<'tcx> for NoStdFsOperations { }; match &expr.kind { hir::ExprKind::Path(qpath) => { - let usage = classify_qpath(cx, qpath, expr.hir_id, UsageCategory::Call); + let usage = classify_qpath(cx, qpath, expr.hir_id); self.emit_optional(cx, site, usage); } hir::ExprKind::Struct(qpath, ..) => { - let usage = classify_qpath(cx, qpath, expr.hir_id, UsageCategory::Call); + let usage = classify_qpath(cx, qpath, expr.hir_id); self.emit_optional(cx, site, usage); } hir::ExprKind::MethodCall(segment, receiver, ..) => { let mut usage = cx .typeck_results() .type_dependent_def_id(expr.hir_id) - .and_then(|def_id| classify_def_id(cx, def_id, UsageCategory::Call)); + .and_then(|def_id| classify_def_id(cx, def_id)); if usage.is_none() { usage = self.receiver_usage_for_method(cx, receiver, segment.ident.as_str()); @@ -115,12 +122,15 @@ impl<'tcx> LateLintPass<'tcx> for NoStdFsOperations { } } + /// Classify path types and emit a diagnostic for a resolved `std::fs` use. + /// + /// Excluded crates and non-path type expressions are ignored. fn check_ty(&mut self, cx: &LateContext<'tcx>, ty: &'tcx hir::Ty<'tcx, AmbigArg>) { if self.should_skip() { return; } if let hir::TyKind::Path(qpath) = &ty.kind { - let usage = classify_qpath(cx, qpath, ty.hir_id, UsageCategory::Type); + let usage = classify_qpath(cx, qpath, ty.hir_id); self.emit_optional( cx, LintSite { @@ -181,6 +191,10 @@ impl NoStdFsOperations { emit_diagnostic(cx, span, usage, &self.localizer); } + /// Infer a filesystem operation from a method receiver's standard type. + /// + /// Returns `None` for non-ADT receivers, types outside `std`, or standard + /// types outside `std::fs`; otherwise returns the qualified operation label. fn receiver_usage_for_method( &self, cx: &LateContext<'_>, @@ -204,7 +218,7 @@ impl NoStdFsOperations { } let operation = format!("{label}::{method}"); - Some(StdFsUsage::new(operation, UsageCategory::Call)) + Some(StdFsUsage::new(operation)) } } diff --git a/crates/no_std_fs_operations/src/exclusion_behaviour.rs b/crates/no_std_fs_operations/src/exclusion_behaviour.rs index e77e9ab8d..542bdd72a 100644 --- a/crates/no_std_fs_operations/src/exclusion_behaviour.rs +++ b/crates/no_std_fs_operations/src/exclusion_behaviour.rs @@ -34,6 +34,8 @@ impl ExclusionWorld { type WorldCell = RefCell; +/// Create the default exclusion behaviour-test world. +#[whitaker_test_macros::allow_fixture_expansion_lints] #[fixture] fn world() -> WorldCell { RefCell::new(ExclusionWorld::default()) diff --git a/crates/no_std_fs_operations/src/usage.rs b/crates/no_std_fs_operations/src/usage/mod.rs similarity index 58% rename from crates/no_std_fs_operations/src/usage.rs rename to crates/no_std_fs_operations/src/usage/mod.rs index f27902607..51bf45488 100644 --- a/crates/no_std_fs_operations/src/usage.rs +++ b/crates/no_std_fs_operations/src/usage/mod.rs @@ -7,53 +7,27 @@ use rustc_lint::LateContext; use rustc_span::sym; use whitaker_common::SimplePath; -/// Category describing how the `std::fs` item is being used. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum UsageCategory { - /// `use std::fs::{..}` imports. - Import, - /// Type positions referencing `std::fs` types (structs, aliases). - Type, - /// Value-level calls, struct literals, or method invocations. - Call, -} - -impl UsageCategory { - /// Returns a stable &str identifier for use in tests and localization. - #[cfg(test)] - #[must_use] - pub const fn as_str(self) -> &'static str { - match self { - Self::Import => "import", - Self::Type => "type", - Self::Call => "call", - } - } -} - /// Normalized view of a `std::fs` operation for diagnostics and tests. #[derive(Clone, Debug, Eq, PartialEq)] pub struct StdFsUsage { operation: String, - category: UsageCategory, } impl StdFsUsage { - /// Construct a new usage instance. + /// Store the resolved operation label used by diagnostics. + /// + /// No usage category is retained; diagnostics are based on this label. #[must_use] /// /// # Examples /// /// ```ignore - /// # use crate::usage::{StdFsUsage, UsageCategory}; - /// let usage = StdFsUsage::new(String::from("std::fs::read"), UsageCategory::Call); + /// # use crate::usage::StdFsUsage; + /// let usage = StdFsUsage::new(String::from("std::fs::read")); /// assert_eq!(usage.operation(), "std::fs::read"); /// ``` - pub fn new(operation: String, category: UsageCategory) -> Self { - Self { - operation, - category, - } + pub fn new(operation: String) -> Self { + Self { operation } } /// Returns the fully qualified operation path (e.g., `std::fs::read`). @@ -62,23 +36,19 @@ impl StdFsUsage { /// # Examples /// /// ```ignore - /// # use crate::usage::{StdFsUsage, UsageCategory}; - /// let usage = StdFsUsage::new(String::from("std::fs::remove_file"), UsageCategory::Call); + /// # use crate::usage::StdFsUsage; + /// let usage = StdFsUsage::new(String::from("std::fs::remove_file")); /// assert_eq!(usage.operation(), "std::fs::remove_file"); /// ``` pub fn operation(&self) -> &str { &self.operation } - - /// Returns the usage category. - #[cfg(test)] - #[must_use] - pub const fn category(&self) -> UsageCategory { - self.category - } } -/// Classify a resolved path (expression, type, import) into a usage record. +/// Classify a resolved path (expression, type, or import) into a usage record. +/// +/// Return `None` when HIR resolution has no definition or the resolved item is +/// not a `std::fs` path. #[must_use] /// /// # Examples @@ -86,22 +56,24 @@ impl StdFsUsage { /// ```ignore /// # use rustc_hir as hir; /// # use rustc_lint::LateContext; -/// # use crate::usage::{classify_qpath, UsageCategory}; +/// # use crate::usage::classify_qpath; /// # fn example<'tcx>(cx: &LateContext<'tcx>, qpath: &hir::QPath<'tcx>, hir_id: hir::HirId) { -/// let _ = classify_qpath(cx, qpath, hir_id, UsageCategory::Call); +/// let _ = classify_qpath(cx, qpath, hir_id); /// # } /// ``` pub fn classify_qpath( cx: &LateContext<'_>, qpath: &hir::QPath<'_>, hir_id: hir::HirId, - category: UsageCategory, ) -> Option { let res = cx.qpath_res(qpath, hir_id); - classify_res(cx, res, category) + classify_res(cx, res) } /// Classify using a `Res` obtained from HIR traversal. +/// +/// Return `None` when the resolution has no `DefId` or does not identify a +/// `std::fs` item. #[must_use] /// /// # Examples @@ -109,17 +81,20 @@ pub fn classify_qpath( /// ```ignore /// # use rustc_hir::def::Res; /// # use rustc_lint::LateContext; -/// # use crate::usage::{classify_res, UsageCategory}; +/// # use crate::usage::classify_res; /// # fn example<'tcx>(cx: &LateContext<'tcx>, res: Res) { -/// let _ = classify_res(cx, res, UsageCategory::Type); +/// let _ = classify_res(cx, res); /// # } /// ``` -pub fn classify_res(cx: &LateContext<'_>, res: Res, category: UsageCategory) -> Option { +pub fn classify_res(cx: &LateContext<'_>, res: Res) -> Option { res.opt_def_id() - .and_then(|def_id| classify_def_id(cx, def_id, category)) + .and_then(|def_id| classify_def_id(cx, def_id)) } /// Classify a `DefId` by inspecting its fully qualified path. +/// +/// Return `None` when the definition belongs to a crate other than `std` or +/// its fully qualified path is outside `std::fs`. #[must_use] /// /// # Examples @@ -127,25 +102,25 @@ pub fn classify_res(cx: &LateContext<'_>, res: Res, category: UsageCategory) -> /// ```ignore /// # use rustc_hir::def_id::DefId; /// # use rustc_lint::LateContext; -/// # use crate::usage::{classify_def_id, UsageCategory}; +/// # use crate::usage::classify_def_id; /// # fn example<'tcx>(cx: &LateContext<'tcx>, def_id: DefId) { -/// let _ = classify_def_id(cx, def_id, UsageCategory::Call); +/// let _ = classify_def_id(cx, def_id); /// # } /// ``` -pub fn classify_def_id( - cx: &LateContext<'_>, - def_id: DefId, - category: UsageCategory, -) -> Option { +pub fn classify_def_id(cx: &LateContext<'_>, def_id: DefId) -> Option { if cx.tcx.crate_name(def_id.krate) != sym::std { return None; } let label = cx.tcx.def_path_str(def_id); - label_is_std_fs(&label).then(|| StdFsUsage::new(label, category)) + label_is_std_fs(&label).then(|| StdFsUsage::new(label)) } +/// Return whether a parsed path is rooted at `std::fs`. +/// +/// The first two segments must be exactly `std` and `fs`; deeper segments are +/// allowed, while shorter or differently rooted paths return `false`. fn is_std_fs_path(path: &SimplePath) -> bool { let segments = path.segments(); segments.len() >= 2 && segments[0] == "std" && segments[1] == "fs" @@ -156,6 +131,10 @@ fn is_invalid_label_char(ch: char) -> bool { ch.is_whitespace() || matches!(ch, '(' | ')') } +/// Validate a resolved definition label as `std::fs` or one of its children. +/// +/// Leading/trailing whitespace, empty labels, whitespace or parentheses +/// within the label, and partial-prefix matches are rejected. pub(crate) fn label_is_std_fs(label: &str) -> bool { if label != label.trim() { return false; diff --git a/crates/no_std_fs_operations/src/usage/tests.rs b/crates/no_std_fs_operations/src/usage/tests.rs index d7c9b7947..5bf11f04e 100644 --- a/crates/no_std_fs_operations/src/usage/tests.rs +++ b/crates/no_std_fs_operations/src/usage/tests.rs @@ -1,7 +1,8 @@ //! Tests for classifying std::fs usage and its reporting metadata. -use super::{StdFsUsage, UsageCategory, label_is_std_fs}; +use super::{StdFsUsage, label_is_std_fs}; use rstest::rstest; +/// Check whether each candidate label names a standard filesystem path. #[rstest] #[case("std::fs", true)] #[case("std::fs::File::open", true)] @@ -21,20 +22,13 @@ use rstest::rstest; #[case("fs::std", false)] #[case("std::", false)] #[case("std::filesystem", false)] -fn recognises_std_fs_paths(#[case] path: &str, #[case] expected: bool) { +fn recognizes_std_fs_paths(#[case] path: &str, #[case] expected: bool) { assert_eq!(label_is_std_fs(path), expected); } -#[rstest] -fn usage_category_labels_are_stable() { - assert_eq!(UsageCategory::Import.as_str(), "import"); - assert_eq!(UsageCategory::Type.as_str(), "type"); - assert_eq!(UsageCategory::Call.as_str(), "call"); -} - +/// Check that the usage builder retains its operation label. #[rstest] fn usage_builder_carries_operation() { - let usage = StdFsUsage::new(String::from("std::fs::read"), UsageCategory::Call); + let usage = StdFsUsage::new(String::from("std::fs::read")); assert_eq!(usage.operation(), "std::fs::read"); - assert_eq!(usage.category(), UsageCategory::Call); } diff --git a/crates/no_std_fs_operations/tests/features/localization.feature b/crates/no_std_fs_operations/tests/features/localization.feature index 41ac1d823..de9376544 100644 --- a/crates/no_std_fs_operations/tests/features/localization.feature +++ b/crates/no_std_fs_operations/tests/features/localization.feature @@ -1,9 +1,9 @@ -Feature: Localised diagnostics for std::fs usage +Feature: Localized diagnostics for std::fs usage Scenario: English messaging encourages cap-std Given the locale "en-GB" is selected And the operation is "std::fs::read_to_string" - When I localise the std::fs diagnostic + When I localize the std::fs diagnostic Then the primary mentions "std::fs::read_to_string" And the note references "cap_std::fs::Dir" And the help references "camino::Utf8Path" @@ -11,26 +11,26 @@ Feature: Localised diagnostics for std::fs usage Scenario: Welsh messaging reflects the capability note Given the locale "cy" is selected And the operation is "std::fs::remove_file" - When I localise the std::fs diagnostic + When I localize the std::fs diagnostic Then the primary mentions "std::fs::remove_file" And the note references "cyfeiriadur" Scenario: Scottish Gaelic messaging mirrors the policy Given the locale "gd" is selected And the operation is "std::fs::metadata" - When I localise the std::fs diagnostic + When I localize the std::fs diagnostic Then the primary mentions "std::fs::metadata" And the note references "neach-gairm" Scenario: Unsupported locale falls back to English Given the locale "zz" is selected And the operation is "std::fs::read_dir" - When I localise the std::fs diagnostic + When I localize the std::fs diagnostic Then the primary mentions "std::fs::read_dir" And the help references "cap_std::fs::Dir" Scenario: Localization failure surfaces the missing key Given localization fails And the operation is "std::fs::canonicalize" - When I localise the std::fs diagnostic + When I localize the std::fs diagnostic Then localization fails for "no_std_fs_operations" diff --git a/crates/no_unwrap_or_else_panic/examples/fail_unwrap_in_rstest_non_test_module.rs b/crates/no_unwrap_or_else_panic/examples/fail_unwrap_in_rstest_non_test_module.rs index 36fd16361..8315dc7d1 100644 --- a/crates/no_unwrap_or_else_panic/examples/fail_unwrap_in_rstest_non_test_module.rs +++ b/crates/no_unwrap_or_else_panic/examples/fail_unwrap_in_rstest_non_test_module.rs @@ -35,7 +35,7 @@ fn parse() { /// Const-only sibling module used as a fixture. /// /// Verifies that `collect_rstest_companion_test_functions` does not mistake an -/// arbitrary `const`-only module for a synthesised rstest harness descriptor. +/// arbitrary `const`-only module for a synthesized rstest harness descriptor. /// A genuine rstest companion module contains `#[test]` functions generated /// by the proc-macro; a module containing only `pub const` items must never /// be treated as one. diff --git a/crates/no_unwrap_or_else_panic/src/context.rs b/crates/no_unwrap_or_else_panic/src/context/mod.rs similarity index 96% rename from crates/no_unwrap_or_else_panic/src/context.rs rename to crates/no_unwrap_or_else_panic/src/context/mod.rs index ffc03bd60..16a0c71fe 100644 --- a/crates/no_unwrap_or_else_panic/src/context.rs +++ b/crates/no_unwrap_or_else_panic/src/context/mod.rs @@ -29,9 +29,12 @@ use rustc_lint::LateContext; #[cfg(feature = "dylint-driver")] use rustc_span::sym; -/// Summarize the context for a given HIR node. +/// Derive test status and `main` membership from a HIR node's ancestry. +/// +/// Ancestor `cfg(test)` attributes contribute to test status alongside the +/// test-like scopes and attributes represented by context entries. #[cfg(feature = "dylint-driver")] -pub(crate) fn summarise_context<'tcx>( +pub(crate) fn summarize_context<'tcx>( cx: &LateContext<'tcx>, hir_id: hir::HirId, ) -> ContextSummary { diff --git a/crates/no_unwrap_or_else_panic/src/driver.rs b/crates/no_unwrap_or_else_panic/src/driver.rs index 2260f891a..eaf1588ea 100644 --- a/crates/no_unwrap_or_else_panic/src/driver.rs +++ b/crates/no_unwrap_or_else_panic/src/driver.rs @@ -77,6 +77,10 @@ impl<'tcx> LateLintPass<'tcx> for NoUnwrapOrElsePanic { self.localizer = get_localizer_for_lint(LINT_NAME, shared_config.locale()); } + /// Diagnose `unwrap_or_else` when an `Option`/`Result` fallback can panic. + /// + /// Calls with another method name or receiver type, a missing/non-closure + /// fallback, or a context accepted by the configured policy are ignored. fn check_expr(&mut self, cx: &LateContext<'tcx>, expr: &'tcx hir::Expr<'tcx>) { let ExprKind::MethodCall(segment, receiver, args, _) = expr.kind else { return; @@ -98,7 +102,7 @@ impl<'tcx> LateLintPass<'tcx> for NoUnwrapOrElsePanic { return; }; - let summary = summarise_context_with_harness( + let summary = summarize_context_with_harness( cx, expr.hir_id, self.is_test_harness, @@ -134,13 +138,13 @@ fn is_inside_harness_test_function<'tcx>( /// Summarizes the lint context for an expression, merging attribute-based and /// harness-based test detection into a single immutable result. -fn summarise_context_with_harness<'tcx>( +fn summarize_context_with_harness<'tcx>( cx: &LateContext<'tcx>, hir_id: hir::HirId, is_test_harness: bool, harness_test_functions: &HashSet, ) -> ContextSummary { - let mut summary = crate::context::summarise_context(cx, hir_id); + let mut summary = crate::context::summarize_context(cx, hir_id); if !summary.is_test && is_test_harness { summary.is_test = is_inside_harness_test_function(cx, hir_id, harness_test_functions); } diff --git a/crates/rstest_helper_should_be_fixture/tests/ui/harness_lock.rs b/crates/rstest_helper_should_be_fixture/tests/ui/harness_lock.rs index 2ffd81892..e4e3a938c 100644 --- a/crates/rstest_helper_should_be_fixture/tests/ui/harness_lock.rs +++ b/crates/rstest_helper_should_be_fixture/tests/ui/harness_lock.rs @@ -19,14 +19,13 @@ use fs2::FileExt; use log::debug; use rstest::rstest; -// The example harness lock coordinates separate nextest processes. Windows CI -// can legitimately hold it for several minutes, so only remove directories -// old enough to be abandoned by a crashed process. -const EXAMPLE_HARNESS_LOCK_STALE_AFTER: Duration = Duration::from_mins(30); +// A live owner keeps the OS liveness lock, so this grace period only delays +// recovery after a process has abandoned its directory. +const EXAMPLE_HARNESS_LOCK_STALE_AFTER: Duration = Duration::from_mins(5); // Bound the default `acquire()` wait so a wedged live owner surfaces a timeout // instead of polling forever. It exceeds the stale-recovery window so genuinely // abandoned locks are reclaimed before this ceiling is reached. -const EXAMPLE_HARNESS_LOCK_ACQUIRE_TIMEOUT: Duration = Duration::from_mins(31); +const EXAMPLE_HARNESS_LOCK_ACQUIRE_TIMEOUT: Duration = Duration::from_mins(6); const EXAMPLE_HARNESS_LOCK_POLL_INTERVAL: Duration = Duration::from_millis(100); const EXAMPLE_HARNESS_LOCK_OWNER_FILENAME: &str = "owner"; const EXAMPLE_HARNESS_LOCK_LIVENESS_EXTENSION: &str = "owner-lock"; diff --git a/crates/test_must_not_have_example/Cargo.toml b/crates/test_must_not_have_example/Cargo.toml index 89905953d..4f0359890 100644 --- a/crates/test_must_not_have_example/Cargo.toml +++ b/crates/test_must_not_have_example/Cargo.toml @@ -34,6 +34,7 @@ log = { workspace = true, optional = true } whitaker = { version = "0.2.9", path = "../../", features = ["dylint-driver"], optional = true } [dev-dependencies] +whitaker_test_macros = { workspace = true } rstest = { workspace = true } rstest-bdd = { workspace = true } rstest-bdd-macros = { workspace = true } diff --git a/crates/test_must_not_have_example/src/behaviour.rs b/crates/test_must_not_have_example/src/behaviour.rs index 522268c86..38cd6135d 100644 --- a/crates/test_must_not_have_example/src/behaviour.rs +++ b/crates/test_must_not_have_example/src/behaviour.rs @@ -26,6 +26,8 @@ impl DocumentationWorld { } } +/// Create an empty world for the documentation behaviour tests. +#[whitaker_test_macros::allow_fixture_expansion_lints] #[fixture] fn world() -> DocumentationWorld { DocumentationWorld::default() diff --git a/crates/whitaker_clones_core/src/index/tests.rs b/crates/whitaker_clones_core/src/index/tests.rs index 2f9871c5d..5f09180c2 100644 --- a/crates/whitaker_clones_core/src/index/tests.rs +++ b/crates/whitaker_clones_core/src/index/tests.rs @@ -1,6 +1,7 @@ //! Unit tests for MinHash and LSH candidate generation. use rstest::{fixture, rstest}; +use whitaker_test_macros::allow_fixture_expansion_lints; use crate::token::Fingerprint; @@ -33,16 +34,22 @@ fn multi_band_config() -> LshConfig { LshConfig::new(32, 4).expect("multi-band config should validate") } +/// Create a deterministic signature shared by fixtures using `[1, 2, 3, 4]`. +#[allow_fixture_expansion_lints] #[fixture] fn shared_signature() -> MinHashSignature { sketch(&[1, 2, 3, 4]) } +/// Create a signature distinct from the shared fixture. +#[allow_fixture_expansion_lints] #[fixture] fn distinct_signature() -> MinHashSignature { sketch(&[8, 9, 10, 11]) } +/// Create the repeated signature used to test identical fragments. +#[allow_fixture_expansion_lints] #[fixture] fn identical_signature() -> MinHashSignature { sketch(&[5, 7, 11, 13]) diff --git a/crates/whitaker_clones_core/src/run0/emit.rs b/crates/whitaker_clones_core/src/run0/emit.rs index 9c9ece949..52bd8d504 100644 --- a/crates/whitaker_clones_core/src/run0/emit.rs +++ b/crates/whitaker_clones_core/src/run0/emit.rs @@ -109,6 +109,11 @@ pub fn emit_run0( Ok(run.build()) } +/// Build one SARIF result for an accepted pair of clone fragments. +/// +/// The primary fragment becomes the result location and the peer becomes a +/// related location. Fingerprint, region, score-conversion, or property-build +/// failures are returned to the caller rather than emitting a partial result. fn build_result( primary: &TokenFragment, peer: &TokenFragment, @@ -160,7 +165,7 @@ fn build_result( text: format!("Peer clone fragment: {}", peer.id().as_str()), }), physical_location: whitaker_sarif::PhysicalLocation { - artifact_location: whitaker_sarif::ArtifactLocation { + artefact_location: whitaker_sarif::ArtefactLocation { uri: peer.file_uri().to_owned(), uri_base_id: None, }, diff --git a/crates/whitaker_clones_core/src/run0/tests.rs b/crates/whitaker_clones_core/src/run0/tests.rs index f33861cf0..aae0d3a4e 100644 --- a/crates/whitaker_clones_core/src/run0/tests.rs +++ b/crates/whitaker_clones_core/src/run0/tests.rs @@ -157,6 +157,7 @@ fn multi_line_region_tracks_trailing_newline() { ); } +/// Verify that run0 emits primary and related SARIF locations. #[test] fn emit_run0_uses_primary_and_related_locations() { let fragments = vec![ @@ -191,13 +192,13 @@ fn emit_run0_uses_primary_and_related_locations() { assert_eq!(result.locations.len(), 1); assert_eq!(result.related_locations.len(), 1); assert_eq!( - result.locations[0].physical_location.artifact_location.uri, + result.locations[0].physical_location.artefact_location.uri, "src/a.rs" ); assert_eq!( result.related_locations[0] .physical_location - .artifact_location + .artefact_location .uri, "src/b.rs" ); diff --git a/crates/whitaker_clones_core/src/run0/types.rs b/crates/whitaker_clones_core/src/run0/types.rs index e11af4c5e..8ee0a8229 100644 --- a/crates/whitaker_clones_core/src/run0/types.rs +++ b/crates/whitaker_clones_core/src/run0/types.rs @@ -71,7 +71,7 @@ impl TokenFragment { self.profile } - /// Returns the source artifact URI used in SARIF output. + /// Returns the source artefact URI used in SARIF output. #[must_use] pub fn file_uri(&self) -> &str { self.file_uri.as_str() diff --git a/crates/whitaker_clones_core/tests/run0_sarif_behaviour.rs b/crates/whitaker_clones_core/tests/run0_sarif_behaviour.rs index 698f84990..2e6c888b7 100644 --- a/crates/whitaker_clones_core/tests/run0_sarif_behaviour.rs +++ b/crates/whitaker_clones_core/tests/run0_sarif_behaviour.rs @@ -304,6 +304,7 @@ fn then_primary_region_is(world: &Run0World, region: String) { }); } +/// Check that the emitted primary location uses the expected file URI. #[then("the primary file is {file_uri}")] fn then_primary_file_is(world: &Run0World, file_uri: String) { with_results(world, |results| { @@ -314,7 +315,7 @@ fn then_primary_file_is(world: &Run0World, file_uri: String) { panic!("a primary location must be present"); }; assert_eq!( - location.physical_location.artifact_location.uri, file_uri, + location.physical_location.artefact_location.uri, file_uri, "primary file URI must match the scenario expectation" ); }); diff --git a/crates/whitaker_sarif/Cargo.toml b/crates/whitaker_sarif/Cargo.toml index 63ec0fce3..7086e155a 100644 --- a/crates/whitaker_sarif/Cargo.toml +++ b/crates/whitaker_sarif/Cargo.toml @@ -24,6 +24,8 @@ rstest = { workspace = true } rstest-bdd = { workspace = true } rstest-bdd-macros = { workspace = true } tempfile = { workspace = true } +proptest = { workspace = true } +trybuild = { workspace = true } [lints.clippy] expect_used = "deny" diff --git a/crates/whitaker_sarif/src/builders/location_builder.rs b/crates/whitaker_sarif/src/builders/location_builder.rs index e3fc7febd..2c14ee6f7 100644 --- a/crates/whitaker_sarif/src/builders/location_builder.rs +++ b/crates/whitaker_sarif/src/builders/location_builder.rs @@ -1,7 +1,7 @@ //! Builders for [`Location`] and [`Region`] objects. use crate::error::SarifError; -use crate::model::location::{ArtifactLocation, Location, PhysicalLocation, Region}; +use crate::model::location::{ArtefactLocation, Location, PhysicalLocation, Region}; /// Fluent builder for constructing a [`Region`]. /// @@ -64,7 +64,7 @@ impl RegionBuilder { self } - /// Sets the byte offset from the start of the artifact. + /// Sets the byte offset from the start of the artefact. #[must_use] pub fn with_byte_offset(mut self, offset: usize) -> Self { self.byte_offset = Some(offset); @@ -163,7 +163,7 @@ impl RegionBuilder { /// let loc = LocationBuilder::new("src/main.rs") /// .with_region(RegionBuilder::new(10).with_end_line(15).build().expect("valid region")) /// .build(); -/// assert_eq!(loc.physical_location.artifact_location.uri, "src/main.rs"); +/// assert_eq!(loc.physical_location.artefact_location.uri, "src/main.rs"); /// ``` #[derive(Debug, Clone)] pub struct LocationBuilder { @@ -190,7 +190,7 @@ impl LocationBuilder { self } - /// Sets the region within the artifact. + /// Sets the region within the artefact. #[must_use] pub fn with_region(mut self, region: Region) -> Self { self.region = Some(region); @@ -202,7 +202,7 @@ impl LocationBuilder { pub fn build(self) -> Location { Location { physical_location: PhysicalLocation { - artifact_location: ArtifactLocation { + artefact_location: ArtefactLocation { uri: self.uri, uri_base_id: self.uri_base_id, }, @@ -252,10 +252,11 @@ mod tests { } } + /// Build a location with only its required artefact URI. #[test] fn location_builder_minimal() { let loc = LocationBuilder::new("src/main.rs").build(); - assert_eq!(loc.physical_location.artifact_location.uri, "src/main.rs"); + assert_eq!(loc.physical_location.artefact_location.uri, "src/main.rs"); assert!(loc.physical_location.region.is_none()); } @@ -274,6 +275,7 @@ mod tests { } } + /// Build a location with a URI base identifier. #[test] fn location_builder_with_base_id() { let loc = LocationBuilder::new("src/lib.rs") @@ -281,7 +283,7 @@ mod tests { .build(); assert_eq!( loc.physical_location - .artifact_location + .artefact_location .uri_base_id .as_deref(), Some("%SRCROOT%") diff --git a/crates/whitaker_sarif/src/builders/log_builder.rs b/crates/whitaker_sarif/src/builders/log_builder.rs index 76911f6a5..91696db58 100644 --- a/crates/whitaker_sarif/src/builders/log_builder.rs +++ b/crates/whitaker_sarif/src/builders/log_builder.rs @@ -98,6 +98,7 @@ mod tests { assert!(log.runs.is_empty()); } + /// Build a log containing a run. #[test] fn builds_log_with_run() { let run = Run { @@ -111,7 +112,7 @@ mod tests { }, invocations: Vec::new(), results: Vec::new(), - artifacts: Vec::new(), + artefacts: Vec::new(), }; let log = SarifLogBuilder::new().with_run(run).build(); assert_eq!(log.runs.len(), 1); diff --git a/crates/whitaker_sarif/src/builders/run_builder.rs b/crates/whitaker_sarif/src/builders/run_builder.rs index e8d1b944c..a436eb3b2 100644 --- a/crates/whitaker_sarif/src/builders/run_builder.rs +++ b/crates/whitaker_sarif/src/builders/run_builder.rs @@ -2,7 +2,7 @@ use crate::model::descriptor::ReportingDescriptor; use crate::model::result::SarifResult; -use crate::model::run::{Artifact, Invocation, Run, Tool, ToolComponent}; +use crate::model::run::{Artefact, Invocation, Run, Tool, ToolComponent}; /// Fluent builder for constructing a [`Run`]. /// @@ -22,7 +22,7 @@ pub struct RunBuilder { rules: Vec, invocations: Vec, results: Vec, - artifacts: Vec, + artefacts: Vec, } impl RunBuilder { @@ -36,7 +36,7 @@ impl RunBuilder { rules: Vec::new(), invocations: Vec::new(), results: Vec::new(), - artifacts: Vec::new(), + artefacts: Vec::new(), } } @@ -117,27 +117,27 @@ impl RunBuilder { self } - /// Appends an artifact reference. + /// Appends an artefact reference. /// /// # Examples /// /// ``` - /// use whitaker_sarif::{RunBuilder, Artifact, ArtifactLocation}; + /// use whitaker_sarif::{RunBuilder, Artefact, ArtefactLocation}; /// /// let run = RunBuilder::new("tool", "1.0") - /// .with_artifact(Artifact { - /// location: ArtifactLocation { + /// .with_artefact(Artefact { + /// location: ArtefactLocation { /// uri: "src/main.rs".into(), /// uri_base_id: None, /// }, /// mime_type: Some("text/x-rust".into()), /// }) /// .build(); - /// assert_eq!(run.artifacts.len(), 1); + /// assert_eq!(run.artefacts.len(), 1); /// ``` #[must_use] - pub fn with_artifact(mut self, artifact: Artifact) -> Self { - self.artifacts.push(artifact); + pub fn with_artefact(mut self, artefact: Artefact) -> Self { + self.artefacts.push(artefact); self } @@ -166,7 +166,7 @@ impl RunBuilder { }, invocations: self.invocations, results: self.results, - artifacts: self.artifacts, + artefacts: self.artefacts, } } } diff --git a/crates/whitaker_sarif/src/lib.rs b/crates/whitaker_sarif/src/lib.rs index bf4cde4a9..63514b6ba 100644 --- a/crates/whitaker_sarif/src/lib.rs +++ b/crates/whitaker_sarif/src/lib.rs @@ -27,7 +27,7 @@ pub use error::{Result, SarifError}; // Model types pub use model::{ - Artifact, ArtifactLocation, Invocation, Level, Location, Message, MultiformatMessageString, + Artefact, ArtefactLocation, Invocation, Level, Location, Message, MultiformatMessageString, PhysicalLocation, Region, RelatedLocation, ReportingDescriptor, Run, SarifLog, SarifResult, Tool, ToolComponent, }; diff --git a/crates/whitaker_sarif/src/merge.rs b/crates/whitaker_sarif/src/merge.rs index 3e76a0116..e16bf4331 100644 --- a/crates/whitaker_sarif/src/merge.rs +++ b/crates/whitaker_sarif/src/merge.rs @@ -66,7 +66,7 @@ fn extract_key(result: &SarifResult) -> Option { .clone(); let location = result.locations.first()?; - let file = location.physical_location.artifact_location.uri.clone(); + let file = location.physical_location.artefact_location.uri.clone(); let region = location.physical_location.region.as_ref()?; Some(ResultKey { @@ -127,7 +127,7 @@ pub fn deduplicate_results(results: &[SarifResult]) -> Vec { /// /// The tool metadata is taken from the first run. Rules are unioned across all /// runs by `id` (first occurrence wins). Results are collected from all runs -/// and deduplicated. Artifacts and invocations are concatenated. +/// and deduplicated. Artefacts and invocations are concatenated. /// /// # Errors /// @@ -152,12 +152,12 @@ pub fn merge_runs(runs: &[Run]) -> Result { tool.driver.rules = union_rules(runs); let mut all_results = Vec::new(); - let mut all_artifacts = Vec::new(); + let mut all_artefacts = Vec::new(); let mut all_invocations = Vec::new(); for run in runs { all_results.extend(run.results.clone()); - all_artifacts.extend(run.artifacts.clone()); + all_artefacts.extend(run.artefacts.clone()); all_invocations.extend(run.invocations.clone()); } @@ -167,7 +167,7 @@ pub fn merge_runs(runs: &[Run]) -> Result { tool, invocations: all_invocations, results, - artifacts: all_artifacts, + artefacts: all_artefacts, }) } diff --git a/crates/whitaker_sarif/src/model/location.rs b/crates/whitaker_sarif/src/model/location.rs index 06d8389d8..895d34bc1 100644 --- a/crates/whitaker_sarif/src/model/location.rs +++ b/crates/whitaker_sarif/src/model/location.rs @@ -2,21 +2,21 @@ //! //! These types describe where a result was found in source code. A //! [`Location`] wraps a [`PhysicalLocation`] which combines an -//! [`ArtifactLocation`] (file URI) with an optional [`Region`] (line and +//! [`ArtefactLocation`] (file URI) with an optional [`Region`] (line and //! column spans). use serde::{Deserialize, Serialize}; -/// A location within an artifact (source file). +/// A location within an artefact (source file). /// /// # Examples /// /// ``` -/// use whitaker_sarif::{Location, PhysicalLocation, ArtifactLocation, Region}; +/// use whitaker_sarif::{Location, PhysicalLocation, ArtefactLocation, Region}; /// /// let loc = Location { /// physical_location: PhysicalLocation { -/// artifact_location: ArtifactLocation { +/// artefact_location: ArtefactLocation { /// uri: "src/main.rs".into(), /// uri_base_id: None, /// }, @@ -30,7 +30,7 @@ use serde::{Deserialize, Serialize}; /// }), /// }, /// }; -/// assert_eq!(loc.physical_location.artifact_location.uri, "src/main.rs"); +/// assert_eq!(loc.physical_location.artefact_location.uri, "src/main.rs"); /// ``` #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -43,19 +43,23 @@ pub struct Location { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PhysicalLocation { - /// Identifies the artifact (file). - pub artifact_location: ArtifactLocation, - - /// Optional region within the artifact. + /// Identifies the artefact (file). + /// + /// The SARIF 2.1.0 schema spells this property `artifactLocation`, so the + /// wire name is pinned here rather than derived from the field name. + #[serde(rename = "artifactLocation")] + pub artefact_location: ArtefactLocation, + + /// Optional region within the artefact. #[serde(default, skip_serializing_if = "Option::is_none")] pub region: Option, } -/// A reference to an artifact by URI. +/// A reference to an artefact by URI. #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] -pub struct ArtifactLocation { - /// Relative or absolute URI of the artifact. +pub struct ArtefactLocation { + /// Relative or absolute URI of the artefact. pub uri: String, /// Base identifier for resolving relative URIs. @@ -63,7 +67,7 @@ pub struct ArtifactLocation { pub uri_base_id: Option, } -/// A region within an artifact, identified by line and column numbers. +/// A region within an artefact, identified by line and column numbers. /// /// `start_line` is always required (1-based). All other fields are optional. /// @@ -100,7 +104,7 @@ pub struct Region { #[serde(default, skip_serializing_if = "Option::is_none")] pub end_column: Option, - /// Optional byte offset from the start of the artifact. + /// Optional byte offset from the start of the artefact. #[serde(default, skip_serializing_if = "Option::is_none")] pub byte_offset: Option, @@ -166,11 +170,12 @@ mod tests { }); } + /// Check that a location survives JSON serialization and deserialization. #[test] fn location_round_trip() { let loc = Location { physical_location: PhysicalLocation { - artifact_location: ArtifactLocation { + artefact_location: ArtefactLocation { uri: "src/lib.rs".into(), uri_base_id: Some("%SRCROOT%".into()), }, @@ -187,6 +192,7 @@ mod tests { assert_json_round_trip(&loc); } + /// Check that a related location survives a JSON round trip. #[test] fn related_location_round_trip() { let rl = RelatedLocation { @@ -195,7 +201,7 @@ mod tests { text: "peer fragment".into(), }), physical_location: PhysicalLocation { - artifact_location: ArtifactLocation { + artefact_location: ArtefactLocation { uri: "src/other.rs".into(), uri_base_id: None, }, diff --git a/crates/whitaker_sarif/src/model/mod.rs b/crates/whitaker_sarif/src/model/mod.rs index a38b1493c..7869f6e4d 100644 --- a/crates/whitaker_sarif/src/model/mod.rs +++ b/crates/whitaker_sarif/src/model/mod.rs @@ -5,10 +5,10 @@ //! //! - [`log`] — top-level [`SarifLog`] container. //! - [`run`] — [`Run`], [`Tool`], [`ToolComponent`], [`Invocation`], and -//! [`Artifact`]. +//! [`Artefact`]. //! - [`result`] — [`SarifResult`], [`Level`], and [`Message`]. //! - [`location`] — [`Location`], [`PhysicalLocation`], -//! [`ArtifactLocation`], [`Region`], and [`RelatedLocation`]. +//! [`ArtefactLocation`], [`Region`], and [`RelatedLocation`]. //! - [`descriptor`] — [`ReportingDescriptor`] and //! [`MultiformatMessageString`]. //! @@ -22,7 +22,7 @@ pub mod result; pub mod run; pub use descriptor::{MultiformatMessageString, ReportingDescriptor}; -pub use location::{ArtifactLocation, Location, PhysicalLocation, Region, RelatedLocation}; +pub use location::{ArtefactLocation, Location, PhysicalLocation, Region, RelatedLocation}; pub use log::SarifLog; pub use result::{Level, Message, SarifResult}; -pub use run::{Artifact, Invocation, Run, Tool, ToolComponent}; +pub use run::{Artefact, Invocation, Run, Tool, ToolComponent}; diff --git a/crates/whitaker_sarif/src/model/run.rs b/crates/whitaker_sarif/src/model/run.rs index 7368b2332..0aeb2c2c4 100644 --- a/crates/whitaker_sarif/src/model/run.rs +++ b/crates/whitaker_sarif/src/model/run.rs @@ -1,9 +1,9 @@ -//! SARIF run, tool, invocation, and artifact types. +//! SARIF run, tool, invocation, and artefact types. //! //! A [`Run`] represents a single execution of an analysis tool. It contains //! the [`Tool`] that produced the results, optional [`Invocation`] metadata, //! the [`SarifResult`] findings, and any referenced -//! [`Artifact`]s. +//! [`Artefact`]s. use serde::{Deserialize, Serialize}; @@ -28,7 +28,7 @@ use super::result::SarifResult; /// }, /// invocations: Vec::new(), /// results: Vec::new(), -/// artifacts: Vec::new(), +/// artefacts: Vec::new(), /// }; /// assert_eq!(run.tool.driver.name, "whitaker_clones_cli"); /// ``` @@ -46,9 +46,12 @@ pub struct Run { #[serde(default, skip_serializing_if = "Vec::is_empty")] pub results: Vec, - /// Referenced source artifacts. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub artifacts: Vec, + /// Referenced source artefacts. + /// + /// The SARIF 2.1.0 schema spells this property `artifacts`, so the wire + /// name is pinned here rather than derived from the field name. + #[serde(default, rename = "artifacts", skip_serializing_if = "Vec::is_empty")] + pub artefacts: Vec, } /// The analysis tool that produced a run. @@ -117,27 +120,27 @@ pub struct Invocation { pub command_line: Option, } -/// A source artifact referenced by results. +/// A source artefact referenced by results. /// /// # Examples /// /// ``` -/// use whitaker_sarif::{Artifact, ArtifactLocation}; +/// use whitaker_sarif::{Artefact, ArtefactLocation}; /// -/// let artifact = Artifact { -/// location: ArtifactLocation { +/// let artefact = Artefact { +/// location: ArtefactLocation { /// uri: "src/main.rs".into(), /// uri_base_id: None, /// }, /// mime_type: Some("text/x-rust".into()), /// }; -/// assert_eq!(artifact.location.uri, "src/main.rs"); +/// assert_eq!(artefact.location.uri, "src/main.rs"); /// ``` #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] -pub struct Artifact { - /// Location of the artifact. - pub location: super::location::ArtifactLocation, +pub struct Artefact { + /// Location of the artefact. + pub location: super::location::ArtefactLocation, /// Optional MIME type. #[serde(default, skip_serializing_if = "Option::is_none")] @@ -150,9 +153,10 @@ mod tests { //! shared JSON assertion helpers. use super::*; - use crate::model::location::ArtifactLocation; + use crate::model::location::ArtefactLocation; use crate::test_support::{assert_json_round_trip, assert_serialized_json}; + /// Check that a run survives JSON serialization and deserialization. #[test] fn run_round_trip() { let run = Run { @@ -169,11 +173,12 @@ mod tests { command_line: None, }], results: Vec::new(), - artifacts: Vec::new(), + artefacts: Vec::new(), }; assert_json_round_trip(&run); } + /// Check that empty run collections are omitted from serialized JSON. #[test] fn empty_collections_omitted_from_run() { let run = Run { @@ -187,7 +192,7 @@ mod tests { }, invocations: Vec::new(), results: Vec::new(), - artifacts: Vec::new(), + artefacts: Vec::new(), }; assert_serialized_json(&run, |json| { assert!( @@ -206,10 +211,11 @@ mod tests { }); } + /// Check that an artefact survives JSON serialization and deserialization. #[test] - fn artifact_round_trip() { - let artefact = Artifact { - location: ArtifactLocation { + fn artefact_round_trip() { + let artefact = Artefact { + location: ArtefactLocation { uri: "src/lib.rs".into(), uri_base_id: Some("%SRCROOT%".into()), }, diff --git a/crates/whitaker_sarif/tests/api_contract.rs b/crates/whitaker_sarif/tests/api_contract.rs new file mode 100644 index 000000000..3737b1d46 --- /dev/null +++ b/crates/whitaker_sarif/tests/api_contract.rs @@ -0,0 +1,10 @@ +//! Compile-time contracts for the externally exposed SARIF spelling changes. + +/// Verify that renamed SARIF APIs compile and legacy names remain removed. +#[test] +fn renamed_sarif_api_is_public_and_legacy_names_stay_removed() { + let cases = trybuild::TestCases::new(); + cases.pass("tests/ui/pass_renamed_sarif_api.rs"); + cases.compile_fail("tests/ui/legacy_sarif_api.rs"); + cases.compile_fail("tests/ui/legacy_sarif_fields.rs"); +} diff --git a/crates/whitaker_sarif/tests/ui/legacy_sarif_api.rs b/crates/whitaker_sarif/tests/ui/legacy_sarif_api.rs new file mode 100644 index 000000000..d31b826c8 --- /dev/null +++ b/crates/whitaker_sarif/tests/ui/legacy_sarif_api.rs @@ -0,0 +1,14 @@ +//! Compile-fail contract for removed American-spelled SARIF API names. + +use whitaker_sarif::{Artifact, ArtifactLocation, RunBuilder}; + +/// Exercise removed SARIF API names in the compile-fail fixture. +fn main() { + let _ = RunBuilder::new("whitaker", "0.3.0").with_artifact(Artifact { + location: ArtifactLocation { + uri: "src/main.rs".into(), + uri_base_id: None, + }, + mime_type: None, + }); +} diff --git a/crates/whitaker_sarif/tests/ui/legacy_sarif_api.stderr b/crates/whitaker_sarif/tests/ui/legacy_sarif_api.stderr new file mode 100644 index 000000000..280a4107c --- /dev/null +++ b/crates/whitaker_sarif/tests/ui/legacy_sarif_api.stderr @@ -0,0 +1,30 @@ +error[E0432]: unresolved imports `whitaker_sarif::Artifact`, `whitaker_sarif::ArtifactLocation` + --> tests/ui/legacy_sarif_api.rs:3:22 + | +3 | use whitaker_sarif::{Artifact, ArtifactLocation, RunBuilder}; + | ^^^^^^^^ ^^^^^^^^^^^^^^^^ no `ArtifactLocation` in the root + | | + | no `Artifact` in the root + | +help: a similar name exists in the module + | +3 - use whitaker_sarif::{Artifact, ArtifactLocation, RunBuilder}; +3 + use whitaker_sarif::{Artefact, ArtifactLocation, RunBuilder}; + | +help: a similar name exists in the module + | +3 - use whitaker_sarif::{Artifact, ArtifactLocation, RunBuilder}; +3 + use whitaker_sarif::{Artifact, ArtefactLocation, RunBuilder}; + | + +error[E0599]: no method named `with_artifact` found for struct `RunBuilder` in the current scope + --> tests/ui/legacy_sarif_api.rs:7:50 + | +7 | let _ = RunBuilder::new("whitaker", "0.3.0").with_artifact(Artifact { + | -------------------------------------^^^^^^^^^^^^^ + | +help: there is a method `with_artefact` with a similar name + | +7 - let _ = RunBuilder::new("whitaker", "0.3.0").with_artifact(Artifact { +7 + let _ = RunBuilder::new("whitaker", "0.3.0").with_artefact(Artifact { + | diff --git a/crates/whitaker_sarif/tests/ui/legacy_sarif_fields.rs b/crates/whitaker_sarif/tests/ui/legacy_sarif_fields.rs new file mode 100644 index 000000000..e7ae6f5d2 --- /dev/null +++ b/crates/whitaker_sarif/tests/ui/legacy_sarif_fields.rs @@ -0,0 +1,23 @@ +//! Compile-fail contract for removed SARIF model and builder field spellings. + +use whitaker_sarif::{Artefact, ArtefactLocation, PhysicalLocation, RunBuilder}; + +/// Exercise removed SARIF field names in the compile-fail fixture. +fn main() { + let _location = PhysicalLocation { + artifact_location: ArtefactLocation { + uri: "src/main.rs".into(), + uri_base_id: None, + }, + region: None, + }; + let run = RunBuilder::new("whitaker", "0.3.0").build(); + let _ = run.artifacts; + let _ = RunBuilder::new("whitaker", "0.3.0").with_artifact(Artefact { + location: ArtefactLocation { + uri: "src/main.rs".into(), + uri_base_id: None, + }, + mime_type: None, + }); +} diff --git a/crates/whitaker_sarif/tests/ui/legacy_sarif_fields.stderr b/crates/whitaker_sarif/tests/ui/legacy_sarif_fields.stderr new file mode 100644 index 000000000..96c7eaa1a --- /dev/null +++ b/crates/whitaker_sarif/tests/ui/legacy_sarif_fields.stderr @@ -0,0 +1,35 @@ +error[E0560]: struct `PhysicalLocation` has no field named `artifact_location` + --> tests/ui/legacy_sarif_fields.rs:8:9 + | +8 | artifact_location: ArtefactLocation { + | ^^^^^^^^^^^^^^^^^ unknown field + | +help: a field with a similar name exists + | +8 - artifact_location: ArtefactLocation { +8 + artefact_location: ArtefactLocation { + | + +error[E0609]: no field `artifacts` on type `Run` + --> tests/ui/legacy_sarif_fields.rs:15:17 + | +15 | let _ = run.artifacts; + | ^^^^^^^^^ unknown field + | +help: a field with a similar name exists + | +15 - let _ = run.artifacts; +15 + let _ = run.artefacts; + | + +error[E0599]: no method named `with_artifact` found for struct `RunBuilder` in the current scope + --> tests/ui/legacy_sarif_fields.rs:16:50 + | +16 | let _ = RunBuilder::new("whitaker", "0.3.0").with_artifact(Artefact { + | -------------------------------------^^^^^^^^^^^^^ + | +help: there is a method `with_artefact` with a similar name + | +16 - let _ = RunBuilder::new("whitaker", "0.3.0").with_artifact(Artefact { +16 + let _ = RunBuilder::new("whitaker", "0.3.0").with_artefact(Artefact { + | diff --git a/crates/whitaker_sarif/tests/ui/pass_renamed_sarif_api.rs b/crates/whitaker_sarif/tests/ui/pass_renamed_sarif_api.rs new file mode 100644 index 000000000..e89581463 --- /dev/null +++ b/crates/whitaker_sarif/tests/ui/pass_renamed_sarif_api.rs @@ -0,0 +1,26 @@ +//! Compile contract for the public Oxford-spelled SARIF model and builder API. + +use whitaker_sarif::{Artefact, ArtefactLocation, PhysicalLocation, RunBuilder}; + +/// Exercise the renamed SARIF API in the compile-pass fixture. +fn main() { + let artefact = Artefact { + location: ArtefactLocation { + uri: "src/main.rs".into(), + uri_base_id: None, + }, + mime_type: None, + }; + let run = RunBuilder::new("whitaker", "0.3.0") + .with_artefact(artefact) + .build(); + let location = PhysicalLocation { + artefact_location: ArtefactLocation { + uri: "src/main.rs".into(), + uri_base_id: None, + }, + region: None, + }; + assert_eq!(run.artefacts.len(), 1); + assert_eq!(location.artefact_location.uri, "src/main.rs"); +} diff --git a/crates/whitaker_sarif/tests/wire_contracts.rs b/crates/whitaker_sarif/tests/wire_contracts.rs new file mode 100644 index 000000000..700d613a0 --- /dev/null +++ b/crates/whitaker_sarif/tests/wire_contracts.rs @@ -0,0 +1,194 @@ +//! Regression and property coverage for the SARIF 2.1.0 JSON boundary. + +use proptest::prelude::*; +use serde_json::{Value, json}; +use whitaker_sarif::{Artefact, ArtefactLocation, PhysicalLocation, Region, Run, RunBuilder}; + +/// Generate varied relative Rust-source URIs for serialization properties. +fn uri_strategy() -> impl Strategy { + proptest::collection::vec(b'a'..=b'z', 1..32).prop_map(|bytes| { + let path: String = bytes.into_iter().map(char::from).collect(); + format!("src/{path}.rs") + }) +} + +/// Check the canonical physical-location key and reject Rust-name variants. +fn assert_physical_location_wire_keys(json: &Value) { + assert!(json.get("artifactLocation").is_some()); + assert!(json.get("artefactLocation").is_none()); + assert!(json.get("artefact_location").is_none()); +} + +/// Check the canonical run artefact key and reject the Rust field spelling. +fn assert_run_wire_keys(json: &Value) { + assert!(json.get("artifacts").is_some()); + assert!(json.get("artefacts").is_none()); +} + +/// Verify populated locations use the SARIF key and decode canonical JSON. +#[test] +fn populated_physical_location_uses_the_canonical_sarif_property() -> Result<(), serde_json::Error> +{ + let physical = PhysicalLocation { + artefact_location: ArtefactLocation { + uri: "src/main.rs".into(), + uri_base_id: Some("%SRCROOT%".into()), + }, + region: Some(Region { + start_line: 12, + start_column: Some(4), + end_line: Some(14), + end_column: Some(19), + byte_offset: Some(128), + byte_length: Some(64), + }), + }; + + let serialized = serde_json::to_value(&physical)?; + assert_physical_location_wire_keys(&serialized); + assert_eq!( + serialized["artifactLocation"], + json!({"uri": "src/main.rs", "uriBaseId": "%SRCROOT%"}) + ); + + let canonical = json!({ + "artifactLocation": {"uri": "src/fixed.rs", "uriBaseId": "%SRCROOT%"}, + "region": {"startLine": 7, "endLine": 8} + }); + let decoded: PhysicalLocation = serde_json::from_value(canonical.clone())?; + assert_eq!(decoded.artefact_location.uri, "src/fixed.rs"); + assert_eq!( + decoded.artefact_location.uri_base_id.as_deref(), + Some("%SRCROOT%") + ); + assert_eq!( + decoded.region.as_ref().map(|region| region.start_line), + Some(7) + ); + assert_eq!(serde_json::to_value(decoded)?, canonical); + Ok(()) +} + +/// Verify the public run builder and model retain the canonical `artifacts` key. +#[test] +fn populated_run_builder_preserves_the_canonical_sarif_property() -> Result<(), serde_json::Error> { + let artefact = Artefact { + location: ArtefactLocation { + uri: "src/main.rs".into(), + uri_base_id: Some("%SRCROOT%".into()), + }, + mime_type: Some("text/x-rust".into()), + }; + let run = RunBuilder::new("whitaker", "0.3.0") + .with_artefact(artefact.clone()) + .build(); + assert_eq!(run.artefacts, vec![artefact]); + + let serialized = serde_json::to_value(&run)?; + assert_run_wire_keys(&serialized); + assert_eq!(serialized["artifacts"][0]["location"]["uri"], "src/main.rs"); + + let canonical = json!({ + "tool": {"driver": {"name": "whitaker", "version": "0.3.0"}}, + "artifacts": [{ + "location": {"uri": "src/fixed.rs", "uriBaseId": "%SRCROOT%"}, + "mimeType": "text/x-rust" + }] + }); + let decoded: Run = serde_json::from_value(canonical.clone())?; + assert_eq!( + decoded.artefacts, + vec![Artefact { + location: ArtefactLocation { + uri: "src/fixed.rs".into(), + uri_base_id: Some("%SRCROOT%".into()), + }, + mime_type: Some("text/x-rust".into()), + }] + ); + assert_eq!(serde_json::to_value(decoded)?, canonical); + Ok(()) +} + +proptest! { + /// Check varied URIs, optional base IDs, and optional regions retain the + /// canonical location key and survive JSON round-trips. + #[test] + fn generated_physical_locations_keep_the_canonical_wire_key( + uri in uri_strategy(), + uri_base_id in prop::option::of(uri_strategy()), + region in prop::option::of((1_usize..=1000, prop::option::of(1_usize..=500)).prop_map(|(start_line, start_column)| Region { + start_line, + start_column, + end_line: None, + end_column: None, + byte_offset: None, + byte_length: None, + })), + ) { + let physical = PhysicalLocation { + artefact_location: ArtefactLocation { uri, uri_base_id }, + region, + }; + let serialized = serde_json::to_value(&physical); + prop_assert!(serialized.is_ok()); + let serialized = serialized.unwrap_or(Value::Null); + prop_assert!(serialized.get("artifactLocation").is_some()); + prop_assert!(serialized.get("artefactLocation").is_none()); + prop_assert!(serialized.get("artefact_location").is_none()); + + let decoded = serde_json::from_value::(serialized.clone()); + prop_assert!(decoded.is_ok()); + let decoded = decoded.unwrap_or_else(|_| physical.clone()); + prop_assert_eq!(decoded.clone(), physical); + let round_trip = serde_json::to_value(decoded); + prop_assert!(round_trip.is_ok()); + let round_trip = round_trip.unwrap_or(Value::Null); + prop_assert_eq!(round_trip, serialized); + } + + /// Check non-empty artefact lists with optional base IDs and MIME types + /// retain the canonical run key and survive JSON round-trips. + #[test] + fn generated_runs_keep_the_canonical_wire_key_and_round_trip( + artefacts in prop::collection::vec( + (uri_strategy(), prop::option::of(uri_strategy()), prop::option::of(any::()).prop_map(|value| value.map(|_| "text/x-rust".to_owned()))), + 1..5 + ), + ) { + let expected_artefacts: Vec = artefacts + .into_iter() + .map(|(uri, uri_base_id, mime_type)| Artefact { + location: ArtefactLocation { uri, uri_base_id }, + mime_type, + }) + .collect(); + let run = expected_artefacts + .iter() + .cloned() + .fold(RunBuilder::new("whitaker", "0.3.0"), |builder, artefact| { + builder.with_artefact(artefact) + }) + .build(); + prop_assert!(!run.artefacts.is_empty()); + + let serialized = serde_json::to_value(&run); + prop_assert!(serialized.is_ok()); + let serialized = serialized.unwrap_or(Value::Null); + prop_assert!(serialized.get("artifacts").is_some()); + prop_assert!(serialized.get("artefacts").is_none()); + prop_assert_eq!( + serialized["artifacts"].as_array().map(|artefacts| artefacts.len()), + Some(run.artefacts.len()) + ); + + let decoded = serde_json::from_value::(serialized.clone()); + prop_assert!(decoded.is_ok()); + let decoded = decoded.unwrap_or_else(|_| run.clone()); + prop_assert_eq!(decoded.artefacts.as_slice(), expected_artefacts.as_slice()); + let round_trip = serde_json::to_value(decoded); + prop_assert!(round_trip.is_ok()); + let round_trip = round_trip.unwrap_or(Value::Null); + prop_assert_eq!(round_trip, serialized); + } +} diff --git a/crates/whitaker_test_macros/tests/ui/fixture_expansion_lint.rs b/crates/whitaker_test_macros/tests/ui/fixture_expansion_lint.rs index d1f1568c2..e04ee6b19 100644 --- a/crates/whitaker_test_macros/tests/ui/fixture_expansion_lint.rs +++ b/crates/whitaker_test_macros/tests/ui/fixture_expansion_lint.rs @@ -1,4 +1,4 @@ -//! Passing trybuild fixture proving the fixture-expansion lint attribute works +//! Passing trybuild fixture verifying suppression of fixture-expansion lints //! with a denied `unused_braces` lint. //! //! The fixture applies `allow_fixture_expansion_lints` to the small function diff --git a/crates/whitaker_test_macros/tests/ui/fixture_expansion_lint_arguments.rs b/crates/whitaker_test_macros/tests/ui/fixture_expansion_lint_arguments.rs index 132fb1510..5f695c3bd 100644 --- a/crates/whitaker_test_macros/tests/ui/fixture_expansion_lint_arguments.rs +++ b/crates/whitaker_test_macros/tests/ui/fixture_expansion_lint_arguments.rs @@ -1,5 +1,5 @@ -//! Compile-fail trybuild fixture proving the fixture-expansion lint attribute -//! rejects unexpected arguments. +//! Compile-fail trybuild fixture verifying rejection of invalid +//! `allow_fixture_expansion_lints` arguments. //! //! The invalid invocation exercises the diagnostic contract for //! `allow_fixture_expansion_lints` while keeping the failure isolated from the diff --git a/docs/contents.md b/docs/contents.md index 4c917f11e..0115566ba 100644 --- a/docs/contents.md +++ b/docs/contents.md @@ -7,6 +7,8 @@ - [User's guide](users-guide.md) explains how to install, configure, and use Whitaker and its lints. +- [Whitaker 0.3.0 migration guide](v0-3-0-migration-guide.md) lists the + source-breaking public API renames and upgrade mappings for library consumers. - [Developer's guide](developers-guide.md) explains how to build, test, verify, and extend Whitaker as a maintainer or contributor. - [Repository layout](repository-layout.md) maps the main directories, crates, diff --git a/docs/developers-guide.md b/docs/developers-guide.md index 30231f93b..35ab73668 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -278,6 +278,11 @@ later assertion steps to return without inspecting missing output, and is reported through `rstest_bdd::skip!`. Keep these skip rules in the shared setup helpers so all scenario variants retain the same isolation and lifecycle. +The PATH-scan tracing test in `installer/src/tracing_config.rs` re-executes its +test binary in a child process to exercise the real CLI flags and diagnostic. +Its probe helper belongs only to that test; the child boundary keeps tracing +subscriber setup and the test `PATH` isolated from the parent process. + ### Test profiles By default, `make test` excludes slow installer integration tests @@ -303,7 +308,9 @@ are never silently skipped in the pipeline. `make test`, but runs it through `cargo llvm-cov nextest`. The Makefile assigns the absolute `COVERAGE_TARGET_DIR` (by default, `$(CURDIR)/target/llvm-cov-target`) to both `CARGO_LLVM_COV_TARGET_DIR` and -`CARGO_TARGET_DIR`. +`CARGO_TARGET_DIR`. It also sets `LLVM_PROFILE_FILE_NAME` to +`whitaker-%m.profraw`, so LLVM merges each instrumented binary's counters +online instead of retaining a separate raw profile for every Nextest process. The first variable makes `cargo-llvm-cov` instrument its output in that directory. The second is inherited by nested Cargo commands that Dylint UI @@ -1172,7 +1179,7 @@ worker count rather than `-n auto`. #### Coverage lane time limits -`coverage-check` and `coverage-upload` run under the 100-minute ceiling that +`coverage-check` and `coverage-upload` run under the 120-minute ceiling that "Test timeouts: four tiers, outermost last" derives from the suite's budgets, and that section's contract pins it. The ceiling must also hold a cold compiler cache, not only a warm one. A cold cache is not rare: every change to the @@ -1194,8 +1201,8 @@ CodeScene upload and three cache saves), could not finish cold at all. A cancelled writer keeps the compiler-cache entries it stored while it compiled, which is why the next trunk run was warm, but it loses its doctests, its CodeScene upload and its archive saves. The slowest cold job measured, 38m45s, -sits well inside the 100-minute ceiling, which leaves `coverage-upload` about -an hour after a cold `Generate coverage` for its doctests, upload and saves. +sits well inside the 120-minute ceiling, which leaves `coverage-upload` about +81 minutes after a cold `Generate coverage` for its doctests, upload and saves. Re-measure the cold figure after a change that makes the suite materially larger; if it approaches the ceiling, the tier derivation is where to raise it, not a per-lane value. @@ -2421,7 +2428,7 @@ no longer implements `io::Write`, so neither `format!("{:x}", digest)` nor shared by `downloader.rs`, `artefact/packaging.rs`, and the `sha256_hex` test helper. -`installer/src/deps.rs` drives the high-level fallback order: +`installer/src/deps/mod.rs` drives the high-level fallback order: 1. Attempt the repository-hosted dependency archive for the current target. 2. Verify the installed tool is now usable. `cargo-dylint` is checked by @@ -2495,27 +2502,79 @@ A Cargo-managed `dylint-link` already on `PATH` is checked by resolving an executable file and comparing the version Cargo recorded for it, which needs no execution either. -The `dylint-link` verification in `installer/src/deps.rs` is implemented by -five small private helpers: - -- `find_binary_on_path(binary_name)` returns the first executable candidate so - the installation check can validate the exact path it found. -- `find_binary_in_directory(directory, binary_name)` performs the per-directory - search that `find_binary_on_path()` uses while walking `PATH`. -- `binary_candidates(directory, binary_name)` builds the ordered set of - candidate paths that each directory contributes to the lookup. -- `is_executable_file(path)` applies the platform-specific file test: - executable-bit plus regular-file checks on Unix, and `path.is_file()` on - non-Unix targets where the executable suffix carries the meaning. +The `dylint-link` verification in `installer/src/deps/mod.rs` is implemented by +six small private helpers: + +- `find_binary_on_path(binary_name)` returns a `PathScanResult` containing the + first executable path and classified failures encountered during the scan. +- `scan_path_directories(directories, binary_name)` scans the supplied ordered + `PathBuf` entries and returns a `PathScanResult` with the first match and + failures from entries checked before that match. +- `find_binary_in_directory` checks candidates relative to one PATH entry and + returns the first match joined to the original directory. Its inputs make the + path, binary name, and failure accumulator explicit: + + ```rust + fn find_binary_in_directory( + directory: &Path, + binary_name: &str, + failures: &mut Vec, + ) -> Option + ``` + + It converts the entry to `camino::Utf8Path`, opens a `cap_std::fs_utf8::Dir`, + and checks candidate names relative to that capability. Non-UTF-8 entries and + directories that cannot be opened are skipped; classified failures are + appended without retaining the path. +- `binary_candidates(binary_name: &str) -> Vec` returns candidate + executable names in probe order. Unix returns the requested name unchanged. + Windows preserves a name with an extension as its sole candidate; otherwise, + it appends the `PATHEXT` suffixes in configured order, skipping suffixes + already present at the end of the name, case-insensitively. +- `is_executable_file` has this signature: + + ```rust + fn is_executable_file( + directory: &Dir, + candidate: &Utf8Path, + failures: &mut Vec, + ) -> bool + ``` + + It obtains metadata with `Dir::metadata`, requires a regular file and, on + Unix, at least one executable permission bit. Ordinary `NotFound` misses add + no failure category, while other metadata errors add the bounded `metadata` + category to the caller-provided list. - `windows_path_extensions()` normalizes `PATHEXT` on Windows so `binary_candidates()` can expand extensionless names the same way the shell does. +`PathScanResult` is a narrowly scoped report for this PATH scan: it carries the +first matching path and the bounded categories encountered while checking +entries. It is not a reusable filesystem abstraction. The scan helpers return +these classifications without logging. At verbose level, the installer CLI +emits structured debug events with `binary_name` and `failure_category`; it +does not record PATH values or candidate paths. + +`check_dylint_tools()` returns `Result`. A successful status +with a `false` field means the query established that a tool is absent or does +not match the pinned version. Command-spawn and Cargo-registry errors propagate +as errors; if PATH scanning finds no binary after a non-missing lookup failure, +it returns `InstallerError::DependencyCheck`. The install flow only starts +installation after a successful query reports a missing tool. The query uses the +`CommandExecutor` and `BinaryLocator` interfaces, and returns PATH diagnostics +without emitting tracing events. The CLI logs those diagnostics at the command +boundary. Cargo's explicit `no such command: dylint` response is treated as an +absent tool; other nonzero command results and successful version probes that +report no semantic version are query errors. + These key helpers are covered by direct unit tests in `installer/src/deps/path_tests.rs` for missing PATH values, empty PATH values, multiple PATH directories, non-executable Unix files, executable Unix files, broken PATH shims, and Windows `PATHEXT` resolution via both direct helper -tests and `check_dylint_tools()`. +tests and `check_dylint_tools()`. Availability tests also verify that query +failures remain errors, do not emit tracing events, and stop before the install +callback. Installer PATH-fixture helpers now live in `installer/src/test_utils/dependency_binary_helpers.rs` instead of being @@ -2532,6 +2591,12 @@ duplicated across multiple test modules. The key helpers are: by `installer/src/tests.rs` to force the direct Cargo fallback path without network access. +The end-to-end tracing test in `installer/tests/tracing_cli.rs` keeps its +workspace, fake executables, and child environment private to that test module. +Those helpers model the test's controlled CLI failure and tracing contract; +they are not shared PATH utilities. Its `CliMode`, `DependencyPackage`, and +`FixtureExecutable` types represent only the finite choices used by this test. + ### CLI tool usage Release automation uses the `whitaker-package-dependency-binary` helper in @@ -2922,7 +2987,7 @@ context by combining a HIR ancestry walk with attribute-shape matching. `ContextEntry` items for modules, functions, impls, and blocks, and carries a boolean `has_test_context_ancestry` alongside that list. On each step, `has_test_ancestry` updates that boolean so the test-only decision can -propagate from outer ancestors into nested helper code. `summarise_context` +propagate from outer ancestors into nested helper code. `summarize_context` then combines the accumulated entries, the propagated boolean, and `in_test_like_context_with(additional_test_attributes)` to produce the final `ContextSummary.is_test` result. This pattern matters because user-configured @@ -2936,7 +3001,7 @@ not just the immediately enclosing function. `is_cfg_test_attribute` - the current ancestor is a function item whose attributes match Whitaker's built-in test list or `additional_test_attributes` -- `summarise_context` merges that ancestry flag with the collected +- `summarize_context` merges that ancestry flag with the collected `ContextEntry` values to derive the final `ContextSummary.is_test` decision. rustc removes an evaluated `#[cfg(test)]` from the item and leaves a parsed @@ -3330,6 +3395,13 @@ Whitaker data directory keyed by toolchain and target: - Windows: `%LOCALAPPDATA%\whitaker\lints\\\lib` +The private `installer/src/tracing_config.rs` formatter belongs only to CLI +startup and its colocated tests; other modules must emit events rather than +reuse or initialize this subscriber. It sends diagnostics to stderr, enables +errors in quiet mode, warnings by default, and debug events when verbosity is +positive, with ANSI escapes disabled. Tests capture formatted events with a +thread-scoped subscriber and verify stderr routing in an isolated subprocess. + ### Configuration options - `-t, --target-dir DIR` — Staging directory for built libraries @@ -3550,6 +3622,40 @@ crate consumes the same HIR lowering policy. Until then, keep compiler-aware HIR logic in the lint crate and keep the pure `ArgAtom`/`ArgFingerprint` model in `whitaker_common::rstest`. +### Numeric and span bounds in complexity diagnostics + +The Bumpy Road and conditional-branch lints convert counts at the boundary +between analysis and compiler diagnostics. Their `diagnostic_integer_argument` +and `saturating_diagnostic_integer` helpers clamp Fluent integer arguments to +`i64::MAX` rather than wrapping. Bumpy Road's `SegmentWeight::Fixed` keeps its +supplied value; `SegmentWeight::Predicate` uses `saturating_branch_count` and +`predicate_segment_weight` to cap branch counts at `u32::MAX` before scaling. + +Rust compiler `BytePos` offsets are also `u32`. `span_byte_offset_range` +rejects a highlighted source range if either endpoint exceeds that limit; +`line_range_byte_offsets` also rejects a start line before the snippet's base +line, a reversed range, or a start line missing from the snippet's line table. +When the requested end line has no following line-start entry, the mapper uses +the snippet length as the end offset. If either resulting byte offset cannot be +represented as `u32`, the mapper omits the secondary span label while still +emitting the primary diagnostic. Tests cover the maximum accepted value and the +first value above each boundary in +`crates/bumpy_road_function/src/driver/numeric.rs` and +`crates/conditional_max_n_branches/src/driver/numeric.rs`. + +Production tests that pass a value beyond a `usize` boundary are gated by +`#[cfg(target_pointer_width = "64")]`; 32-bit targets cannot represent those +inputs. The cases are `diagnostic_arguments_saturate_oversized_bump_counts` and +`span_mapping_omits_unrepresentable_offsets` in +`crates/bumpy_road_function/src/driver/diagnostic.rs`, plus +`production_diagnostic_arguments_saturate_at_the_signed_boundary`, +`production_predicate_weight_saturates_excess_branches`, +`production_span_offset_ranges_reject_an_unrepresentable_end`, and +`production_line_range_mapping_rejects_an_unrepresentable_offset` in its +`numeric.rs`. The conditional-branch boundary case is +`production_diagnostic_arguments_saturate_at_the_signed_boundary` in +`crates/conditional_max_n_branches/src/driver/numeric.rs`. + ### Adding a new lint New lints should typically start as experimental. To add a lint: @@ -3905,12 +4011,12 @@ of the rule lives in the `generate-coverage` README in this repository sets, what each value is sized against, and where the third tier went. -| Tier | What it bounds | Where it is set | Current value | -| ------------------------ | ---------------------------------- | ------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Per-test `slow-timeout` | one test | `.config/nextest.toml`, both profiles | 300 s default; 20 m for serialized Dylint/trybuild UI harnesses; 30 m for serialized nested-Cargo fixtures; 30 m for toolchain-installing behaviour tests | -| nextest `global-timeout` | the whole test run | `.config/nextest.toml`, both profiles | 60 m | -| Cargo watchdog | one `cargo` invocation, wall clock | not used here, see below | absent | -| Job `timeout-minutes` | the whole job | `ci.yml` and `coverage-main.yml`, job level | 100 m for every lane that runs the suite | +| Tier | What it bounds | Where it is set | Current value | +| ------------------------ | ---------------------------------- | ------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Per-test `slow-timeout` | one test | `.config/nextest.toml`, both profiles | 300 s default; 20 m for ordinary serialized UI and no-default-feature builds; 45 m for parser-pin and trybuild compile-contract fixtures; 30 m for toolchain-installing behaviour tests | +| nextest `global-timeout` | the whole test run | `.config/nextest.toml`, both profiles | 85 m | +| Cargo watchdog | one `cargo` invocation, wall clock | not used here, see below | absent | +| Job `timeout-minutes` | the whole job | `ci.yml` and `coverage-main.yml`, job level | 120 m for every lane that runs the suite | *Table: the timers that can end a run, innermost first.* @@ -3953,28 +4059,36 @@ where a reader of that profile will find them. Restating them also makes a later divergence between the two profiles explicit rather than silent, since a changed default would otherwise change CI without touching the `ci` section. -The parser-pin build-script tests each create an isolated nested Cargo -workspace with its own target directory. Their 10 m allowance accommodates cold -builds queued for compiler admission after one exceeded the 300 s base timeout; -it does not serialize the tests. +The no-default-feature regression test checks the library in a nested Cargo +invocation with an isolated target directory, so it rebuilds dependencies +instead of reusing artefacts from the workspace test run. It exceeded the +300-second base allowance while compiler admission was heavily queued. The +20-minute allowance and shared nested-Cargo test group leave room for a cold +build without letting it compete with the parser-pin fixtures. The contract holds that policy, and its failure messages say so rather than claiming nextest requires it. -The serial UI group has a 20-minute bound. A full workspace run reached 9m56 in -the nested trybuild harness while compiler admission queued; the wider -allowance lets a contended compile finish while still terminating a stalled -test. - -The three parser-pin build-script integration cases also receive a 30-minute -allowance and run in a dedicated serial test group. Each case invokes nested -Cargo against a fresh temporary target directory, so it rebuilds the fixture's -build dependencies. The exact-pin case performs both `cargo check` and -`cargo run`; a traced run completed in 591 seconds after waiting on the shared -package-cache lock. The loose-pin case was still compiling when its 15-minute -allowance expired under shared compiler admission. The 30-minute allowance -leaves room for that queue without changing the whole-suite budget. Serializing -these cases keeps their Cargo invocations from queueing against one another. +The ordinary serial UI group has a 20-minute bound. The installer UI case +measured 235 seconds and timed out at the 300-second base allowance on Windows. +The wider allowance lets nested UI builds finish under contention while still +terminating a stalled test. + +Every trybuild compile contract rebuilds fixture crates through nested Cargo. +In a cold workspace, the Bumpy Road public API contract timed out on three +20-minute attempts; the final attempt reached its pass fixture after 15 minutes +of nested compilation. Retrying restarted that expensive build without +producing a result. All discovered compile contracts therefore share a +45-minute allowance and disable retries, leaving one build enough time to reach +its fixtures without repeating the same work. + +The three parser-pin build-script integration cases run in the same serial +nested-Cargo group and each use a fresh temporary target directory. The +exact-pin case performs both `cargo check` and `cargo run`; a traced run +completed in 591 seconds after waiting on the shared package-cache lock. A +later full run exhausted the 30-minute allowance while compiler admission had a +large queue. The 45-minute allowance reflects that observed wait, and the group +prevents these fixtures from competing with the no-default-feature build. ### The clocks do not start together @@ -4001,10 +4115,10 @@ for the build and the steps either side of the suite, plus a fifteen-minute margin above that sum. The margin is there because a ceiling equal to the sum it contains cancels the job at the moment the innermost timer would have reported the overrun, and the report is the only thing that makes an overrun -actionable. That is 60 m + 65 s + 15 m + 15 m, or 91 minutes 5 seconds, which +actionable. That is 85 m + 65 s + 15 m + 15 m, or 116 minutes 5 seconds, which `required_ceiling` computes for both profiles. -The ceiling is not that requirement. It is pinned separately at 100 minutes, a +The ceiling is not that requirement. It is pinned separately at 120 minutes, a policy value above it: the derivation accepts any ceiling at or above the requirement, so the value is pinned as well, to stop a lane drifting to a number nobody chose. @@ -4015,11 +4129,11 @@ written down, so it has been corrected rather than kept as a special case. ### What the values are sized against -The 60 m whole-run budget is a bound with headroom for the complete workspace -suite. A recent full run reached the previous 45 m limit with 1,759 of 1,762 -tests completed while the final nested trybuild compile was still progressing. -The additional 15 m leaves time for that compile and normal variation without -weakening the 30 m per-test ceiling. +The 85 m whole-run budget is sized against the latest uncached full run. It +reached 1,840 of 1,842 tests before the global timeout stopped the SARIF API +contract's nested trybuild compilation while Cargo waited on shared locks and +compiler admission. The additional 10 m gives that contract and the remaining +test room to finish without weakening the configured per-test ceilings. The 15 m allowance for work outside nextest's window is measured, from the worst of several runs rather than one: @@ -4048,7 +4162,7 @@ and this suite cannot run a bare `--workspace` build. Coverage runs through That absence is asserted rather than assumed. A lane that adopted the action without setting `RUN_RUST_CARGO_WAIT_TIMEOUT` would inherit the 1,800 s default -underneath a 60 m nextest budget, which is exactly the inversion the canonical +underneath an 85 m nextest budget, which is exactly the inversion the canonical section exists to prevent, and it would do so silently. The contract therefore fails if either the action appears or the variable is set, so adopting it needs this section updated in the same change. @@ -4176,16 +4290,20 @@ failure and `tests/nextest_ui_filter.rs` requires the key. It discovers the compile-contract tests rather than listing them. A test that drives `trybuild` invokes a nested cargo build, so it costs what a build costs -and the base per-test allowance is the wrong order of magnitude for it. There -are two here, and both are in a binary called `ui`, which is why the set is -found from the `trybuild::TestCases` calls in the tree and each test is -required by name: `whitaker-installer`'s test sits in a binary named `ui` but -is not itself named `ui`, so the `(binary(ui) & test(=ui))` clause never -reached it. It ran under the base 300-second allowance, passed at 235 seconds, -and timed out at 300.216 seconds on the Windows lane, cancelling the run. A -handwritten list would have had the same hole, and a discovery that stops -recognizing the call would sweep a smaller set silently, so finding nothing is -a failure rather than a pass. +and the base per-test allowance is the wrong order of magnitude for it. The +current discovery finds seven tests across +`common/tests/public_api_contract.rs`, +`crates/bumpy_road_function/tests/api_contract.rs`, +`crates/whitaker_sarif/tests/api_contract.rs`, `installer/tests/ui.rs`, +`crates/whitaker_test_macros/tests/ui.rs`, and +`crates/rstest_helper_should_be_fixture/tests/ui.rs`. Three of those binaries +are named `ui`, but their test functions have different names. For example, +`whitaker-installer`'s test is in a binary named `ui` but is not itself named +`ui`, so the `(binary(ui) & test(=ui))` clause never reached it. It ran under +the base 300-second allowance, passed at 235 seconds, and timed out at 300.216 +seconds on the Windows lane, cancelling the run. A handwritten list would have +had the same hole, and a discovery that stops recognizing the call would sweep +a smaller set silently, so finding nothing is a failure rather than a pass. It drives the lane discovery with workflow documents rather than only with the files. `_declared_jobs` takes parsed documents and reads the repository's own @@ -4216,9 +4334,9 @@ fails `ci`'s ordering too. Whether a profile terminates a test no override matches is still read from its own table alone. It pins the values the tables above state as well as ordering them: the base -`slow-timeout` on both profiles compared as a whole table, the 60 m whole-run +`slow-timeout` on both profiles compared as a whole table, the 85 m whole-run budget, both overrides' whole `slow-timeout` including the grace period, and -the 100-minute ceiling on every suite lane. The ordering assertions hold for a +the 120-minute ceiling on every suite lane. The ordering assertions hold for a range of values, so on their own they would let any of these drift to a number nobody chose while still passing. The grace period is pinned for the same reason it is read: the watchdog and the ceiling above it are sized to cover diff --git a/docs/execplans/2-2-5-test-must-not-have-example-lint.md b/docs/execplans/2-2-5-test-must-not-have-example-lint.md index a85ff270e..a6c6e7e20 100644 --- a/docs/execplans/2-2-5-test-must-not-have-example-lint.md +++ b/docs/execplans/2-2-5-test-must-not-have-example-lint.md @@ -211,7 +211,7 @@ Current repository state relevant to this task: - Existing reusable detection helpers already exist: - `common::context::{is_test_fn_with, in_test_like_context_with}`; - `crates/no_expect_outside_tests/src/context.rs` for HIR ancestor collection - and `cfg(test)` handling (`collect_context`, `summarise_context`). + and `cfg(test)` handling (`collect_context`, `summarize_context`). - Existing BDD tests use `rstest_bdd_macros::{given, when, then, scenario}` and are executed under `cargo test`. diff --git a/docs/execplans/3-4-4-install-prebuilt-artefacts.md b/docs/execplans/3-4-4-install-prebuilt-artefacts.md index f3252a249..568a849fe 100644 --- a/docs/execplans/3-4-4-install-prebuilt-artefacts.md +++ b/docs/execplans/3-4-4-install-prebuilt-artefacts.md @@ -558,7 +558,7 @@ All phases produce additive changes. If a phase fails partway through, fix the issue and re-run the quality gates. The `tempfile` crate ensures downloaded archives are cleaned up on failure. -## Artifacts and notes +## Artefacts and notes Key files created: diff --git a/docs/execplans/3.4.6. Record download-versus-build rates.md b/docs/execplans/3.4.6. Record download-versus-build rates.md index 05af2d05d..2c300c785 100644 --- a/docs/execplans/3.4.6. Record download-versus-build rates.md +++ b/docs/execplans/3.4.6. Record download-versus-build rates.md @@ -356,7 +356,7 @@ Expected observable checks after implementation: - If metrics write fails, continue installation and log a warning; retry on next installation. -## Artifacts and notes +## Artefacts and notes During implementation, capture concise evidence snippets in this section: diff --git a/docs/execplans/7-1-1-whitaker-sarif-crate.md b/docs/execplans/7-1-1-whitaker-sarif-crate.md index ac6ed9665..44cd6e7c9 100644 --- a/docs/execplans/7-1-1-whitaker-sarif-crate.md +++ b/docs/execplans/7-1-1-whitaker-sarif-crate.md @@ -263,9 +263,9 @@ field. Convenience type alias `Result`. ### Stage D: Implement SARIF model types Split into `model/` directory: `mod.rs`, `log.rs` (`SarifLog`), `run.rs` (`Run`, -`Tool`, `ToolComponent`, `Invocation`, `Artifact`), `result.rs` (`SarifResult`, +`Tool`, `ToolComponent`, `Invocation`, `Artefact`), `result.rs` (`SarifResult`, `Level`, `Message`), `location.rs` (`Location`, `PhysicalLocation`, -`ArtifactLocation`, `Region`, `RelatedLocation`), `descriptor.rs` +`ArtefactLocation`, `Region`, `RelatedLocation`), `descriptor.rs` (`ReportingDescriptor`, `MultiformatMessageString`). All types derive `Debug`, `Clone`, `PartialEq`, `Serialize`, `Deserialize` with `#[serde(rename_all = "camelCase")]`. diff --git a/docs/execplans/7-2-8-kani-verification-of-bounded-lsh-index-invariants.md b/docs/execplans/7-2-8-kani-verification-of-bounded-lsh-index-invariants.md index d72b85813..8477738ea 100644 --- a/docs/execplans/7-2-8-kani-verification-of-bounded-lsh-index-invariants.md +++ b/docs/execplans/7-2-8-kani-verification-of-bounded-lsh-index-invariants.md @@ -679,7 +679,7 @@ If a quality gate fails because of unrelated main-branch drift or another agent's changes, stop, record the evidence in this plan, and ask for direction. Do not revert unrelated work. -## Artifacts and notes +## Artefacts and notes Wyvern planning agents reported three useful facts: diff --git a/docs/execplans/7-3-1-map-candidate-spans-and-extract-ast-feature-vectors.md b/docs/execplans/7-3-1-map-candidate-spans-and-extract-ast-feature-vectors.md index ae2e52dde..bf8e223f8 100644 --- a/docs/execplans/7-3-1-map-candidate-spans-and-extract-ast-feature-vectors.md +++ b/docs/execplans/7-3-1-map-candidate-spans-and-extract-ast-feature-vectors.md @@ -77,7 +77,7 @@ escalation, not a workaround. bare `ra_ap_syntax::`/`rowan::` path appears outside comments, with the forbidden-crate list as a `const`. - **No persisted `KindId` from 7.3.1.** Only `AstHash` (which is seeded with - `PARSER_SCHEMA_VERSION`) is hashable/serialisable in this item. `KindId` is + `PARSER_SCHEMA_VERSION`) is hashable/serializable in this item. `KindId` is an in-memory opaque token and must not be persisted, so a future cache (7.6.x) cannot accidentally compare raw discriminants across parser pins. - **Bounded per-candidate cost.** Lowering touches one candidate subtree; the @@ -617,7 +617,7 @@ Decisions already taken while drafting this plan: corruption, no crash). Seeding makes every hash change on a bump, so any cross-pin cache compare fails closed. An `insta` snapshot of `PARSER_SCHEMA_VERSION` forces any bump to be reviewed. `KindId` itself is - **not** persisted by 7.3.1 (only `AstHash` is hashable/serialisable here); + **not** persisted by 7.3.1 (only `AstHash` is hashable/serializable here); this is stated as a Constraint so 7.6.x inherits the rule. Date/Author: 2026-06-09, Doggylump (review panel). - Decision: **Promote the FNV-1a constants and byte-mixing step from diff --git a/docs/execplans/8-2-1-create-the-rstest-helper-lint-crate.md b/docs/execplans/8-2-1-create-the-rstest-helper-lint-crate.md index b168c6d52..770a2066a 100644 --- a/docs/execplans/8-2-1-create-the-rstest-helper-lint-crate.md +++ b/docs/execplans/8-2-1-create-the-rstest-helper-lint-crate.md @@ -730,7 +730,7 @@ If validation fails, inspect the matching changing code. Record persistent failures in `Surprises & Discoveries` or `Decision Log` with the command and log path. -## Artifacts and notes +## Artefacts and notes Wyvern repository-pattern findings: diff --git a/docs/execplans/8-2-2-call-site-collection-in-rstest-tests.md b/docs/execplans/8-2-2-call-site-collection-in-rstest-tests.md index 84b0648d9..a0ae48ce2 100644 --- a/docs/execplans/8-2-2-call-site-collection-in-rstest-tests.md +++ b/docs/execplans/8-2-2-call-site-collection-in-rstest-tests.md @@ -892,13 +892,13 @@ If validation fails, inspect the matching changing code. Record persistent failures under "Surprises & discoveries" or "Decision log" with the command and log path. -## Artifacts and notes +## Artefacts and notes Wyvern repository-pattern findings (summary): ```plaintext - No existing Whitaker lint uses check_crate_post. The current lints - initialise per-crate config in check_crate and emit diagnostics + initialize per-crate config in check_crate and emit diagnostics expression-locally through check_expr / check_fn / check_item / check_impl_item. - Callee resolution is consistently done via cx.qpath_res for Call and diff --git a/docs/execplans/installer-does-not-install-toolchain.md b/docs/execplans/installer-does-not-install-toolchain.md index fc33b4e21..c24f48cec 100644 --- a/docs/execplans/installer-does-not-install-toolchain.md +++ b/docs/execplans/installer-does-not-install-toolchain.md @@ -186,7 +186,7 @@ already installed, no rustup install command should run. If rustup fails, the installer should return an error and can be re-run once the environment recovers. -## Artifacts and Notes +## Artefacts and notes Example log (expected after change): diff --git a/docs/execplans/issue-110-ambiguous-name-reported-by-dylint.md b/docs/execplans/issue-110-ambiguous-name-reported-by-dylint.md index a0df36c8d..7e3faa3bf 100644 --- a/docs/execplans/issue-110-ambiguous-name-reported-by-dylint.md +++ b/docs/execplans/issue-110-ambiguous-name-reported-by-dylint.md @@ -216,7 +216,7 @@ Edits are safe to re-run. If a step fails, revert file changes with Git and re-apply. Validation commands are safe to repeat. If validation fails twice, stop and escalate with the captured logs. -## Artifacts and Notes +## Artefacts and notes Keep the validation logs from `/tmp/whitaker-*.log` as evidence for each quality gate. Record any output from `cargo dylint list` that shows the updated diff --git a/docs/execplans/issue-271-ref-pinned-installation.md b/docs/execplans/issue-271-ref-pinned-installation.md index f492d4c84..a2ecaeac6 100644 --- a/docs/execplans/issue-271-ref-pinned-installation.md +++ b/docs/execplans/issue-271-ref-pinned-installation.md @@ -477,7 +477,7 @@ detached, `git -C ~/.local/share/whitaker checkout main` restores it by hand. If a gate fails mid-commit, fix and re-run the gate; never commit over a red gate. -## Artifacts and notes +## Artefacts and notes Red/Green evidence (gate logs under `/tmp/*-whitaker-issue-271*.out`): diff --git a/docs/execplans/issue-94-dual-ownership-of-binary.md b/docs/execplans/issue-94-dual-ownership-of-binary.md index 158b01e30..1b40d96d1 100644 --- a/docs/execplans/issue-94-dual-ownership-of-binary.md +++ b/docs/execplans/issue-94-dual-ownership-of-binary.md @@ -186,7 +186,7 @@ Edits are safe to re-run. If a step fails, revert the file changes with Git and re-apply. The `install-smoke` target uses a temporary directory and is safe to repeat. If validation fails twice, stop and escalate with the captured logs. -## Artifacts and Notes +## Artefacts and notes Capture key outputs in the log files listed in Concrete Steps. These logs are sufficient evidence for validation and troubleshooting. diff --git a/docs/local-validation-of-github-actions-with-act-and-pytest.md b/docs/local-validation-of-github-actions-with-act-and-pytest.md index 0b6494adf..7083dbed9 100644 --- a/docs/local-validation-of-github-actions-with-act-and-pytest.md +++ b/docs/local-validation-of-github-actions-with-act-and-pytest.md @@ -121,9 +121,9 @@ def run_act( job: str = "selftest", event_path: Path = EVENT, *, - artifact_dir: Path, + artefact_dir: Path, ) -> tuple[int, Path, str]: - artifact_dir.mkdir(parents=True, exist_ok=True) + artefact_dir.mkdir(parents=True, exist_ok=True) cmd = [ "act", "pull_request", @@ -134,18 +134,18 @@ def run_act( "-P", "ubuntu-latest=catthehacker/ubuntu:act-latest", "--artifact-server-path", - str(artifact_dir), + str(artefact_dir), "--json", # machine-parseable log stream "-b", # bind-mount repo as workspace (preserves side effects) ] completed = subprocess.run(cmd, text=True, capture_output=True) logs = completed.stdout + "\n" + completed.stderr - return completed.returncode, artifact_dir, logs + return completed.returncode, artefact_dir, logs def test_workflow_produces_expected_artefact_and_logs(tmp_path: Path) -> None: - artifact_dir = tmp_path / "act-artifacts" - code, artdir, logs = run_act(artifact_dir=artifact_dir) + artefact_dir = tmp_path / "act-artefacts" + code, artdir, logs = run_act(artefact_dir=artefact_dir) assert code == 0, f"act failed:\n{logs}" # Assert artefact presence and contents @@ -214,11 +214,11 @@ loop: from cmd_mox import CmdMox def test_record(tmp_path: Path) -> None: - artifact_dir = tmp_path / "act-artifacts" + artefact_dir = tmp_path / "act-artefacts" with CmdMox() as mox: gh = mox.spy("gh").passthrough() mox.replay() - code, _, logs = run_act(artifact_dir=artifact_dir) + code, _, logs = run_act(artefact_dir=artefact_dir) assert code == 0, logs mox.verify() assert gh.call_count == 1 @@ -229,7 +229,7 @@ loop: ```python def test_replay(tmp_path: Path, cmd_mox) -> None: - artifact_dir = tmp_path / "act-artifacts" + artefact_dir = tmp_path / "act-artefacts" cmd_mox.mock("gh").with_args( "release", "view", @@ -237,7 +237,7 @@ loop: "tagName", ).returns(stdout='{"tagName":"v9.9.9"}\n') cmd_mox.replay() - code, _, logs = run_act(artifact_dir=artifact_dir) + code, _, logs = run_act(artefact_dir=artefact_dir) assert code == 0, logs cmd_mox.verify() ``` diff --git a/docs/rstest-bdd-users-guide.md b/docs/rstest-bdd-users-guide.md index 67db84097..9e2ad2c76 100644 --- a/docs/rstest-bdd-users-guide.md +++ b/docs/rstest-bdd-users-guide.md @@ -988,7 +988,7 @@ assert_eq!(row_number, 2); assert_eq!(column_index, 2); assert!(err .to_string() - .contains("unrecognised boolean value 'maybe'")); + .contains("unrecognized boolean value 'maybe'")); ``` [`DataTableError`]: crate::datatable::DataTableError diff --git a/docs/scripting-standards.md b/docs/scripting-standards.md index 288fc5ff8..15b042b04 100644 --- a/docs/scripting-standards.md +++ b/docs/scripting-standards.md @@ -241,7 +241,7 @@ from pathlib import Path PROJECT_ROOT = Path(__file__).resolve().parents[1] DIST = PROJECT_ROOT / "dist" -(DIST / "artifacts").mkdir(parents=True, exist_ok=True) +(DIST / "artefacts").mkdir(parents=True, exist_ok=True) # Portable joins and normalization cfg = PROJECT_ROOT.joinpath("config", "release.toml").resolve() diff --git a/docs/users-guide.md b/docs/users-guide.md index 34f803a98..64a0b8244 100644 --- a/docs/users-guide.md +++ b/docs/users-guide.md @@ -6,6 +6,15 @@ explains how to integrate the lints into a project and configure them. For contributors who want to develop new lints or work on Whitaker itself, see the [Developer's Guide](developers-guide.md). +## Upgrading library consumers + +Version 0.3.0 includes source-breaking spelling changes to public Rust APIs. +Existing library consumers must update imports, field access, and builder calls +to the names listed in the +[Whitaker 0.3.0 migration guide](v0-3-0-migration-guide.md). The changes affect +Rust source only: SARIF 2.1.0 JSON keys retain their schema-defined spellings, +so existing serialized data requires no migration. + ## Quick Setup ### Prerequisites @@ -103,6 +112,18 @@ environment-variable workaround. [Pinning the lint suite](#pinning-the-lint-suite) - `--no-source-fallback` — Fail rather than build from source when a published artefact is absent. See [Refusing a source build](#refusing-a-source-build) +- `-v`, `--verbose` — Include debug diagnostics, including bounded reasons why + a `PATH` candidate was skipped. The candidate path is not printed. +- `-q`, `--quiet` — Suppress progress output and show only errors. + +Installer diagnostics are written to standard error without ANSI formatting. +The default verbosity shows warnings and errors; `-v` enables debug events, and +`-q` limits tracing output to errors. + +The installer scans `PATH` entries in order and uses the first regular +executable candidate for `dylint-link`. It skips missing candidates and entries +that cannot be represented as UTF-8 or opened, then continues searching later +entries. ### Adding Whitaker to a project diff --git a/docs/v0-3-0-migration-guide.md b/docs/v0-3-0-migration-guide.md new file mode 100644 index 000000000..b946ad59e --- /dev/null +++ b/docs/v0-3-0-migration-guide.md @@ -0,0 +1,155 @@ +# Whitaker 0.3.0 migration guide + +This guide covers source-breaking public API changes when upgrading library +consumers from the 0.2.x line to Whitaker 0.3.0. The release standardizes +Rust-facing identifiers on en-GB-oxendict spelling. Existing consumers must +update source references to the replacement names below; compatibility aliases +are not provided for the removed spellings. + +## Migration scope + +The changes affect Rust imports, type names, field access, and method calls. +They do not change the SARIF 2.1.0 serialized representation. Serde renames +keep the JSON keys `artifacts` and `artifactLocation`, so no serialized-data +migration is needed. + +## `whitaker_common` APIs + +Update the locale helper import and calls: + +```rust +use whitaker_common::i18n::normalise_locale; + +let locale = normalise_locale(Some("en-GB")); +``` + +becomes: + +```rust +use whitaker_common::i18n::normalize_locale; + +let locale = normalize_locale(Some("en-GB")); +``` + +The complete function mapping is: + +| 0.2.x name | 0.3.0 name | +| ------------------ | ------------------ | +| `normalise_locale` | `normalize_locale` | +| `rasterise_signal` | `rasterize_signal` | + +Update complexity-signal rasterization in the same way. The deprecated +`rasterise_signal` compatibility wrapper is removed; callers must use +`rasterize_signal`: + +```rust +use whitaker_common::complexity_signal::rasterize_signal; + +let signal = rasterize_signal(function_lines, segments)?; +``` + +## `bumpy_road_function` API + +Rename the settings normalizer at its import and call sites: + +```rust +use bumpy_road_function::analysis::{normalize_settings, Settings}; + +fn normalized(settings: Settings) -> Settings { + normalize_settings(settings) +} +``` + +The old `normalise_settings` function is replaced by `normalize_settings`. + +The complete settings mapping is: + +| 0.2.x name | 0.3.0 name | +| -------------------- | -------------------- | +| `normalise_settings` | `normalize_settings` | + +## `whitaker_sarif` APIs + +The SARIF model uses `Artefact` and `ArtefactLocation` for Rust identifiers. +Update type imports and values as follows: + +```rust +use whitaker_sarif::{Artefact, ArtefactLocation}; + +let artefact = Artefact { + location: ArtefactLocation { + uri: "src/main.rs".into(), + uri_base_id: None, + }, + mime_type: Some("text/x-rust".into()), +}; +``` + +The complete type mapping is: + +| 0.2.x name | 0.3.0 name | +| ---------------------------------- | ---------------------------------- | +| `whitaker_sarif::Artifact` | `whitaker_sarif::Artefact` | +| `whitaker_sarif::ArtifactLocation` | `whitaker_sarif::ArtefactLocation` | + +Update renamed fields and builder methods at the same time: + +```rust +use whitaker_sarif::{Artefact, ArtefactLocation, PhysicalLocation, RunBuilder}; + +let physical_location = PhysicalLocation { + artefact_location: ArtefactLocation { + uri: "src/main.rs".into(), + uri_base_id: None, + }, + region: None, +}; + +let run = RunBuilder::new("tool", "0.3.0") + .with_artefact(Artefact { + location: ArtefactLocation { + uri: "src/main.rs".into(), + uri_base_id: None, + }, + mime_type: None, + }) + .build(); + +assert_eq!(run.artefacts.len(), 1); +assert_eq!(physical_location.artefact_location.uri, "src/main.rs"); +``` + +The field and method mappings are: + +| 0.2.x name | 0.3.0 name | +| ------------------------------------ | ------------------------------------ | +| `Run.artifacts` | `Run.artefacts` | +| `PhysicalLocation.artifact_location` | `PhysicalLocation.artefact_location` | +| `RunBuilder::with_artifact` | `.with_artefact` | + +The Rust names change while the SARIF wire names remain fixed. The +`Run.artefacts` field carries `#[serde(rename = "artifacts")]`, and the +`PhysicalLocation::artefact_location` field carries +`#[serde(rename = "artifactLocation")]`. Consequently, serialized JSON still +uses the SARIF 2.1.0 keys `artifacts` and `artifactLocation`; no update to +stored SARIF data or downstream SARIF readers is required. + +## Installer dependency query + +`whitaker_installer::deps::check_dylint_tools` now returns +`Result` so callers can distinguish a missing or unpinned +tool from a failed availability query. Propagate the result before deciding +whether to install: + +```rust +let status = check_dylint_tools(&executor)?; +if !status.all_installed() { + install_dylint_tools(&executor, &status)?; +} +``` + +The error enum also adds `InstallerError::DependencyCheck`. Update exhaustive +matches on `InstallerError` to handle this query failure. A missing tool or a +version mismatch remains a successful query with a `false` status field. +Unexpected command failures or version output without a semantic version now +return an error instead of being treated as permission to install. diff --git a/docs/whitaker-clone-detector-design.md b/docs/whitaker-clone-detector-design.md index b627af767..f7a506c84 100644 --- a/docs/whitaker-clone-detector-design.md +++ b/docs/whitaker-clone-detector-design.md @@ -61,7 +61,7 @@ passes. Use `runs[0]` for the token pass and `runs[1]` for the AST pass. Each - `serde` models of SARIF 2.1.0 (subset plus extensions) with `From` and `Into` helpers. -- Helpers to build rules, results, locations, artifacts, and invocations. +- Helpers to build rules, results, locations, artefacts, and invocations. - Stable file layout: `target/whitaker/clones.{pass}.sarif` and `target/whitaker/clones.refined.sarif`. - Merge logic: combine runs and deduplicate results by diff --git a/docs/whitaker-dylint-suite-design.md b/docs/whitaker-dylint-suite-design.md index 451212c5c..7ab9e8989 100644 --- a/docs/whitaker-dylint-suite-design.md +++ b/docs/whitaker-dylint-suite-design.md @@ -731,10 +731,9 @@ qualified paths (`std::fs::read_to_string`) and items pulled in via into a `SimplePath` parser. A match requires the first two segments to equal `["std", "fs"]`. Re-exports remain covered because the resolved `DefId` points back to `std`. -- Each finding produces a `StdFsUsage` struct containing the resolved operation - label (for diagnostics) and a category (`Import`, `Type`, or `Call`). The - category drives unit tests and future config knobs but diagnostics currently - emphasize the operation name only to keep translations succinct. +- Each finding produces a `StdFsUsage` value that stores only the resolved + operation label. Classifiers return operation-only usage values, and + diagnostics use that label to keep translations succinct. - Diagnostics reuse Fluent bundles (`no_std_fs_operations.ftl`) with the operation label supplied via `{ $operation }`. The note explains why ambient access is disallowed and the help recommends `cap_std::fs` + `camino`. @@ -813,8 +812,8 @@ sequenceDiagram - Unit tests cover the operation classifier, ensuring paths such as `std::fs::File::open`, `std::fs::remove_file`, bare module references, and - renamed imports all register as `StdFsUsage` while `cap_std::fs` remains - untouched. + renamed imports all produce operation-only usage values while `cap_std::fs` + remains untouched. - Behaviour-driven tests (via `rstest-bdd 0.5.0`) exercise localization, failure fallbacks, and the world state used to model capability hints. Scenarios cover en-GB, cy, and gd locales, as well as missing-message paths. @@ -1237,7 +1236,7 @@ and predicate complexity. Collect segments `(start_line, end_line, value)` using `SourceMap` mapping and accumulate contributions with weights (`wD = 1.0`, `wP = 0.5`, `wK = 0.5`). -Rasterise once per function to produce a per-line signal `C[line]` representing +Rasterize once per function to produce a per-line signal `C[line]` representing local complexity. ```rust @@ -1291,11 +1290,39 @@ highlight the top two intervals in the diagnostic. - Include the lint in the aggregated `whitaker_suite` crate by default as a stable rule, while still allowing direct usage by loading the lint crate. +**Numeric and span boundaries.** Fluent diagnostic integer arguments are signed +64-bit values, so `diagnostic_integer_argument` and +`saturating_diagnostic_integer` clamp oversized counts at `i64::MAX`. +`SegmentWeight::Fixed` keeps its supplied value, while +`SegmentWeight::Predicate` uses `saturating_branch_count` and +`predicate_segment_weight` to cap branch counts at `u32::MAX` before scaling. +Compiler `BytePos` offsets are `u32`; `span_byte_offset_range` rejects any +highlighted range with an unrepresentable endpoint. `line_range_byte_offsets` +also rejects starts before the snippet's base line, reversed ranges, and starts +missing from its line table; when the end line has no following entry, it uses +the snippet length as the end offset. A rejected or unrepresentable range omits +the secondary label while preserving the primary diagnostic. The +conditional-branch lint uses the same signed boundary for Fluent counts. +Boundary tests cover the maximum accepted values and the first values above +them. + +Production tests that pass values beyond a `usize` boundary use +`#[cfg(target_pointer_width = "64")]`; 32-bit targets cannot represent those +inputs. Bumpy Road tests `diagnostic_arguments_saturate_oversized_bump_counts` +and `span_mapping_omits_unrepresentable_offsets` in `driver/diagnostic.rs`, plus +`production_diagnostic_arguments_saturate_at_the_signed_boundary`, +`production_predicate_weight_saturates_excess_branches`, +`production_span_offset_ranges_reject_an_unrepresentable_end`, and +`production_line_range_mapping_rejects_an_unrepresentable_offset` in +`driver/numeric.rs`. The conditional-branch test is +`production_diagnostic_arguments_saturate_at_the_signed_boundary` in its +`driver/numeric.rs`. + **Algorithm sketch.** 1. Walk the function HIR, updating depth and collecting segments for blocks, branches, and predicate spans. -2. Rasterise segments to per-line values, then smooth with the configured +2. Rasterize segments to per-line values, then smooth with the configured window. 3. Detect bumps where the smoothed value meets or exceeds `threshold`. 4. Emit a diagnostic on the function name span when bumps ≥ 2, attaching labels @@ -1321,7 +1348,7 @@ functions typically remain below the threshold after smoothing. Deep single nests fall under other lints such as `excessive_nesting`. **Performance.** The pass is linear in the size of each function’s HIR. Segment -rasterisation touches at most the number of lines in the function, keeping the +rasterization touches at most the number of lines in the function, keeping the overhead negligible for typical Rust code. **Test plan.** Provide UI cases covering two separated nested blocks, @@ -1366,7 +1393,7 @@ function and is suitable as a stable Dylint rule in the default suite. ### Phase 0 — Repo scaffolding -- Initialise workspace +- Initialize workspace - Create `Cargo.toml` with `[workspace]`, resolver = 2, members = `crates/*`, `common`, `suite`, `installer`. - Add `rust-toolchain.toml` (pin nightly) and `rustfmt.toml`. @@ -1651,7 +1678,10 @@ used by the installer itself. - Extend `.github/workflows/release.yml` so tagged releases publish the same dependency-binary archives and provenance document next to the `whitaker-installer` archives. -- Teach `installer/src/deps.rs` to install missing tools in this order: +- Keep the dependency-tool installer in `installer/src/deps/mod.rs` so its + implementation children, including `install.rs`, remain colocated with the + module that owns the fallback policy. +- Teach `installer/src/deps/mod.rs` to install missing tools in this order: repository archive, `cargo binstall`, then `cargo install`. Repository-path failures stay non-fatal and degrade cleanly into the Cargo fallback path. diff --git a/installer/Cargo.toml b/installer/Cargo.toml index 01226879c..7bf897afe 100644 --- a/installer/Cargo.toml +++ b/installer/Cargo.toml @@ -74,6 +74,7 @@ tempfile = { workspace = true } thiserror = { workspace = true } toml = { workspace = true } tracing = { workspace = true } +tracing-subscriber = { workspace = true } ureq = { workspace = true } wait-timeout = { workspace = true } zip = { workspace = true } @@ -82,6 +83,7 @@ zstd = { workspace = true } [dev-dependencies] whitaker_test_macros = { workspace = true } libc = { workspace = true } +proptest = { workspace = true } mockall = { workspace = true } rstest = { workspace = true } rstest-bdd = { workspace = true } diff --git a/installer/src/artefact/extraction.rs b/installer/src/artefact/extraction.rs index fdb6f812c..c07e50bb3 100644 --- a/installer/src/artefact/extraction.rs +++ b/installer/src/artefact/extraction.rs @@ -117,6 +117,7 @@ mod tests { use rstest::rstest; use std::path::PathBuf; + /// Extract a real archive and verify that its file is written. #[test] fn extract_real_archive() { // Create a temp archive with a single file, then extract it. @@ -137,8 +138,8 @@ mod tests { builder .append_path_with_name(&source_file, "hello.txt") .expect("append"); - let encoder = builder.into_inner().expect("tar finish"); - encoder.finish().expect("zstd finish"); + let finished_encoder = builder.into_inner().expect("tar finish"); + finished_encoder.finish().expect("zstd finish"); let extractor = ZstdExtractor; let files = extractor @@ -173,6 +174,7 @@ mod tests { assert!(matches!(result, Err(ExtractionError::PathTraversal { .. }))); } + /// Reject an archive that contains no entries. #[test] fn extract_empty_archive() { let temp_dir = tempfile::tempdir().expect("temp dir"); @@ -184,8 +186,8 @@ mod tests { let output_file = std::fs::File::create(&archive_path).expect("create"); let encoder = zstd::Encoder::new(output_file, 0).expect("zstd"); let builder = tar::Builder::new(encoder); - let encoder = builder.into_inner().expect("tar finish"); - encoder.finish().expect("zstd finish"); + let finished_encoder = builder.into_inner().expect("tar finish"); + finished_encoder.finish().expect("zstd finish"); let extractor = ZstdExtractor; let result = extractor.extract(&archive_path, &dest_dir); diff --git a/installer/src/artefact/schema_version.rs b/installer/src/artefact/schema_version.rs index 2f0b101a8..69640b7cd 100644 --- a/installer/src/artefact/schema_version.rs +++ b/installer/src/artefact/schema_version.rs @@ -96,11 +96,12 @@ mod tests { assert_eq!(v.expect("checked above").as_u32(), 1); } + /// Reject schema version zero as unsupported. #[test] fn rejects_version_zero() { let result = SchemaVersion::try_from(0_u32); assert!(result.is_err()); - let err = result.unwrap_err(); + let err = result.expect_err("schema version zero must be rejected"); assert!(matches!( err, ArtefactError::UnsupportedSchemaVersion { value: 0, max: 1 } diff --git a/installer/src/artefact/sha256_digest.rs b/installer/src/artefact/sha256_digest.rs index c64cfc143..2109e580b 100644 --- a/installer/src/artefact/sha256_digest.rs +++ b/installer/src/artefact/sha256_digest.rs @@ -129,6 +129,8 @@ mod tests { use super::*; use rstest::{fixture, rstest}; + /// Create a valid hexadecimal digest for validation tests. + #[whitaker_test_macros::allow_fixture_expansion_lints] #[fixture] fn valid_digest() -> String { "a".repeat(64) diff --git a/installer/src/dependency_binaries/install/checksum.rs b/installer/src/dependency_binaries/install/checksum.rs index 066adb126..414a606af 100644 --- a/installer/src/dependency_binaries/install/checksum.rs +++ b/installer/src/dependency_binaries/install/checksum.rs @@ -253,6 +253,7 @@ mod tests { } impl Read for ChunkedReader<'_> { + /// Read at most the configured chunk size and record each call. fn read(&mut self, buf: &mut [u8]) -> io::Result { self.reads += 1; if self.pending_interrupts > 0 { @@ -260,8 +261,11 @@ mod tests { return Err(io::Error::new(io::ErrorKind::Interrupted, "interrupted")); } let take = self.data.len().min(self.chunk).min(buf.len()); - buf[..take].copy_from_slice(&self.data[..take]); - self.data = &self.data[take..]; + let (to_copy, rest) = self.data.split_at(take); + buf.get_mut(..take) + .expect("take is bounded by buf.len()") + .copy_from_slice(to_copy); + self.data = rest; Ok(take) } } diff --git a/installer/src/dependency_binaries/install/http_test_server.rs b/installer/src/dependency_binaries/install/http_test_server.rs index 17bd35bd1..6dd601de7 100644 --- a/installer/src/dependency_binaries/install/http_test_server.rs +++ b/installer/src/dependency_binaries/install/http_test_server.rs @@ -52,6 +52,7 @@ pub(super) struct LocalServer { } impl LocalServer { + /// Start a loopback HTTP server for the supplied canned routes. pub(super) fn start(routes: HashMap) -> Self { let listener = TcpListener::bind("127.0.0.1:0").expect("bind loopback listener"); let port = listener.local_addr().expect("resolve local addr").port(); @@ -61,9 +62,11 @@ impl LocalServer { let requested = Arc::new(Mutex::new(Vec::new())); let stop = Arc::new(AtomicBool::new(false)); let handle = { - let requested = Arc::clone(&requested); - let stop = Arc::clone(&stop); - thread::spawn(move || run_server(&listener, &routes, &requested, &stop)) + let requested_for_thread = Arc::clone(&requested); + let stop_for_thread = Arc::clone(&stop); + thread::spawn(move || { + run_server(&listener, &routes, &requested_for_thread, &stop_for_thread); + }) }; Self { base_url: format!("http://127.0.0.1:{port}"), @@ -83,10 +86,14 @@ impl LocalServer { } impl Drop for LocalServer { + /// Stop the server loop and wait for its thread to exit. fn drop(&mut self) { self.stop.store(true, Ordering::Relaxed); - if let Some(handle) = self.handle.take() { - let _ = handle.join(); + if let Some(handle) = self.handle.take() + && handle.join().is_err() + { + // The server thread panicked; tests observe failures through the + // requests they make, so nothing further can be reported here. } } } @@ -110,8 +117,14 @@ fn run_server( } } -/// Read one request, record its path, and write the matching canned response -/// (or a 404). `Connection: close` lets the client frame the response end. +/// Read one request, record its path, and write its canned response or a 404. +/// +/// Bound socket reads and writes with five-second timeouts. Drop the connection +/// without responding if socket setup or cloning fails, or if no request line +/// can be read. A header read failure ends header draining but still uses the +/// parsed request path. `Connection: close` frames the response; write or flush +/// failures, including a client disconnect, are not retried and drop the +/// connection on return. fn serve_connection( mut stream: TcpStream, routes: &HashMap, @@ -120,9 +133,14 @@ fn serve_connection( // Restore blocking mode and bound reads/writes on the accepted connection // (the listener is non-blocking only so the accept loop can poll for // shutdown). `try_clone` shares the socket, so `peer` inherits these. - let _ = stream.set_nonblocking(false); - let _ = stream.set_read_timeout(Some(Duration::from_secs(5))); - let _ = stream.set_write_timeout(Some(Duration::from_secs(5))); + // Drop the connection if the socket cannot be configured. + let setup_result = stream + .set_nonblocking(false) + .and_then(|()| stream.set_read_timeout(Some(Duration::from_secs(5)))) + .and_then(|()| stream.set_write_timeout(Some(Duration::from_secs(5)))); + if setup_result.is_err() { + return; + } let Ok(peer) = stream.try_clone() else { return; }; @@ -139,19 +157,24 @@ fn serve_connection( // Drain the remaining request headers up to the blank line. loop { let mut line = String::new(); - match reader.read_line(&mut line) { - Ok(0) => break, - Ok(_) if line == "\r\n" || line == "\n" => break, - Ok(_) => {} - Err(_) => break, + let bytes_read = reader.read_line(&mut line).unwrap_or(0); + if matches!((bytes_read, line.as_str()), (0, _) | (_, "\r\n" | "\n")) { + break; } } // Resolve the route first — the returned references borrow `routes`, not // `path` — so the owned `path` can then move into the request log. - let (status_line, body, declared_len): (&str, &[u8], usize) = match routes.get(&path) { - Some(response) => (response.status_line, &response.body, response.declared_len), - None => ("404 Not Found", b"not found", b"not found".len()), - }; + let not_found: &[u8] = b"not found"; + let (status_line, body, declared_len) = routes.get(&path).map_or_else( + || ("404 Not Found", not_found, not_found.len()), + |response| { + ( + response.status_line, + response.body.as_slice(), + response.declared_len, + ) + }, + ); requested.lock().expect("lock requested paths").push(path); let header = format!( concat!( @@ -162,7 +185,10 @@ fn serve_connection( ), status_line, declared_len, ); - let _ = stream.write_all(header.as_bytes()); - let _ = stream.write_all(body); - let _ = stream.flush(); + let write_result = stream + .write_all(header.as_bytes()) + .and_then(|()| stream.write_all(body)) + .and_then(|()| stream.flush()); + // The client may disconnect early; there is nothing further to serve. + let _ = write_result; } diff --git a/installer/src/deps.rs b/installer/src/deps.rs deleted file mode 100644 index 4be663c2b..000000000 --- a/installer/src/deps.rs +++ /dev/null @@ -1,366 +0,0 @@ -//! Dependency installation for Dylint tools. -//! -//! This module checks whether `cargo-dylint` and `dylint-link` are already -//! available, then installs any missing tools by preferring repository-hosted -//! release archives before falling back to `cargo binstall` or `cargo install`. - -use crate::dependency_binaries::{ - DependencyBinaryInstaller, RepositoryDependencyBinaryInstaller, find_dependency_binary, - host_target, -}; -use crate::dirs::{BaseDirs, SystemBaseDirs}; -use crate::error::{InstallerError, Result}; -use std::io; -use std::io::Write; -use std::path::Path; -use std::process::{Command, Output}; - -mod install; -use install::*; - -/// Abstraction for running external commands. -pub trait CommandExecutor { - /// Runs a command with arguments and returns the captured output. - /// - /// # Errors - /// - /// Returns any I/O errors encountered while spawning or running the command. - fn run(&self, cmd: &str, args: &[&str]) -> Result; -} - -/// Executes commands on the host system. -#[derive(Debug, Clone, Copy, Default)] -pub struct SystemCommandExecutor; - -impl CommandExecutor for SystemCommandExecutor { - fn run(&self, cmd: &str, args: &[&str]) -> Result { - Command::new(cmd) - .args(args) - .output() - .map_err(InstallerError::from) - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -struct DependencyTool { - package: &'static str, - command: &'static str, - args: &'static [&'static str], -} - -const CARGO_DYLINT_TOOL: DependencyTool = DependencyTool { - package: "cargo-dylint", - command: "cargo", - args: &["dylint", "--version"], -}; - -const DYLINT_LINK_TOOL: DependencyTool = DependencyTool { - package: "dylint-link", - command: "dylint-link", - args: &["--version"], -}; - -const DEPENDENCY_TOOLS: [DependencyTool; 2] = [CARGO_DYLINT_TOOL, DYLINT_LINK_TOOL]; - -/// Status of Dylint tool availability. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct DylintToolStatus { - /// Whether `cargo-dylint` is installed. - pub cargo_dylint: bool, - /// Whether `dylint-link` is installed. - pub dylint_link: bool, -} - -impl DylintToolStatus { - /// Returns `true` when both tools are installed. - #[must_use] - pub fn all_installed(&self) -> bool { - self.cargo_dylint && self.dylint_link - } -} - -/// How a run should react when a published artefact is missing. -/// -/// Carried together because they travel together: every layer that decides -/// whether to print also decides whether a source build is acceptable, and -/// threading two booleans through the same five signatures invites them to -/// drift apart. -#[derive(Clone, Copy, Debug, Default)] -pub struct SourcePolicy { - /// Whether stderr output should be suppressed. - pub quiet: bool, - /// Whether a missing artefact is an error rather than a source build. - pub no_source_fallback: bool, -} - -/// Additional install options used by test-support hooks. -#[cfg(any(test, feature = "test-support"))] -pub struct DependencyInstallOptions<'a> { - /// Base directories used by the repository installer. - pub dirs: &'a dyn BaseDirs, - /// The repository-first installer implementation. - pub repository_installer: &'a dyn DependencyBinaryInstaller, - /// Host target override used for repository asset naming. - pub target: Option, - /// How the run reacts to a missing published artefact. - pub policy: SourcePolicy, -} - -/// Checks whether the Dylint tools are installed. -#[must_use] -pub fn check_dylint_tools(executor: &dyn CommandExecutor) -> DylintToolStatus { - DylintToolStatus { - cargo_dylint: is_tool_installed(executor, &CARGO_DYLINT_TOOL), - dylint_link: is_tool_installed(executor, &DYLINT_LINK_TOOL), - } -} - -/// Install missing tools without emitting progress output. -pub fn install_dylint_tools( - executor: &dyn CommandExecutor, - status: &DylintToolStatus, -) -> Result<()> { - let mut sink = io::sink(); - install_dylint_tools_with_output( - executor, - status, - SourcePolicy { - quiet: true, - no_source_fallback: false, - }, - &mut sink, - ) -} - -/// Install missing tools while writing progress output to `stderr`. -pub fn install_dylint_tools_with_output( - executor: &dyn CommandExecutor, - status: &DylintToolStatus, - policy: SourcePolicy, - stderr: &mut dyn Write, -) -> Result<()> { - let repository_installer = RepositoryDependencyBinaryInstaller; - let system_dirs = SystemBaseDirs::new(); - let target = host_target(); - let dirs = system_dirs.as_ref().map(|dirs| dirs as &dyn BaseDirs); - let cargo_fallback_mode = cargo_fallback_mode(executor); - install_missing_tools( - executor, - status, - stderr, - &InstallContext { - repo: repository_install_context( - dirs, - Some(&repository_installer as &dyn DependencyBinaryInstaller), - target.as_ref(), - ), - cargo_fallback_mode, - quiet: policy.quiet, - no_source_fallback: policy.no_source_fallback, - }, - ) -} - -/// Install missing tools with injected repository-install hooks. -#[cfg(any(test, feature = "test-support"))] -#[doc(hidden)] -pub fn install_dylint_tools_with_options( - executor: &dyn CommandExecutor, - status: &DylintToolStatus, - stderr: &mut dyn Write, - options: DependencyInstallOptions<'_>, -) -> Result<()> { - let cargo_fallback_mode = cargo_fallback_mode(executor); - install_missing_tools( - executor, - status, - stderr, - &InstallContext { - repo: repository_install_context( - Some(options.dirs), - Some(options.repository_installer), - options.target.as_ref(), - ), - cargo_fallback_mode, - quiet: options.policy.quiet, - no_source_fallback: options.policy.no_source_fallback, - }, - ) -} - -/// Arguments used to query Cargo's registry of installed binaries. -const CARGO_INSTALL_LIST_ARGS: [&str; 2] = ["install", "--list"]; - -fn is_tool_installed(executor: &dyn CommandExecutor, tool: &DependencyTool) -> bool { - // The manifest is embedded in the binary, so a lookup failure is - // effectively unreachable. Should it ever occur, degrade to the previous - // success-only probes rather than reporting every tool as missing. - let expected_version = find_dependency_binary(tool.package) - .ok() - .flatten() - .map(|dependency| dependency.version()); - - if tool == &DYLINT_LINK_TOOL { - return is_dylint_link_installed(executor, expected_version); - } - is_versioned_tool_installed(executor, tool, expected_version) -} - -fn is_dylint_link_installed( - executor: &dyn CommandExecutor, - expected_version: Option<&str>, -) -> bool { - // Presence is established by resolving an executable file on PATH. - // `dylint-link` is a linker wrapper with no reliable self-reporting - // subcommand, so it is never executed to prove it works. - if find_binary_on_path(DYLINT_LINK_TOOL.command).is_none() { - return false; - } - let Some(expected_version) = expected_version else { - return true; - }; - // `dylint-link` is a pure linker wrapper: it forwards its entire argument - // list to the underlying linker and can never report its own version, so - // `dylint-link --version` is not a usable version source. Query Cargo's - // registry of installed binaries instead, which records the version each - // binary was installed at. - cargo_installed_version(executor, DYLINT_LINK_TOOL.package) - .is_some_and(|version| version == expected_version) -} - -fn is_versioned_tool_installed( - executor: &dyn CommandExecutor, - tool: &DependencyTool, - expected_version: Option<&str>, -) -> bool { - let Some(expected_version) = expected_version else { - return command_succeeds(executor, tool.command, tool.args); - }; - executor.run(tool.command, tool.args).is_ok_and(|output| { - output.status.success() - && first_semver_token(&String::from_utf8_lossy(&output.stdout)) - .is_some_and(|version| version == expected_version) - }) -} - -/// Extracts the installed version of `package` from `cargo install --list`. -/// -/// Matches lines of the form `dylint-link v6.0.1:` (or -/// `dylint-link v6.0.1 (/path):` for path installs), returning the version -/// with the leading `v` and trailing `:` stripped. -fn cargo_installed_version(executor: &dyn CommandExecutor, package: &str) -> Option { - let output = executor.run("cargo", &CARGO_INSTALL_LIST_ARGS).ok()?; - if !output.status.success() { - return None; - } - let stdout = String::from_utf8_lossy(&output.stdout); - stdout.lines().find_map(|line| { - let mut tokens = line.split_whitespace(); - if tokens.next() != Some(package) { - return None; - } - tokens - .next() - .and_then(|token| token.strip_prefix('v')) - .map(|version| version.trim_end_matches(':').to_owned()) - }) -} - -/// Returns the first whitespace-separated token shaped like `MAJOR.MINOR.PATCH`. -fn first_semver_token(text: &str) -> Option<&str> { - text.split_whitespace().find(|token| { - let components: Vec<&str> = token.split('.').collect(); - components.len() == 3 - && components.iter().all(|component| { - !component.is_empty() - && component - .chars() - .all(|character| character.is_ascii_digit()) - }) - }) -} - -fn is_binstall_available(executor: &dyn CommandExecutor) -> bool { - command_succeeds(executor, "cargo", &["binstall", "--version"]) -} - -#[cfg(test)] -fn is_binary_on_path(binary_name: &str) -> bool { - find_binary_on_path(binary_name).is_some() -} - -fn find_binary_on_path(binary_name: &str) -> Option { - let path_var = std::env::var_os("PATH")?; - - std::env::split_paths(&path_var) - .find_map(|directory| find_binary_in_directory(&directory, binary_name)) -} - -fn find_binary_in_directory(directory: &Path, binary_name: &str) -> Option { - binary_candidates(directory, binary_name) - .into_iter() - .find(|candidate| is_executable_file(candidate)) -} - -fn binary_candidates(directory: &Path, binary_name: &str) -> Vec { - #[cfg(windows)] - let mut candidates = Vec::new(); - #[cfg(not(windows))] - let candidates = vec![directory.join(binary_name)]; - #[cfg(windows)] - { - if Path::new(binary_name).extension().is_some() { - candidates.push(directory.join(binary_name)); - } else { - let lowercase_name = binary_name.to_ascii_lowercase(); - candidates.extend( - windows_path_extensions() - .into_iter() - .filter(|extension| !lowercase_name.ends_with(&extension.to_ascii_lowercase())) - .map(|extension| directory.join(format!("{binary_name}{extension}"))), - ); - } - } - candidates -} - -#[cfg(windows)] -fn windows_path_extensions() -> Vec { - let path_ext = std::env::var_os("PATHEXT") - .filter(|value| !value.is_empty()) - .unwrap_or_else(|| std::ffi::OsString::from(".COM;.EXE;.BAT;.CMD")); - - path_ext - .to_string_lossy() - .split(';') - .filter_map(|extension| { - let trimmed = extension.trim(); - if trimmed.is_empty() { - None - } else if trimmed.starts_with('.') { - Some(trimmed.to_owned()) - } else { - Some(format!(".{trimmed}")) - } - }) - .collect() -} - -#[cfg(unix)] -fn is_executable_file(path: &Path) -> bool { - use std::os::unix::fs::PermissionsExt; - - std::fs::metadata(path) - .map(|metadata| metadata.is_file() && metadata.permissions().mode() & 0o111 != 0) - .unwrap_or(false) -} - -#[cfg(not(unix))] -fn is_executable_file(path: &Path) -> bool { - path.is_file() -} - -#[cfg(test)] -mod path_tests; -#[cfg(test)] -mod tests; diff --git a/installer/src/deps/availability_tests.rs b/installer/src/deps/availability_tests.rs new file mode 100644 index 000000000..6c012e3f9 --- /dev/null +++ b/installer/src/deps/availability_tests.rs @@ -0,0 +1,198 @@ +//! Verify dependency queries preserve failure state without emitting events. + +use super::{ + BinaryLocator, PathScanFailure, PathScanFailureCategory, PathScanResult, + check_dylint_tools_with_locator, +}; +use crate::test_utils::{ + ExpectedCall, StubExecutor, + dependency_binary_helpers::{cargo_dylint_check, dylint_link_install_list_check}, + failure_output, +}; +use std::path::PathBuf; +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; +use tracing::Subscriber; +use tracing_subscriber::{Layer, layer::Context, prelude::*}; + +struct FixedBinaryLocator { + binary: Option, + failure: Option, +} + +impl BinaryLocator for FixedBinaryLocator { + /// Return the configured fixture result for the requested executable. + fn find_binary(&self, binary_name: &str) -> PathScanResult { + let failures = self + .failure + .map(|category| { + vec![PathScanFailure { + binary_name: binary_name.to_owned(), + category, + }] + }) + .unwrap_or_default(); + PathScanResult::new(self.binary.clone(), failures) + } +} + +struct EventCounter(Arc); + +impl Layer for EventCounter +where + S: Subscriber, +{ + /// Count events emitted while a dependency query is in scope. + fn on_event(&self, _event: &tracing::Event<'_>, _context: Context<'_, S>) { + self.0.fetch_add(1, Ordering::Relaxed); + } +} + +/// Verify successful dependency lookup returns diagnostics without tracing them. +#[test] +fn dependency_query_returns_path_diagnostics_without_emitting_events() { + let event_count = Arc::new(AtomicUsize::new(0)); + let subscriber = tracing_subscriber::registry().with(EventCounter(Arc::clone(&event_count))); + let locator = FixedBinaryLocator { + binary: Some(PathBuf::from("/fixture/dylint-link")), + failure: Some(PathScanFailureCategory::DirectoryOpen), + }; + let executor = StubExecutor::new(vec![ + cargo_dylint_check().expect("cargo-dylint version should resolve"), + dylint_link_install_list_check().expect("dylint-link version should resolve"), + ]); + + let check = tracing::subscriber::with_default(subscriber, || { + check_dylint_tools_with_locator(&executor, &locator) + .expect("a later PATH match should establish availability") + }); + + assert_eq!(event_count.load(Ordering::Relaxed), 0); + assert_eq!(check.path_scan_failures.len(), 1); + assert_eq!( + check.path_scan_failures[0].category, + PathScanFailureCategory::DirectoryOpen + ); + assert!(check.status.all_installed()); + executor.assert_finished(); +} + +/// Verify unresolved PATH failures make availability queries fail. +#[rstest::rstest] +#[case::path(PathScanFailureCategory::DirectoryOpen, "directory_open")] +#[case::metadata(PathScanFailureCategory::Metadata, "metadata")] +fn unresolved_path_failures_are_errors( + #[case] category: PathScanFailureCategory, + #[case] expected_category: &str, +) { + let locator = FixedBinaryLocator { + binary: None, + failure: Some(category), + }; + let executor = StubExecutor::new(vec![ + cargo_dylint_check().expect("cargo-dylint version should resolve"), + ]); + + let error = check_dylint_tools_with_locator(&executor, &locator) + .expect_err("a failed PATH query must not report a missing tool"); + + assert!(error.to_string().contains(expected_category)); + executor.assert_finished(); +} + +/// Verify command spawn errors remain visible to the dependency caller. +#[test] +fn command_spawn_errors_are_propagated() { + let locator = FixedBinaryLocator { + binary: None, + failure: None, + }; + let executor = StubExecutor::new(vec![ExpectedCall { + cmd: "cargo", + args: vec!["dylint", "--version"], + result: Err(crate::error::InstallerError::Io(std::io::Error::other( + "cargo could not be started", + ))), + }]); + + let error = check_dylint_tools_with_locator(&executor, &locator) + .expect_err("a command spawn error must not look like a missing tool"); + + assert!(error.to_string().contains("cargo could not be started")); + executor.assert_finished(); +} + +/// Verify operational command failures are not treated as missing tools. +#[test] +fn command_failures_are_propagated() { + let locator = FixedBinaryLocator { + binary: None, + failure: None, + }; + let executor = StubExecutor::new(vec![ExpectedCall { + cmd: "cargo", + args: vec!["dylint", "--version"], + result: Ok(failure_output( + "cargo-dylint failed to read its configuration", + )), + }]); + + let error = check_dylint_tools_with_locator(&executor, &locator) + .expect_err("an operational command failure must not look like a missing tool"); + + assert!( + error + .to_string() + .contains("failed to read its configuration") + ); + executor.assert_finished(); +} + +/// Verify Cargo's explicit missing-subcommand response establishes absence. +#[test] +fn missing_cargo_subcommand_is_reported_as_absent() { + let locator = FixedBinaryLocator { + binary: None, + failure: None, + }; + let executor = StubExecutor::new(vec![ExpectedCall { + cmd: "cargo", + args: vec!["dylint", "--version"], + result: Ok(failure_output("error: no such command: `dylint`")), + }]); + + let check = check_dylint_tools_with_locator(&executor, &locator) + .expect("Cargo's explicit missing-subcommand result establishes absence"); + + assert_eq!( + check.status, + crate::deps::DylintToolStatus { + cargo_dylint: false, + dylint_link: false, + } + ); + executor.assert_finished(); +} + +/// Verify failed registry reads remain visible to the dependency caller. +#[test] +fn failed_registry_command_is_propagated() { + let locator = FixedBinaryLocator { + binary: Some(PathBuf::from("/fixture/dylint-link")), + failure: None, + }; + let executor = StubExecutor::new(vec![ + cargo_dylint_check().expect("cargo-dylint version should resolve"), + ExpectedCall { + cmd: "cargo", + args: vec!["install", "--list"], + result: Ok(failure_output("registry failure")), + }, + ]); + + let error = check_dylint_tools_with_locator(&executor, &locator) + .expect_err("a failed registry command must not look like an absent package"); + + assert!(error.to_string().contains("registry failure")); + executor.assert_finished(); +} diff --git a/installer/src/deps/install.rs b/installer/src/deps/install.rs index e0ec81606..d018e32bf 100644 --- a/installer/src/deps/install.rs +++ b/installer/src/deps/install.rs @@ -29,6 +29,12 @@ pub(super) struct InstallContext<'a> { pub(super) no_source_fallback: bool, } +/// Install each missing dependency and refresh availability after installation. +/// +/// # Errors +/// +/// Returns an error if installation, verification, or a follow-up availability +/// query fails. pub(super) fn install_missing_tools( executor: &dyn CommandExecutor, status: &DylintToolStatus, @@ -43,7 +49,12 @@ pub(super) fn install_missing_tools( } let outcome = install_tool(executor, tool, stderr, context)?; - update_status_after_install(&mut remaining_status, executor, tool, outcome); + update_status_after_install(&mut remaining_status, executor, tool, outcome)?; + write_message( + stderr, + context.quiet, + install_success_message(tool, outcome, context.repo.is_some()), + ); } Ok(()) @@ -64,12 +75,17 @@ pub(super) fn repository_install_context<'a>( } } -pub(super) fn cargo_fallback_mode(executor: &dyn CommandExecutor) -> InstallMode { - if is_binstall_available(executor) { +/// Select Cargo Binstall when available, otherwise use `cargo install`. +/// +/// # Errors +/// +/// Returns an error if the Binstall availability query fails. +pub(super) fn cargo_fallback_mode(executor: &dyn CommandExecutor) -> Result { + Ok(if is_binstall_available(executor)? { InstallMode::Binstall } else { InstallMode::CargoInstall - } + }) } pub(super) fn should_install_tool(status: &DylintToolStatus, tool: &DependencyTool) -> bool { @@ -82,6 +98,12 @@ pub(super) fn should_install_tool(status: &DylintToolStatus, tool: &DependencyTo } } +/// Install one dependency, preferring its repository release when configured. +/// +/// # Errors +/// +/// Returns an error if dependency metadata, installation, source policy, or +/// post-install verification fails. pub(super) fn install_tool( executor: &dyn CommandExecutor, tool: &DependencyTool, @@ -109,15 +131,10 @@ pub(super) fn install_tool( cargo_install_plan = cargo_install_plan.with_version(dependency.version()); match repo.installer.install(dependency, repo.target, repo.dirs) { - Ok(_) if repository_install_verified(executor, tool) => { - write_message( - stderr, - context.quiet, - format!("Installed {} from repository release.", tool.package), - ); - return Ok(InstallOutcome::RepositoryRelease); - } Ok(_) => { + if repository_install_verified(executor, tool)? { + return Ok(InstallOutcome::RepositoryRelease); + } refuse_or_announce_fallback(tool, "failed verification", stderr, context)?; } Err(error) if error.is_not_found() => { @@ -229,6 +246,11 @@ impl<'a> CargoInstallPlan<'a> { } } +/// Install a dependency through Cargo, trying Binstall before source install. +/// +/// # Errors +/// +/// Returns an error if a Cargo command cannot run or source installation fails. fn install_tool_with_cargo( executor: &dyn CommandExecutor, cargo_install_plan: CargoInstallPlan<'_>, @@ -237,7 +259,7 @@ fn install_tool_with_cargo( ) -> Result { // Always try binstall first if available; fall back to cargo install on failure if context.cargo_fallback_mode == InstallMode::Binstall && !cargo_install_plan.skip_binstall { - if try_binstall(executor, cargo_install_plan, stderr, context.quiet)? { + if try_binstall(executor, cargo_install_plan)? { return Ok(InstallOutcome::CargoBinstall); } write_message( @@ -250,14 +272,13 @@ fn install_tool_with_cargo( ); } - run_cargo_install(executor, cargo_install_plan, stderr, context.quiet) + run_cargo_install(executor, cargo_install_plan) } +/// Install through Cargo Binstall and verify the resulting executable. fn try_binstall( executor: &dyn CommandExecutor, cargo_install_plan: CargoInstallPlan<'_>, - stderr: &mut dyn Write, - quiet: bool, ) -> Result { let mut args = vec!["binstall", "-y"]; if let Some(version) = cargo_install_plan.version { @@ -270,40 +291,22 @@ fn try_binstall( return Ok(false); } - if !is_tool_installed(executor, cargo_install_plan.tool) { + if !is_tool_installed(executor, cargo_install_plan.tool)? { return Ok(false); } - write_message( - stderr, - quiet, - format!( - "Installed {} with cargo binstall.", - cargo_install_plan.tool.package - ), - ); Ok(true) } +/// Install through Cargo and verify the resulting executable. fn run_cargo_install( executor: &dyn CommandExecutor, cargo_install_plan: CargoInstallPlan<'_>, - stderr: &mut dyn Write, - quiet: bool, ) -> Result { let mut args = vec!["install"]; - let success_message = if let Some(version) = cargo_install_plan.version { + if let Some(version) = cargo_install_plan.version { args.extend(["--locked", "--version", version]); - format!( - "Installed {} from source with cargo install.", - cargo_install_plan.tool.package - ) - } else { - format!( - "Installed {} with cargo install.", - cargo_install_plan.tool.package - ) - }; + } args.push(cargo_install_plan.tool.package); let output = executor.run("cargo", &args)?; @@ -315,7 +318,7 @@ fn run_cargo_install( }); } - if !is_tool_installed(executor, cargo_install_plan.tool) { + if !is_tool_installed(executor, cargo_install_plan.tool)? { return Err(InstallerError::DependencyInstall { tool: cargo_install_plan.tool.package, message: format!( @@ -325,10 +328,31 @@ fn run_cargo_install( }); } - write_message(stderr, quiet, success_message); Ok(InstallOutcome::CargoInstall) } +/// Describe a completed install after its follow-up status checks succeed. +fn install_success_message( + tool: &DependencyTool, + outcome: InstallOutcome, + has_pinned_version: bool, +) -> String { + match outcome { + InstallOutcome::RepositoryRelease => { + format!("Installed {} from repository release.", tool.package) + } + InstallOutcome::CargoBinstall => { + format!("Installed {} with cargo binstall.", tool.package) + } + InstallOutcome::CargoInstall if has_pinned_version => { + format!("Installed {} from source with cargo install.", tool.package) + } + InstallOutcome::CargoInstall => { + format!("Installed {} with cargo install.", tool.package) + } + } +} + /// Verify a repository-release install of `tool`. /// /// The trust boundary for a repository install is established entirely by the @@ -348,9 +372,12 @@ fn run_cargo_install( /// /// `cargo-dylint` keeps the generic check because it reports its own version /// and must additionally be discoverable by Cargo as a subcommand. -fn repository_install_verified(executor: &dyn CommandExecutor, tool: &DependencyTool) -> bool { +fn repository_install_verified( + executor: &dyn CommandExecutor, + tool: &DependencyTool, +) -> Result { if tool == &DYLINT_LINK_TOOL { - return true; + return Ok(true); } is_tool_installed(executor, tool) } @@ -372,32 +399,45 @@ pub(super) fn write_message(stderr: &mut dyn Write, quiet: bool, message: String let _ = writeln!(stderr, "{message}"); } -pub(super) fn command_succeeds(executor: &dyn CommandExecutor, cmd: &str, args: &[&str]) -> bool { - executor - .run(cmd, args) - .is_ok_and(|output| output.status.success()) +/// Run a command and report whether it exits successfully. +/// +/// # Errors +/// +/// Returns an error if the command cannot be started or completed. +pub(super) fn command_succeeds( + executor: &dyn CommandExecutor, + cmd: &str, + args: &[&str], +) -> Result { + Ok(executor.run(cmd, args)?.status.success()) } fn should_refresh_companions(outcome: InstallOutcome, status: &DylintToolStatus) -> bool { outcome != InstallOutcome::RepositoryRelease && !status.dylint_link } +/// Record an installed tool and recheck companion binaries when needed. +/// +/// # Errors +/// +/// Returns an error if checking for the companion `dylint-link` binary fails. fn update_status_after_install( status: &mut DylintToolStatus, executor: &dyn CommandExecutor, tool: &DependencyTool, outcome: InstallOutcome, -) { +) -> Result<()> { if tool == &CARGO_DYLINT_TOOL { status.cargo_dylint = true; if should_refresh_companions(outcome, status) { // Installing cargo-dylint locally can also provide dylint-link. - status.dylint_link = is_tool_installed(executor, &DYLINT_LINK_TOOL); + status.dylint_link = is_tool_installed(executor, &DYLINT_LINK_TOOL)?; } } else if tool == &DYLINT_LINK_TOOL { status.dylint_link = true; } + Ok(()) } #[cfg(test)] diff --git a/installer/src/deps/install_failure_tests.rs b/installer/src/deps/install_failure_tests.rs new file mode 100644 index 000000000..b56a7ffbc --- /dev/null +++ b/installer/src/deps/install_failure_tests.rs @@ -0,0 +1,191 @@ +//! Verify installer command-query errors are propagated through fallback paths. + +use super::*; +use rstest::rstest; + +/// Assert that a dependency command's spawn error remains visible to callers. +fn assert_spawn_error(error: InstallerError, expected_message: &str) { + match error { + InstallerError::Io(source) => assert_eq!(source.to_string(), expected_message), + other => panic!("expected command spawn error, got {other}"), + } +} + +/// Assert that a failed dependency path did not report a successful install. +fn assert_no_install_success_message(stderr: &[u8]) { + let stderr = String::from_utf8_lossy(stderr); + assert!( + !stderr.contains("Installed "), + "failure path emitted a success message: {stderr}" + ); +} + +/// Identify the installer query whose error must be propagated. +#[derive(Clone, Copy, Debug)] +enum QueryFailure { + BinstallAvailability, + PostBinstallVerification, + PostCargoInstallVerification, + RepositoryVerification, + CompanionRefresh, +} + +impl QueryFailure { + /// Return the error text associated with this query failure. + fn message(self) -> &'static str { + match self { + Self::BinstallAvailability => "binstall lookup failed", + Self::PostBinstallVerification => "post-binstall lookup failed", + Self::PostCargoInstallVerification => "post-cargo-install lookup failed", + Self::RepositoryVerification => "repository verification lookup failed", + Self::CompanionRefresh => "companion refresh lookup failed", + } + } + + /// Prepare the repository installer result that reaches this query. + fn configure_repository_installer(self, installer: &mut MockDependencyBinaryInstaller) { + match self { + Self::BinstallAvailability => { + installer.expect_install().never(); + } + Self::RepositoryVerification => { + installer + .expect_install() + .returning(|_, _, _| Ok(PathBuf::from("/tmp/bin/cargo-dylint"))); + } + _ => { + installer + .expect_install() + .returning(|_, _, _| Err(DependencyBinaryInstallError::MissingBinDir)); + } + } + } + + /// Build the command sequence that reaches this failing query. + fn expected_calls(self) -> Vec { + if let Self::BinstallAvailability = self { + return vec![binstall_version_check_with_result(Err(InstallerError::Io( + std::io::Error::other(self.message()), + )))]; + } + + let binstall_available = matches!(self, Self::PostBinstallVerification); + let mut calls = vec![binstall_version_check_with_result(if binstall_available { + Ok(success_output()) + } else { + Ok(failure_output("missing binstall")) + })]; + + if binstall_available { + calls.push( + binstall_install("cargo-dylint", Ok(success_output())) + .expect("binstall install call should be configured"), + ); + } + + if matches!( + self, + Self::PostCargoInstallVerification | Self::CompanionRefresh + ) { + calls.push(ExpectedCall { + cmd: "cargo", + args: vec!["install", "--locked", "--version", "6.0.1", "cargo-dylint"], + result: Ok(success_output()), + }); + } + + match self { + Self::PostBinstallVerification + | Self::PostCargoInstallVerification + | Self::RepositoryVerification => calls.push(cargo_dylint_check_with_result(Err( + InstallerError::Io(std::io::Error::other(self.message())), + ))), + Self::CompanionRefresh => { + calls.push(cargo_dylint_check().expect("cargo-dylint verification is configured")); + calls.push(ExpectedCall { + cmd: "cargo", + args: vec!["install", "--list"], + result: Err(InstallerError::Io(std::io::Error::other(self.message()))), + }); + } + Self::BinstallAvailability => unreachable!("handled before building calls"), + } + + calls + } + + /// Indicate whether this failure occurs while refreshing the companion. + fn refreshes_companion(self) -> bool { + matches!(self, Self::CompanionRefresh) + } +} + +/// Preserve errors from dependency queries across installation paths. +#[rstest] +#[case::binstall_availability(QueryFailure::BinstallAvailability)] +#[case::post_binstall_verification(QueryFailure::PostBinstallVerification)] +#[case::post_cargo_install_verification(QueryFailure::PostCargoInstallVerification)] +#[case::repository_verification(QueryFailure::RepositoryVerification)] +#[case::companion_refresh(QueryFailure::CompanionRefresh)] +fn dependency_query_errors_are_propagated(#[case] failure: QueryFailure) { + let mut repository_installer = MockDependencyBinaryInstaller::new(); + failure.configure_repository_installer(&mut repository_installer); + let executor = StubExecutor::new(failure.expected_calls()); + let mut stderr = Vec::new(); + + let error = with_fake_binary_on_path("dylint-link", || { + install_dylint_tools_with_options( + &executor, + &DylintToolStatus { + cargo_dylint: false, + dylint_link: !failure.refreshes_companion(), + }, + &mut stderr, + install_options(&repository_installer, false), + ) + }) + .expect("temporary companion binary should be staged") + .expect_err("query errors must stop installation"); + + assert_spawn_error(error, failure.message()); + assert_no_install_success_message(&stderr); + executor.assert_finished(); +} + +/// Fall back to Cargo when Binstall succeeds without making the tool available. +#[test] +fn unverified_binstall_install_falls_back_to_cargo_install() { + let mut repository_installer = MockDependencyBinaryInstaller::new(); + repository_installer + .expect_install() + .returning(|_, _, _| Err(DependencyBinaryInstallError::MissingBinDir)); + let executor = StubExecutor::new(vec![ + binstall_version_check_with_result(Ok(success_output())), + binstall_install("cargo-dylint", Ok(success_output())) + .expect("binstall install call should be configured"), + cargo_dylint_check_with_result(Ok(stdout_output("cargo-dylint 5.0.0\n"))), + ExpectedCall { + cmd: "cargo", + args: vec!["install", "--locked", "--version", "6.0.1", "cargo-dylint"], + result: Ok(success_output()), + }, + cargo_dylint_check().expect("cargo-dylint verification should be configured"), + ]); + let mut stderr = Vec::new(); + + install_dylint_tools_with_options( + &executor, + &DylintToolStatus { + cargo_dylint: false, + dylint_link: true, + }, + &mut stderr, + install_options(&repository_installer, false), + ) + .expect("cargo install should recover from an unverified binstall result"); + + let output = String::from_utf8(stderr).expect("stderr should be UTF-8"); + assert!(output.contains("falling back to cargo install")); + assert!(output.contains("Installed cargo-dylint from source with cargo install.")); + executor.assert_finished(); +} diff --git a/installer/src/deps/install_tests.rs b/installer/src/deps/install_tests.rs index 5b352bb65..59784eba0 100644 --- a/installer/src/deps/install_tests.rs +++ b/installer/src/deps/install_tests.rs @@ -7,6 +7,7 @@ use crate::test_utils::dependency_binary_helpers::{ dylint_link_install_list_check, with_fake_binary_on_path, }; +/// Verify local Cargo installs refresh companion link status. #[rstest] #[case(InstallOutcome::CargoBinstall)] #[case(InstallOutcome::CargoInstall)] @@ -25,7 +26,8 @@ fn update_status_after_install_refreshes_link_for_local_cargo_dylint_installs( dylint_link: false, }; - update_status_after_install(&mut status, &executor, &CARGO_DYLINT_TOOL, outcome); + update_status_after_install(&mut status, &executor, &CARGO_DYLINT_TOOL, outcome) + .expect("local Cargo install status should refresh"); assert!(status.cargo_dylint); assert!(status.dylint_link); @@ -34,6 +36,7 @@ fn update_status_after_install_refreshes_link_for_local_cargo_dylint_installs( Ok(()) } +/// Verify repository releases skip the companion link query. #[test] fn update_status_after_install_skips_link_probe_for_repository_release() { let executor = crate::test_utils::StubExecutor::new(vec![]); @@ -47,13 +50,15 @@ fn update_status_after_install_skips_link_probe_for_repository_release() { &executor, &CARGO_DYLINT_TOOL, InstallOutcome::RepositoryRelease, - ); + ) + .expect("repository release status should refresh"); assert!(status.cargo_dylint); assert!(!status.dylint_link); executor.assert_finished(); } +/// Verify installation decisions for both dependency tool statuses. #[rstest] #[case(false, false, &CARGO_DYLINT_TOOL, true)] #[case(true, false, &CARGO_DYLINT_TOOL, false)] @@ -73,6 +78,7 @@ fn should_install_tool_returns_expected( assert_eq!(should_install_tool(&status, tool), expected); } +/// Verify companion refresh decisions for install outcomes and current status. #[rstest] #[case::non_repo_release_and_link_missing( InstallOutcome::CargoInstall, diff --git a/installer/src/deps/mod.rs b/installer/src/deps/mod.rs new file mode 100644 index 000000000..cd018aa3b --- /dev/null +++ b/installer/src/deps/mod.rs @@ -0,0 +1,745 @@ +//! Dependency installation for Dylint tools. +//! +//! This module checks whether `cargo-dylint` and `dylint-link` are already +//! available, then installs any missing tools by preferring repository-hosted +//! release archives before falling back to `cargo binstall` or `cargo install`. + +use crate::dependency_binaries::{ + DependencyBinaryInstaller, RepositoryDependencyBinaryInstaller, find_dependency_binary, + host_target, +}; +use crate::dirs::{BaseDirs, SystemBaseDirs}; +use crate::error::{InstallerError, Result}; +use camino::Utf8Path; +use cap_std::{ambient_authority, fs_utf8::Dir}; +use std::io; +use std::io::Write; +use std::path::Path; +use std::process::{Command, Output}; + +mod install; +use install::*; + +/// Abstraction for running external commands. +pub trait CommandExecutor { + /// Runs a command with arguments and returns the captured output. + /// + /// # Errors + /// + /// Returns any I/O errors encountered while spawning or running the command. + fn run(&self, cmd: &str, args: &[&str]) -> Result; +} + +/// Executes commands on the host system. +#[derive(Debug, Clone, Copy, Default)] +pub struct SystemCommandExecutor; + +impl CommandExecutor for SystemCommandExecutor { + fn run(&self, cmd: &str, args: &[&str]) -> Result { + Command::new(cmd) + .args(args) + .output() + .map_err(InstallerError::from) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct DependencyTool { + package: &'static str, + command: &'static str, + args: &'static [&'static str], +} + +const CARGO_DYLINT_TOOL: DependencyTool = DependencyTool { + package: "cargo-dylint", + command: "cargo", + args: &["dylint", "--version"], +}; + +const DYLINT_LINK_TOOL: DependencyTool = DependencyTool { + package: "dylint-link", + command: "dylint-link", + args: &["--version"], +}; + +const DEPENDENCY_TOOLS: [DependencyTool; 2] = [CARGO_DYLINT_TOOL, DYLINT_LINK_TOOL]; + +/// Stable, bounded failure labels returned while checking a PATH entry. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[doc(hidden)] +pub enum PathScanFailureCategory { + /// A PATH entry cannot be represented as UTF-8 for capability-based access. + NonUtf8Path, + /// A PATH entry cannot be opened as a directory capability. + DirectoryOpen, + /// Candidate metadata cannot be read through the directory capability. + Metadata, +} + +impl PathScanFailureCategory { + /// Return the low-cardinality field value used by trace events. + pub const fn as_str(self) -> &'static str { + match self { + Self::NonUtf8Path => "non_utf8_path", + Self::DirectoryOpen => "directory_open", + Self::Metadata => "metadata", + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +#[doc(hidden)] +pub struct PathScanFailure { + /// Executable being searched for; no filesystem path is retained. + pub binary_name: String, + /// Bounded reason a PATH entry was skipped. + pub category: PathScanFailureCategory, +} + +/// Collect the first match and classified failures for one PATH scan. +#[derive(Debug, Default)] +pub(crate) struct PathScanResult { + binary: Option, + failures: Vec, +} + +impl PathScanResult { + /// Build a result for a found binary and any skipped-entry failures. + #[cfg(test)] + fn new(binary: Option, failures: Vec) -> Self { + Self { binary, failures } + } +} + +/// Query PATH and its filesystem entries for one executable. +pub(crate) trait BinaryLocator { + /// Return the first matching executable and bounded skipped-entry details. + fn find_binary(&self, binary_name: &str) -> PathScanResult; +} + +/// Use the process PATH and host filesystem to locate dependency binaries. +#[derive(Debug, Clone, Copy, Default)] +pub(crate) struct SystemBinaryLocator; + +impl BinaryLocator for SystemBinaryLocator { + /// Search the process PATH and classify failures from skipped entries. + fn find_binary(&self, binary_name: &str) -> PathScanResult { + find_binary_on_path(binary_name) + } +} + +/// Status and diagnostics from a successful availability query. +#[derive(Debug)] +#[doc(hidden)] +pub struct DylintToolCheck { + /// Availability for each required tool. + pub status: DylintToolStatus, + /// PATH entries skipped before a match was found. + pub path_scan_failures: Vec, +} + +/// Classify metadata errors, omitting the ordinary miss for an absent candidate. +fn metadata_failure_category(kind: io::ErrorKind) -> Option { + (kind != io::ErrorKind::NotFound).then_some(PathScanFailureCategory::Metadata) +} + +/// Classify directory-open errors, omitting PATH entries that no longer exist. +fn directory_open_failure_category(kind: io::ErrorKind) -> Option { + (kind != io::ErrorKind::NotFound).then_some(PathScanFailureCategory::DirectoryOpen) +} + +/// Status of Dylint tool availability. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DylintToolStatus { + /// Whether `cargo-dylint` is installed. + pub cargo_dylint: bool, + /// Whether `dylint-link` is installed. + pub dylint_link: bool, +} + +impl DylintToolStatus { + /// Returns `true` when both tools are installed. + #[must_use] + pub fn all_installed(&self) -> bool { + self.cargo_dylint && self.dylint_link + } +} + +/// How a run should react when a published artefact is missing. +/// +/// Carried together because they travel together: every layer that decides +/// whether to print also decides whether a source build is acceptable, and +/// threading two booleans through the same five signatures invites them to +/// drift apart. +#[derive(Clone, Copy, Debug, Default)] +pub struct SourcePolicy { + /// Whether stderr output should be suppressed. + pub quiet: bool, + /// Whether a missing artefact is an error rather than a source build. + pub no_source_fallback: bool, +} + +/// Additional install options used by test-support hooks. +#[cfg(any(test, feature = "test-support"))] +pub struct DependencyInstallOptions<'a> { + /// Base directories used by the repository installer. + pub dirs: &'a dyn BaseDirs, + /// The repository-first installer implementation. + pub repository_installer: &'a dyn DependencyBinaryInstaller, + /// Host target override used for repository asset naming. + pub target: Option, + /// How the run reacts to a missing published artefact. + pub policy: SourcePolicy, +} + +/// Check whether the Dylint tools are installed. +/// +/// A failed command or PATH query is returned as an error so an uncertain +/// result cannot be mistaken for permission to install a missing tool. +/// +/// # Errors +/// +/// Returns an error when a command, registry, or PATH query cannot establish +/// the availability of a dependency. +pub fn check_dylint_tools(executor: &dyn CommandExecutor) -> Result { + check_dylint_tools_with_locator(executor, &SystemBinaryLocator).map(|check| check.status) +} + +/// Check tool availability and return bounded PATH diagnostics for the caller. +/// +/// The command boundary owns any tracing of returned diagnostics. The query +/// itself has no logging side effects. +/// +/// # Errors +/// +/// Returns an error when a command, registry, or PATH query cannot establish +/// the availability of a dependency. +#[doc(hidden)] +pub fn check_dylint_tools_with_diagnostics( + executor: &dyn CommandExecutor, +) -> Result { + check_dylint_tools_with_locator(executor, &SystemBinaryLocator) +} + +/// Check tool availability while retaining PATH diagnostics for the caller. +pub(crate) fn check_dylint_tools_with_locator( + executor: &dyn CommandExecutor, + locator: &dyn BinaryLocator, +) -> Result { + let mut path_scan_failures = Vec::new(); + let cargo_dylint = is_tool_installed_with_locator( + executor, + &CARGO_DYLINT_TOOL, + locator, + &mut path_scan_failures, + )?; + let dylint_link = is_tool_installed_with_locator( + executor, + &DYLINT_LINK_TOOL, + locator, + &mut path_scan_failures, + )?; + + Ok(DylintToolCheck { + status: DylintToolStatus { + cargo_dylint, + dylint_link, + }, + path_scan_failures, + }) +} + +/// Install missing tools without emitting progress output. +pub fn install_dylint_tools( + executor: &dyn CommandExecutor, + status: &DylintToolStatus, +) -> Result<()> { + let mut sink = io::sink(); + install_dylint_tools_with_output( + executor, + status, + SourcePolicy { + quiet: true, + no_source_fallback: false, + }, + &mut sink, + ) +} + +/// Install missing tools while writing progress output to `stderr`. +/// +/// # Errors +/// +/// Returns query, installation, or source-policy errors from the install flow. +pub fn install_dylint_tools_with_output( + executor: &dyn CommandExecutor, + status: &DylintToolStatus, + policy: SourcePolicy, + stderr: &mut dyn Write, +) -> Result<()> { + let repository_installer = RepositoryDependencyBinaryInstaller; + let system_dirs = SystemBaseDirs::new(); + let target = host_target(); + let dirs = system_dirs.as_ref().map(|dirs| dirs as &dyn BaseDirs); + let cargo_fallback_mode = cargo_fallback_mode(executor)?; + install_missing_tools( + executor, + status, + stderr, + &InstallContext { + repo: repository_install_context( + dirs, + Some(&repository_installer as &dyn DependencyBinaryInstaller), + target.as_ref(), + ), + cargo_fallback_mode, + quiet: policy.quiet, + no_source_fallback: policy.no_source_fallback, + }, + ) +} + +/// Install missing tools with injected repository-install hooks. +/// +/// # Errors +/// +/// Returns query, installation, or source-policy errors from the install flow. +#[cfg(any(test, feature = "test-support"))] +#[doc(hidden)] +pub fn install_dylint_tools_with_options( + executor: &dyn CommandExecutor, + status: &DylintToolStatus, + stderr: &mut dyn Write, + options: DependencyInstallOptions<'_>, +) -> Result<()> { + let cargo_fallback_mode = cargo_fallback_mode(executor)?; + install_missing_tools( + executor, + status, + stderr, + &InstallContext { + repo: repository_install_context( + Some(options.dirs), + Some(options.repository_installer), + options.target.as_ref(), + ), + cargo_fallback_mode, + quiet: options.policy.quiet, + no_source_fallback: options.policy.no_source_fallback, + }, + ) +} + +/// Arguments used to query Cargo's registry of installed binaries. +const CARGO_INSTALL_LIST_ARGS: [&str; 2] = ["install", "--list"]; + +/// Check whether a dependency tool is installed at its manifest version. +/// +/// Dylint-link is checked through PATH and Cargo's install list because the +/// wrapper cannot reliably report its own version. The embedded dependency +/// manifest must provide the expected version before availability is decided. +fn is_tool_installed(executor: &dyn CommandExecutor, tool: &DependencyTool) -> Result { + is_tool_installed_with_locator(executor, tool, &SystemBinaryLocator, &mut Vec::new()) +} + +/// Check one tool while returning any PATH failures to the application boundary. +fn is_tool_installed_with_locator( + executor: &dyn CommandExecutor, + tool: &DependencyTool, + locator: &dyn BinaryLocator, + path_scan_failures: &mut Vec, +) -> Result { + let dependency = find_dependency_binary(tool.package) + .map_err(|error| InstallerError::DependencyCheck { + tool: tool.package, + reason: format!("dependency manifest could not be read: {error}"), + })? + .ok_or_else(|| InstallerError::DependencyCheck { + tool: tool.package, + reason: String::from("dependency manifest has no pinned version"), + })?; + let expected_version = dependency.version(); + + if tool == &DYLINT_LINK_TOOL { + return is_dylint_link_installed(executor, expected_version, locator, path_scan_failures); + } + is_versioned_tool_installed(executor, tool, expected_version) +} + +/// Verify `dylint-link` presence and, when known, its Cargo-recorded version. +/// +/// Presence is established by finding an executable on PATH without running +/// the linker wrapper. An expected version is compared with Cargo's install +/// list. If PATH scanning finds no executable after entry failures, or the +/// Cargo query fails, return an error; skipped-entry classifications are +/// collected for the caller. +fn is_dylint_link_installed( + executor: &dyn CommandExecutor, + expected_version: &str, + locator: &dyn BinaryLocator, + path_scan_failures: &mut Vec, +) -> Result { + // Presence is established by resolving an executable file on PATH. + // `dylint-link` is a linker wrapper with no reliable self-reporting + // subcommand, so it is never executed to prove it works. + let path_scan = locator.find_binary(DYLINT_LINK_TOOL.command); + path_scan_failures.extend(path_scan.failures.iter().cloned()); + if path_scan.binary.is_none() { + if path_scan.failures.is_empty() { + return Ok(false); + } + let categories = path_scan + .failures + .iter() + .map(|failure| failure.category.as_str()) + .collect::>() + .join(", "); + return Err(InstallerError::DependencyCheck { + tool: DYLINT_LINK_TOOL.package, + reason: format!( + "PATH scanning encountered {categories} failures and found no executable" + ), + }); + } + // `dylint-link` is a pure linker wrapper: it forwards its entire argument + // list to the underlying linker and can never report its own version, so + // `dylint-link --version` is not a usable version source. Query Cargo's + // registry of installed binaries instead, which records the version each + // binary was installed at. + Ok(cargo_installed_version(executor, DYLINT_LINK_TOOL.package)? + .is_some_and(|version| version == expected_version)) +} + +/// Probe a tool and, when available, compare its reported version. +/// +/// A valid version mismatch means the pinned tool is absent. Unexpected command +/// failures and successful output without a version are query errors. +fn is_versioned_tool_installed( + executor: &dyn CommandExecutor, + tool: &DependencyTool, + expected_version: &str, +) -> Result { + let output = executor.run(tool.command, tool.args)?; + if !output.status.success() { + if tool == &CARGO_DYLINT_TOOL && cargo_reports_missing_subcommand(&output) { + return Ok(false); + } + return Err(InstallerError::DependencyCheck { + tool: tool.package, + reason: command_error_message(&output), + }); + } + let stdout = String::from_utf8_lossy(&output.stdout); + let Some(actual_version) = first_semver_token(&stdout) else { + return Err(InstallerError::DependencyCheck { + tool: tool.package, + reason: String::from("version command succeeded without reporting a semantic version"), + }); + }; + Ok(actual_version == expected_version) +} + +/// Recognize Cargo's explicit diagnostic for an absent `cargo-dylint` command. +fn cargo_reports_missing_subcommand(output: &Output) -> bool { + let stderr = String::from_utf8_lossy(&output.stderr).to_ascii_lowercase(); + stderr.contains("no such command") && stderr.contains("dylint") +} + +/// Extracts the installed version of `package` from `cargo install --list`. +/// +/// Matches lines of the form `dylint-link v6.0.1:` (or +/// `dylint-link v6.0.1 (/path):` for path installs), returning the version +/// with the leading `v` and trailing `:` stripped. +fn cargo_installed_version( + executor: &dyn CommandExecutor, + package: &'static str, +) -> Result> { + let output = executor.run("cargo", &CARGO_INSTALL_LIST_ARGS)?; + if !output.status.success() { + return Err(InstallerError::DependencyCheck { + tool: package, + reason: command_error_message(&output), + }); + } + let stdout = String::from_utf8_lossy(&output.stdout); + Ok(stdout.lines().find_map(|line| { + let mut tokens = line.split_whitespace(); + if tokens.next() != Some(package) { + return None; + } + tokens + .next() + .and_then(|token| token.strip_prefix('v')) + .map(|version| version.trim_end_matches(':').to_owned()) + })) +} + +/// Returns the first whitespace-separated token shaped like `MAJOR.MINOR.PATCH`. +fn first_semver_token(text: &str) -> Option<&str> { + text.split_whitespace().find(|token| { + let components: Vec<&str> = token.split('.').collect(); + components.len() == 3 + && components.iter().all(|component| { + !component.is_empty() + && component + .chars() + .all(|character| character.is_ascii_digit()) + }) + }) +} + +/// Return whether `cargo binstall --version` exits successfully. +/// +/// This checks command availability only; it does not compare a version. +fn is_binstall_available(executor: &dyn CommandExecutor) -> Result { + command_succeeds(executor, "cargo", &["binstall", "--version"]) +} + +/// Check whether a named executable resolves to a file on the current PATH. +/// +/// This test helper reports presence only; it does not verify an installed +/// package version. +#[cfg(test)] +fn is_binary_on_path(binary_name: &str) -> bool { + SystemBinaryLocator + .find_binary(binary_name) + .binary + .is_some() +} + +/// Find the first executable match while retaining skipped-entry classifications. +/// +/// Return an empty report when PATH is unset. Otherwise, scan entries in their +/// configured order and return the classifications without emitting logs. +fn find_binary_on_path(binary_name: &str) -> PathScanResult { + let Some(path_var) = std::env::var_os("PATH") else { + return PathScanResult::default(); + }; + + scan_path_directories(std::env::split_paths(&path_var), binary_name) +} + +/// Scan ordered PATH entries and retain classified failures for the caller. +/// +/// The report preserves every skipped-entry category before the first match so +/// the installer boundary can emit bounded diagnostics without coupling the +/// filesystem query helpers to tracing. +fn scan_path_directories( + directories: impl IntoIterator, + binary_name: &str, +) -> PathScanResult { + let mut result = PathScanResult::default(); + for directory in directories { + result.binary = find_binary_in_directory(&directory, binary_name, &mut result.failures); + if result.binary.is_some() { + break; + } + } + result +} + +/// Find the first executable candidate relative to one UTF-8 PATH directory. +/// +/// Non-UTF-8 entries and directories that cannot be opened are skipped so the +/// caller can continue searching later PATH entries. Candidate metadata is +/// inspected through the opened directory capability rather than ambient +/// filesystem access. Failures are returned as categories without retaining +/// the directory path. +fn find_binary_in_directory( + directory: &Path, + binary_name: &str, + failures: &mut Vec, +) -> Option { + let Some(utf8_directory) = Utf8Path::from_path(directory) else { + failures.push(PathScanFailure { + binary_name: binary_name.to_owned(), + category: PathScanFailureCategory::NonUtf8Path, + }); + return None; + }; + let directory_capability = match Dir::open_ambient_dir(utf8_directory, ambient_authority()) { + Ok(directory_capability) => directory_capability, + Err(error) => { + if let Some(category) = directory_open_failure_category(error.kind()) { + failures.push(PathScanFailure { + binary_name: binary_name.to_owned(), + category, + }); + } + return None; + } + }; + + let mut failure_categories = Vec::new(); + let candidate = binary_candidates(binary_name) + .into_iter() + .find(|candidate| { + is_executable_file( + &directory_capability, + Utf8Path::new(candidate), + &mut failure_categories, + ) + }); + failures.extend( + failure_categories + .into_iter() + .map(|category| PathScanFailure { + binary_name: binary_name.to_owned(), + category, + }), + ); + candidate.map(|candidate| directory.join(candidate)) +} + +/// Return executable candidate names in their PATH-search order. +/// +/// Unix returns the requested name unchanged. Windows expands extensionless +/// names using `PATHEXT`; an existing extension is preserved as the sole +/// candidate. +fn binary_candidates(binary_name: &str) -> Vec { + #[cfg(windows)] + let mut candidates = Vec::new(); + #[cfg(not(windows))] + let candidates = vec![binary_name.to_owned()]; + #[cfg(windows)] + { + if Path::new(binary_name).extension().is_some() { + candidates.push(binary_name.to_owned()); + } else { + let lowercase_name = binary_name.to_ascii_lowercase(); + candidates.extend( + windows_path_extensions() + .into_iter() + .filter(|extension| !lowercase_name.ends_with(&extension.to_ascii_lowercase())) + .map(|extension| format!("{binary_name}{extension}")), + ); + } + } + candidates +} + +/// Parse `PATHEXT` into suffixes used to probe Windows PATH entries. +#[cfg(windows)] +fn windows_path_extensions() -> Vec { + let path_ext = std::env::var_os("PATHEXT") + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| std::ffi::OsString::from(".COM;.EXE;.BAT;.CMD")); + + path_ext + .to_string_lossy() + .split(';') + .filter_map(|extension| { + let trimmed = extension.trim(); + if trimmed.is_empty() { + None + } else if trimmed.starts_with('.') { + Some(trimmed.to_owned()) + } else { + Some(format!(".{trimmed}")) + } + }) + .collect() +} + +/// Classify a candidate through its containing directory capability. +/// +/// Return whether the candidate is a regular file with an executable bit. +/// +/// Missing candidates return `false` without a failure category; other +/// metadata errors add the bounded `Metadata` category and also return `false`. +#[cfg(unix)] +fn is_executable_file( + directory: &Dir, + candidate: &Utf8Path, + failures: &mut Vec, +) -> bool { + use cap_std::fs::PermissionsExt; + + match directory.metadata(candidate) { + Ok(metadata) => metadata.is_file() && metadata.permissions().mode() & 0o111 != 0, + Err(error) => { + if let Some(category) = metadata_failure_category(error.kind()) { + failures.push(category); + } + false + } + } +} + +/// Classify a candidate as a regular file through its directory capability. +/// +/// Missing candidates return `false` without a failure category; other +/// metadata errors add the bounded `Metadata` category. Otherwise, return +/// whether the candidate is a regular file. +#[cfg(not(unix))] +fn is_executable_file( + directory: &Dir, + candidate: &Utf8Path, + failures: &mut Vec, +) -> bool { + match directory.metadata(candidate) { + Ok(metadata) => metadata.is_file(), + Err(error) => { + if let Some(category) = metadata_failure_category(error.kind()) { + failures.push(category); + } + false + } + } +} + +#[cfg(test)] +mod availability_tests; +#[cfg(test)] +mod path_tests; +#[cfg(test)] +mod tests; + +#[cfg(test)] +mod path_scan_tests { + //! Verify that PATH scan failure categories remain stable and bounded. + + use super::{ + PathScanFailureCategory, directory_open_failure_category, metadata_failure_category, + }; + + /// Treat an absent PATH candidate as routine while preserving other failures. + #[test] + fn metadata_failures_exclude_missing_candidates() { + assert_eq!( + metadata_failure_category(std::io::ErrorKind::NotFound), + None + ); + assert_eq!( + metadata_failure_category(std::io::ErrorKind::PermissionDenied), + Some(PathScanFailureCategory::Metadata) + ); + } + + /// Treat disappeared PATH directories as misses while preserving other errors. + #[test] + fn directory_open_failures_exclude_missing_directories() { + assert_eq!( + directory_open_failure_category(std::io::ErrorKind::NotFound), + None + ); + assert_eq!( + directory_open_failure_category(std::io::ErrorKind::PermissionDenied), + Some(PathScanFailureCategory::DirectoryOpen) + ); + } + + /// Keep path-scan failure category labels stable and bounded. + #[test] + fn path_scan_failure_categories_are_stable_and_bounded() { + assert_eq!( + PathScanFailureCategory::NonUtf8Path.as_str(), + "non_utf8_path" + ); + assert_eq!( + PathScanFailureCategory::DirectoryOpen.as_str(), + "directory_open" + ); + assert_eq!(PathScanFailureCategory::Metadata.as_str(), "metadata"); + } +} diff --git a/installer/src/deps/path_tests.rs b/installer/src/deps/path_tests.rs index 58655fc9c..b3a5b03d1 100644 --- a/installer/src/deps/path_tests.rs +++ b/installer/src/deps/path_tests.rs @@ -1,5 +1,11 @@ //! Tests for PATH-based dependency-binary discovery helpers. +// These imports are used only by the Unix executable-permission test; gating +// them keeps Windows test builds free of unused-import warnings. +#[cfg(unix)] +use camino::Utf8Path; +#[cfg(unix)] +use cap_std::{ambient_authority, fs_utf8::Dir}; use temp_env::with_vars_unset; use super::*; @@ -16,6 +22,11 @@ use crate::{ }, }; +/// Verify both installed tools are reported as available. +/// +/// # Errors +/// +/// Returns an I/O error if the temporary PATH fixture cannot be created. #[test] fn check_dylint_tools_reports_installed_tools() -> std::io::Result<()> { with_fake_binary_on_path("dylint-link", || { @@ -25,7 +36,8 @@ fn check_dylint_tools_reports_installed_tools() -> std::io::Result<()> { .expect("dylint-link dependency version should resolve"), ]); - let status = check_dylint_tools(&executor); + let status = + check_dylint_tools(&executor).expect("available dependency checks should complete"); assert_eq!( status, @@ -39,9 +51,13 @@ fn check_dylint_tools_reports_installed_tools() -> std::io::Result<()> { Ok(()) } +/// Reject Cargo output that does not report the pinned `cargo-dylint` version. +/// +/// # Errors +/// +/// Returns an I/O error if the temporary PATH fixture cannot be created. #[rstest::rstest] #[case::stale_version("cargo-dylint 5.0.0\n")] -#[case::unparsable_output("not a version\n")] fn check_dylint_tools_rejects_unusable_cargo_dylint_output( #[case] version_stdout: &str, ) -> std::io::Result<()> { @@ -53,7 +69,8 @@ fn check_dylint_tools_rejects_unusable_cargo_dylint_output( stdout_output(version_stdout), ))]); - let status = check_dylint_tools(&executor); + let status = + check_dylint_tools(&executor).expect("available dependency checks should complete"); assert_eq!( status, @@ -68,6 +85,39 @@ fn check_dylint_tools_rejects_unusable_cargo_dylint_output( Ok(()) } +/// Reject successful Cargo output that contains no parseable version. +/// +/// # Errors +/// +/// Returns an I/O error if the temporary PATH fixture cannot be created. +#[test] +fn check_dylint_tools_rejects_unparsable_cargo_dylint_output() -> std::io::Result<()> { + with_fake_path( + |_| Ok(()), + || { + let executor = StubExecutor::new(vec![cargo_dylint_check_with_result(Ok( + stdout_output("not a version\n"), + ))]); + + let error = check_dylint_tools(&executor) + .expect_err("unparsable version output must not authorize installation"); + + assert!( + error + .to_string() + .contains("without reporting a semantic version") + ); + executor.assert_finished(); + }, + )?; + Ok(()) +} + +/// Reject `dylint-link` when Cargo does not record the pinned installed version. +/// +/// # Errors +/// +/// Returns an I/O error if the temporary PATH fixture cannot be created. #[rstest::rstest] #[case::stale_version(dylint_link_install_list_check_with_version("5.0.0"))] #[case::missing_from_list(ExpectedCall { @@ -84,7 +134,8 @@ fn check_dylint_tools_rejects_unpinned_dylint_link( install_list_check, ]); - let status = check_dylint_tools(&executor); + let status = + check_dylint_tools(&executor).expect("available dependency checks should complete"); assert_eq!( status, @@ -98,8 +149,9 @@ fn check_dylint_tools_rejects_unpinned_dylint_link( Ok(()) } +/// Verify PATH detection never executes the discovered dylint-link wrapper. #[test] -fn check_dylint_tools_rejects_non_invocable_dylint_link_on_path() -> std::io::Result<()> { +fn check_dylint_tools_does_not_invoke_dylint_link_on_path() -> std::io::Result<()> { with_fake_path( |directories| { let first_dir = &directories[0]; @@ -113,15 +165,18 @@ fn check_dylint_tools_rejects_non_invocable_dylint_link_on_path() -> std::io::Re || { let executor = StubExecutor::new(vec![ cargo_dylint_check().expect("cargo-dylint dependency version should resolve"), + dylint_link_install_list_check() + .expect("dylint-link dependency version should resolve"), ]); - let status = check_dylint_tools(&executor); + let status = + check_dylint_tools(&executor).expect("available dependency checks should complete"); assert_eq!( status, DylintToolStatus { cargo_dylint: true, - dylint_link: false, + dylint_link: true, } ); executor.assert_finished(); @@ -177,28 +232,197 @@ fn is_binary_on_path_checks_multiple_directories() -> std::io::Result<()> { Ok(()) } +/// Verify executable permission bits determine Unix candidate classification. +/// +/// # Errors +/// +/// Returns an I/O error if the temporary candidate cannot be created. #[cfg(unix)] -#[test] -fn is_executable_file_rejects_non_executable_files() -> std::io::Result<()> { +#[rstest::rstest] +#[case::non_executable(false, false)] +#[case::executable(true, true)] +fn is_executable_file_matches_executable_permission( + #[case] has_execute_permission: bool, + #[case] expected_is_executable: bool, +) -> std::io::Result<()> { let temp_dir = tempfile::tempdir().expect("create temp dir"); let binary_path = temp_dir.path().join("dylint-link"); - write_fake_binary(&binary_path, false)?; + write_fake_binary(&binary_path, has_execute_permission)?; - assert!(!is_executable_file(&binary_path)); + let directory = Dir::open_ambient_dir( + Utf8Path::from_path(temp_dir.path()).expect("temp directory path should be UTF-8"), + ambient_authority(), + ) + .expect("open temp-dir capability"); + + assert_eq!( + is_executable_file(&directory, Utf8Path::new("dylint-link"), &mut Vec::new(),), + expected_is_executable, + ); Ok(()) } +/// Verify skipped invalid entries are classified and do not stop later searches. +/// +/// # Errors +/// +/// Returns an I/O error if the temporary PATH fixtures cannot be created. #[cfg(unix)] #[test] -fn is_executable_file_accepts_executable_files() -> std::io::Result<()> { - let temp_dir = tempfile::tempdir().expect("create temp dir"); - let binary_path = temp_dir.path().join("dylint-link"); - write_fake_binary(&binary_path, true)?; +fn path_scan_classifies_invalid_entries_and_continues_to_a_later_binary() -> std::io::Result<()> { + use std::os::unix::ffi::{OsStrExt, OsStringExt}; - assert!(is_executable_file(&binary_path)); + let temp_dir = tempfile::tempdir()?; + let mut invalid_path_bytes = temp_dir.path().as_os_str().as_bytes().to_vec(); + invalid_path_bytes.push(0xff); + let non_utf8_entry = std::path::PathBuf::from(std::ffi::OsString::from_vec(invalid_path_bytes)); + + let not_a_directory = temp_dir.path().join("not-a-directory"); + std::fs::write(¬_a_directory, b"not a directory")?; + + let valid_directory = tempfile::tempdir()?; + let expected_binary = valid_directory.path().join("dylint-link"); + write_fake_binary(&expected_binary, true)?; + + let scan = scan_path_directories( + vec![ + non_utf8_entry, + not_a_directory, + valid_directory.path().to_path_buf(), + ], + "dylint-link", + ); + + assert_eq!(scan.binary, Some(expected_binary)); + assert_eq!( + scan.failures, + vec![ + PathScanFailure { + binary_name: "dylint-link".to_owned(), + category: PathScanFailureCategory::NonUtf8Path, + }, + PathScanFailure { + binary_name: "dylint-link".to_owned(), + category: PathScanFailureCategory::DirectoryOpen, + }, + ] + ); + Ok(()) +} + +/// Verify missing candidates are quiet and the first executable wins. +/// +/// # Errors +/// +/// Returns an I/O error if the temporary PATH fixtures cannot be created. +#[test] +fn path_scan_skips_missing_candidates_and_returns_the_first_binary() -> std::io::Result<()> { + let empty_directory = tempfile::tempdir()?; + let first_binary_directory = tempfile::tempdir()?; + let later_binary_directory = tempfile::tempdir()?; + let binary_name = "dylint-link.exe"; + let expected_binary = first_binary_directory.path().join(binary_name); + write_fake_binary(&expected_binary, true)?; + write_fake_binary(&later_binary_directory.path().join(binary_name), true)?; + + let scan = scan_path_directories( + vec![ + empty_directory.path().to_path_buf(), + first_binary_directory.path().to_path_buf(), + later_binary_directory.path().to_path_buf(), + ], + binary_name, + ); + + assert_eq!(scan.binary, Some(expected_binary)); + assert!(scan.failures.is_empty()); Ok(()) } +/// Skip a PATH directory that disappeared and continue to a later executable. +/// +/// # Errors +/// +/// Returns an I/O error if the temporary PATH fixtures cannot be created. +#[test] +fn path_scan_skips_missing_directories_and_returns_a_later_binary() -> std::io::Result<()> { + let fixture_root = tempfile::tempdir()?; + let missing_directory = fixture_root.path().join("removed-from-path"); + let valid_directory = tempfile::tempdir()?; + let binary_name = "dylint-link.exe"; + let expected_binary = valid_directory.path().join(binary_name); + write_fake_binary(&expected_binary, true)?; + + let scan = scan_path_directories( + vec![missing_directory, valid_directory.path().to_path_buf()], + binary_name, + ); + + assert_eq!(scan.binary, Some(expected_binary)); + assert!(scan.failures.is_empty()); + Ok(()) +} + +/// Check first-match and stop-after-match behaviour for bounded PATH patterns. +#[test] +fn path_scan_ordering_holds_for_bounded_match_patterns() { + use proptest::prelude::*; + + let mut runner = proptest::test_runner::TestRunner::new(proptest::test_runner::Config { + failure_persistence: None, + rng_seed: proptest::test_runner::RngSeed::Fixed(0x51_7a_00), + ..proptest::test_runner::Config::default() + }); + runner + .run(&prop::collection::vec(0_u8..3, 1..=5), |entry_kinds| { + let mut roots = Vec::new(); + let directories = entry_kinds + .iter() + .map(|entry_kind| { + let directory = tempfile::tempdir().expect("create PATH fixture directory"); + let entry = if *entry_kind == 0 { + directory.path().to_path_buf() + } else if *entry_kind == 1 { + let file = directory.path().join("not-a-directory"); + std::fs::write(&file, b"not a directory") + .expect("create non-directory PATH entry"); + file + } else { + write_fake_binary(&directory.path().join("probe.exe"), true) + .expect("create executable PATH fixture"); + directory.path().to_path_buf() + }; + roots.push(directory); + entry + }) + .collect::>(); + let expected_binary = + directories + .iter() + .zip(&entry_kinds) + .find_map(|(directory, entry_kind)| { + (*entry_kind == 2).then(|| directory.join("probe.exe")) + }); + let expected_failures = entry_kinds + .iter() + .take_while(|entry_kind| **entry_kind != 2) + .filter(|entry_kind| **entry_kind == 1) + .map(|_| PathScanFailure { + binary_name: "probe.exe".to_owned(), + category: PathScanFailureCategory::DirectoryOpen, + }) + .collect::>(); + + let scan = scan_path_directories(directories, "probe.exe"); + + prop_assert_eq!(scan.binary, expected_binary); + prop_assert_eq!(scan.failures, expected_failures); + drop(roots); + Ok(()) + }) + .expect("ordered PATH scan invariant should hold for generated layouts"); +} + #[cfg(windows)] #[rstest::rstest] #[case("dylint-link.exe", 0, true)] @@ -217,6 +441,11 @@ fn is_binary_on_path_handles_windows_executable_suffixes( Ok(()) } +/// Detect `dylint-link` through a suffix supplied by the Windows `PATHEXT`. +/// +/// # Errors +/// +/// Returns an I/O error if the temporary PATH fixture cannot be created. #[cfg(windows)] #[test] fn check_dylint_tools_detects_dylint_link_via_pathext_suffix() -> std::io::Result<()> { @@ -232,7 +461,8 @@ fn check_dylint_tools_detects_dylint_link_via_pathext_suffix() -> std::io::Resul .expect("dylint-link dependency version should resolve"), ]); - let status = check_dylint_tools(&executor); + let status = check_dylint_tools(&executor) + .expect("available dependency checks should complete"); assert_eq!( status, diff --git a/installer/src/deps/tests.rs b/installer/src/deps/tests.rs index 5b568c5e6..1049fd2aa 100644 --- a/installer/src/deps/tests.rs +++ b/installer/src/deps/tests.rs @@ -7,7 +7,9 @@ use crate::test_utils::dependency_binary_helpers::{ binstall_install, binstall_version_check_with_result, cargo_dylint_check, cargo_dylint_check_with_result, dylint_link_install_list_check, with_fake_binary_on_path, }; -use crate::test_utils::{ExpectedCall, StubDirs, StubExecutor, failure_output, success_output}; +use crate::test_utils::{ + ExpectedCall, StubDirs, StubExecutor, failure_output, stdout_output, success_output, +}; use std::path::PathBuf; fn install_options<'a>( @@ -69,6 +71,9 @@ fn install_dylint_tools_uses_repository_release_first() { executor.assert_finished(); } +#[path = "install_failure_tests.rs"] +mod failure_tests; + #[test] fn install_dylint_tools_falls_back_to_binstall_when_repository_unavailable() { let mut repository_installer = MockDependencyBinaryInstaller::new(); @@ -136,15 +141,16 @@ fn install_dylint_tools_falls_back_to_cargo_install_when_binstall_missing() { executor.assert_finished(); } +/// Exercise source fallback when repository verification reports a stale version. #[test] -fn install_dylint_tools_falls_back_when_repository_verification_fails() { +fn install_dylint_tools_falls_back_when_repository_verification_reports_a_stale_version() { let mut repository_installer = MockDependencyBinaryInstaller::new(); repository_installer .expect_install() .returning(|_, _, _| Ok(PathBuf::from("/tmp/bin/cargo-dylint"))); let executor = StubExecutor::new(vec![ binstall_version_check_with_result(Ok(success_output())), - cargo_dylint_check_with_result(Ok(failure_output("still missing"))), + cargo_dylint_check_with_result(Ok(stdout_output("cargo-dylint 5.0.0\n"))), binstall_install("cargo-dylint", Ok(success_output())) .expect("cargo-dylint dependency version should resolve"), cargo_dylint_check().expect("cargo-dylint dependency version should resolve"), diff --git a/installer/src/error.rs b/installer/src/error.rs index 41bb4ece8..25c684ab8 100644 --- a/installer/src/error.rs +++ b/installer/src/error.rs @@ -173,6 +173,15 @@ pub enum InstallerError { message: String, }, + /// A dependency availability query could not establish whether a tool is installed. + #[error("failed to check whether {tool} is installed: {reason}")] + DependencyCheck { + /// Name of the tool whose availability could not be established. + tool: &'static str, + /// Description of the command or filesystem query failure. + reason: String, + }, + /// Wrapper script generation failed. #[error("wrapper script generation failed: {0}")] WrapperGeneration(String), @@ -202,13 +211,45 @@ pub enum InstallerError { }, } -// Helper to clone io::Error by preserving ErrorKind and formatted message. -// This is lossy: the original source chain is discarded. +/// Clone an I/O error while preserving its kind and formatted message. +/// +/// The clone is lossy because the original source chain is discarded. fn clone_io_error(source: &std::io::Error) -> std::io::Error { std::io::Error::new(source.kind(), source.to_string()) } impl InstallerError { + /// Clone the source-policy variants while preserving their policy details. + fn clone_source_policy_variant(&self) -> Option { + match self { + Self::ConflictingSourceOptions { option } => Some(Self::ConflictingSourceOptions { + option: option.clone(), + }), + Self::SourceFallbackForbidden { artefact, reason } => { + Some(Self::SourceFallbackForbidden { + artefact: artefact.clone(), + reason: reason.clone(), + }) + } + _ => None, + } + } + + /// Clone the I/O-backed variants while preserving their error details. + fn clone_io_variant(&self) -> Option { + match self { + Self::Io(source) => Some(Self::Io(clone_io_error(source))), + Self::ScanFailed { source } => Some(Self::ScanFailed { + source: clone_io_error(source), + }), + Self::WriteFailed { source } => Some(Self::WriteFailed { + source: clone_io_error(source), + }), + _ => None, + } + } + + /// Clone the toolchain variants while preserving their semantic details. fn clone_toolchain_variant(&self) -> Option { match self { Self::ToolchainDetection { reason } => Some(Self::ToolchainDetection { @@ -244,8 +285,13 @@ impl InstallerError { } impl Clone for InstallerError { + /// Clone every error variant while preserving its displayable details. fn clone(&self) -> Self { - if let Some(value) = self.clone_toolchain_variant() { + if let Some(value) = self + .clone_toolchain_variant() + .or_else(|| self.clone_io_variant()) + .or_else(|| self.clone_source_policy_variant()) + { return value; } @@ -276,7 +322,6 @@ impl Clone for InstallerError { path: path.clone(), reason: reason.clone(), }, - Self::Io(source) => Self::Io(clone_io_error(source)), Self::Git { operation, message } => Self::Git { operation, message: message.clone(), @@ -285,27 +330,23 @@ impl Clone for InstallerError { tool, message: message.clone(), }, - Self::ConflictingSourceOptions { option } => Self::ConflictingSourceOptions { - option: option.clone(), - }, - Self::SourceFallbackForbidden { artefact, reason } => Self::SourceFallbackForbidden { - artefact: artefact.clone(), + Self::DependencyCheck { tool, reason } => Self::DependencyCheck { + tool, reason: reason.clone(), }, Self::WrapperGeneration(message) => Self::WrapperGeneration(message.clone()), - Self::ScanFailed { source } => Self::ScanFailed { - source: clone_io_error(source), - }, - Self::WriteFailed { source } => Self::WriteFailed { - source: clone_io_error(source), - }, - Self::ToolchainDetection { .. } + Self::ConflictingSourceOptions { .. } + | Self::SourceFallbackForbidden { .. } + | Self::ToolchainDetection { .. } | Self::ToolchainFileNotFound { .. } | Self::InvalidToolchainFile { .. } | Self::ToolchainNotInstalled { .. } | Self::ToolchainInstallFailed { .. } - | Self::ToolchainComponentInstallFailed { .. } => { - unreachable!("handled by clone_toolchain_variant") + | Self::ToolchainComponentInstallFailed { .. } + | Self::Io(_) + | Self::ScanFailed { .. } + | Self::WriteFailed { .. } => { + unreachable!("handled by a clone helper") } #[cfg(any(test, feature = "test-support"))] Self::StubMismatch { message } => Self::StubMismatch { diff --git a/installer/src/error_tests.rs b/installer/src/error_tests.rs index 56ca2d4fe..e7066da24 100644 --- a/installer/src/error_tests.rs +++ b/installer/src/error_tests.rs @@ -132,8 +132,9 @@ fn source_fallback_forbidden_carries_the_artefact_and_the_reason() { assert!(message.contains("not found"), "got: {message}"); } +/// Verify policy and I/O error variants retain their messages when cloned. #[test] -fn the_policy_errors_survive_a_clone() { +fn policy_and_io_errors_survive_a_clone() { // `InstallerError::clone` matches every variant by hand, so a new variant // that nobody added an arm for is a panic waiting for the first caller. for err in [ @@ -144,6 +145,13 @@ fn the_policy_errors_survive_a_clone() { artefact: "a prebuilt lint library".to_owned(), reason: "repository asset not found".to_owned(), }, + InstallerError::Io(std::io::Error::other("standalone I/O error")), + InstallerError::ScanFailed { + source: std::io::Error::other("scan I/O error"), + }, + InstallerError::WriteFailed { + source: std::io::Error::other("write I/O error"), + }, ] { assert_eq!(err.clone().to_string(), err.to_string()); } diff --git a/installer/src/install_flow.rs b/installer/src/install_flow.rs index 5b873f455..2e2c6c36e 100644 --- a/installer/src/install_flow.rs +++ b/installer/src/install_flow.rs @@ -13,11 +13,7 @@ use std::time::Duration; use whitaker_installer::builder::{library_extension, library_prefix}; use whitaker_installer::cli::InstallArgs; use whitaker_installer::crate_name::CrateName; -use whitaker_installer::deps::{ - CommandExecutor, SourcePolicy, check_dylint_tools, install_dylint_tools_with_output, -}; -#[cfg(test)] -use whitaker_installer::deps::{DependencyInstallOptions, install_dylint_tools_with_options}; +use whitaker_installer::deps::{CommandExecutor, DylintToolStatus, SourcePolicy}; use whitaker_installer::dirs::BaseDirs; use whitaker_installer::error::{InstallerError, Result}; use whitaker_installer::install_metrics::{InstallMode, RecordOutcome, record_install}; @@ -26,6 +22,10 @@ use whitaker_installer::prebuilt::{PrebuiltConfig, PrebuiltResult, attempt_prebu use whitaker_installer::prebuilt_path::prebuilt_library_dir; use whitaker_installer::resolution::{EXPERIMENTAL_LINT_CRATES, LINT_CRATES, SUITE_CRATE}; +mod dependency_check; +#[cfg(all(test, unix))] +pub(crate) use dependency_check::check_dylint_tools_for_command; + pub(crate) fn ensure_dylint_tools_core( quiet: bool, stderr: &mut dyn Write, @@ -50,29 +50,21 @@ pub(crate) fn ensure_dylint_tools_core( Ok(()) } +/// Query availability once, then delegate missing-tool installation. +/// +/// The callback keeps query ordering shared by the CLI and tests while +/// leaving the concrete installation boundary injectable. pub(crate) fn ensure_dylint_tools_with_executor( executor: &dyn CommandExecutor, policy: SourcePolicy, stderr: &mut dyn Write, + install: impl FnOnce(&DylintToolStatus, &mut dyn Write) -> Result<()>, ) -> Result<()> { - let status = check_dylint_tools(executor); - ensure_dylint_tools_core(policy.quiet, stderr, status.all_installed(), |stderr| { - install_dylint_tools_with_output(executor, &status, policy, stderr) - }) -} - -#[cfg(test)] -pub(crate) fn ensure_dylint_tools_with_options( - executor: &dyn CommandExecutor, - stderr: &mut dyn Write, - options: DependencyInstallOptions<'_>, -) -> Result<()> { - let status = check_dylint_tools(executor); - ensure_dylint_tools_core( - options.policy.quiet, + dependency_check::ensure_dylint_tools_with_query( + policy.quiet, stderr, - status.all_installed(), - |stderr| install_dylint_tools_with_options(executor, &status, stderr, options), + || dependency_check::check_dylint_tools_for_command(executor), + install, ) } @@ -371,3 +363,7 @@ fn write_metrics_summary(quiet: bool, record_outcome: &RecordOutcome, stderr: &m #[cfg(test)] mod tests; + +#[cfg(test)] +#[path = "install_flow/dependency_check_tests.rs"] +mod dependency_check_tests; diff --git a/installer/src/install_flow/dependency_check.rs b/installer/src/install_flow/dependency_check.rs new file mode 100644 index 000000000..44c4d569d --- /dev/null +++ b/installer/src/install_flow/dependency_check.rs @@ -0,0 +1,41 @@ +//! Query Dylint dependency availability before the install-flow decision. + +use std::io::Write; +use tracing::debug; +use whitaker_installer::deps::{ + CommandExecutor, DylintToolStatus, PathScanFailure, check_dylint_tools_with_diagnostics, +}; +use whitaker_installer::error::Result; + +/// Query dependency availability and emit bounded PATH diagnostics. +pub(crate) fn check_dylint_tools_for_command( + executor: &dyn CommandExecutor, +) -> Result { + let check = check_dylint_tools_with_diagnostics(executor)?; + report_path_scan_failures(&check.path_scan_failures); + Ok(check.status) +} + +/// Report skipped PATH entries without exposing their paths. +fn report_path_scan_failures(failures: &[PathScanFailure]) { + for failure in failures { + debug!( + binary_name = ?failure.binary_name, + failure_category = failure.category.as_str(), + "skipping PATH scan candidate after lookup failure" + ); + } +} + +/// Install only after a successful query establishes which tools are missing. +pub(super) fn ensure_dylint_tools_with_query( + quiet: bool, + stderr: &mut dyn Write, + query: impl FnOnce() -> Result, + install: impl FnOnce(&DylintToolStatus, &mut dyn Write) -> Result<()>, +) -> Result<()> { + let status = query()?; + super::ensure_dylint_tools_core(quiet, stderr, status.all_installed(), |stderr| { + install(&status, stderr) + }) +} diff --git a/installer/src/install_flow/dependency_check_tests.rs b/installer/src/install_flow/dependency_check_tests.rs new file mode 100644 index 000000000..4817a17ab --- /dev/null +++ b/installer/src/install_flow/dependency_check_tests.rs @@ -0,0 +1,44 @@ +//! Verify failed dependency queries stop before the installation decision. + +use super::dependency_check::ensure_dylint_tools_with_query; +use rstest::rstest; +use std::cell::Cell; +use whitaker_installer::error::InstallerError; + +/// Verify failed availability queries stop before tool installation begins. +#[rstest] +#[case::path_lookup(InstallerError::DependencyCheck { + tool: "dylint-link", + reason: "PATH directory could not be opened".to_owned(), +})] +#[case::metadata_lookup(InstallerError::DependencyCheck { + tool: "dylint-link", + reason: "PATH candidate metadata could not be read".to_owned(), +})] +#[case::command_query(InstallerError::Io(std::io::Error::other( + "dependency command could not be started", +)))] +fn query_failures_do_not_start_tool_installation(#[case] query_error: InstallerError) { + let install_started = Cell::new(false); + let mut stderr = Vec::new(); + let expected_message = query_error.to_string(); + + let result = ensure_dylint_tools_with_query( + false, + &mut stderr, + || Err(query_error), + |_, _| { + install_started.set(true); + Ok(()) + }, + ); + + assert_eq!( + result + .expect_err("a failed availability query must stop the flow") + .to_string(), + expected_message, + ); + assert!(!install_started.get()); + assert!(stderr.is_empty()); +} diff --git a/installer/src/main.rs b/installer/src/main.rs index 76fa4fd36..2df4e0840 100644 --- a/installer/src/main.rs +++ b/installer/src/main.rs @@ -6,9 +6,8 @@ mod install_flow; mod staged_suite; +mod tracing_config; -#[cfg(test)] -use crate::install_flow::ensure_dylint_tools_with_options; use crate::install_flow::{ MetricsWriteContext, PrebuiltInstallationContext, ensure_dylint_tools_with_executor, try_prebuilt_installation, write_install_metrics, @@ -19,7 +18,9 @@ use std::io::Write; use std::time::Instant; use whitaker_installer::cli::{Cli, Command, InstallArgs}; use whitaker_installer::crate_name::CrateName; -use whitaker_installer::deps::{SourcePolicy, SystemCommandExecutor}; +use whitaker_installer::deps::{ + SourcePolicy, SystemCommandExecutor, install_dylint_tools_with_output, +}; use whitaker_installer::dirs::{BaseDirs, SystemBaseDirs}; use whitaker_installer::error::{InstallerError, Result}; use whitaker_installer::install_metrics::InstallMode; @@ -32,12 +33,16 @@ use whitaker_installer::resolution::{ use whitaker_installer::toolchain::Toolchain; use whitaker_installer::wrapper::{generate_wrapper_scripts, path_instructions}; +/// Parse options, configure tracing, and run the selected command. fn main() { let cli = Cli::parse(); let mut stdout = std::io::stdout(); let mut stderr = std::io::stderr(); - let run_result = run(&cli, &mut stdout, &mut stderr); - let exit_code = exit_code_for_run_result(run_result, &mut stderr); + if let Err(error) = tracing_config::configure_tracing(cli.install_args()) { + write_stderr_line(&mut stderr, error); + std::process::exit(1); + } + let exit_code = exit_code_for_run_result(run(&cli, &mut stdout, &mut stderr), &mut stderr); if exit_code != 0 { std::process::exit(exit_code); } @@ -194,7 +199,9 @@ use dry_run::run_dry; /// Checks for and installs Dylint tools if missing. fn ensure_dylint_tools(policy: SourcePolicy, stderr: &mut dyn Write) -> Result<()> { let executor = SystemCommandExecutor; - ensure_dylint_tools_with_executor(&executor, policy, stderr) + ensure_dylint_tools_with_executor(&executor, policy, stderr, |status, stderr| { + install_dylint_tools_with_output(&executor, status, policy, stderr) + }) } /// Ensures a Whitaker workspace is available. diff --git a/installer/src/output.rs b/installer/src/output.rs index fd62d8cb6..e0eac400e 100644 --- a/installer/src/output.rs +++ b/installer/src/output.rs @@ -187,6 +187,7 @@ mod tests { } /// Shared fixture providing a shell snippet for the test path. + #[whitaker_test_macros::allow_fixture_expansion_lints] #[fixture] fn test_snippet(test_path: Utf8PathBuf) -> ShellSnippet { ShellSnippet::new(&test_path) @@ -220,10 +221,11 @@ mod tests { assert!(display.contains("PowerShell")); } + /// Check that success messages use singular or plural labels as needed. #[rstest] #[case::singular(1, "1 lint library")] #[case::plural(5, "5 lint libraries")] - fn success_message_pluralises_correctly(#[case] count: usize, #[case] expected: &str) { + fn success_message_pluralizes_correctly(#[case] count: usize, #[case] expected: &str) { let path = Utf8PathBuf::from("/tmp"); let msg = success_message(count, &path); assert!(msg.contains(expected)); diff --git a/installer/src/test_utils/dependency_binary_helpers.rs b/installer/src/test_utils/dependency_binary_helpers.rs index 854a1cb22..dc16aab45 100644 --- a/installer/src/test_utils/dependency_binary_helpers.rs +++ b/installer/src/test_utils/dependency_binary_helpers.rs @@ -126,6 +126,20 @@ pub fn cargo_dylint_version_output() -> HelperResult { ))) } +/// Creates an expected Cargo probe reporting a stale `cargo-dylint` version. +pub fn cargo_dylint_stale_version_check() -> HelperResult { + let pinned_version = dependency_version("cargo-dylint")?; + let stale_version = if pinned_version.starts_with("0.") { + "1.0.0" + } else { + "0.0.0" + }; + + Ok(cargo_dylint_check_with_result(Ok(stdout_output(format!( + "cargo-dylint {stale_version}\n" + ))))) +} + /// Expected `cargo install --list` call reporting the manifest-pinned /// `dylint-link` version. pub fn dylint_link_install_list_check() -> HelperResult { @@ -153,14 +167,11 @@ pub fn repository_verification_call( verification_fails: bool, ) -> HelperResult> { Ok(match tool { - "cargo-dylint" => Some(ExpectedCall { - cmd: "cargo", - args: vec!["dylint", "--version"], - result: if verification_fails { - Ok(failure_output("still missing")) - } else { - Ok(cargo_dylint_version_output()?) - }, + "cargo-dylint" => Some(if verification_fails { + // A stale version is a verification miss; command failures are query errors. + cargo_dylint_stale_version_check()? + } else { + cargo_dylint_check()? }), // Repository installs of dylint-link are verified by probing the // extracted binary directly, so neither outcome consults the diff --git a/installer/src/tests/mod.rs b/installer/src/tests/mod.rs index 138fdcb78..c9cf3adde 100644 --- a/installer/src/tests/mod.rs +++ b/installer/src/tests/mod.rs @@ -8,12 +8,13 @@ use rstest::{fixture, rstest}; use whitaker_installer::{ cli::InstallArgs, dependency_binaries::DependencyBinaryInstaller, - deps::{DependencyInstallOptions, SourcePolicy}, + deps::{DependencyInstallOptions, SourcePolicy, install_dylint_tools_with_options}, dirs::BaseDirs, installer_packaging::TargetTriple, test_utils::{ dependency_binary_helpers::{ - AlwaysNotFoundRepositoryInstaller, cargo_dylint_check, dylint_link_install_list_check, + AlwaysNotFoundRepositoryInstaller, cargo_dylint_check, + cargo_dylint_stale_version_check, dylint_link_install_list_check, with_fake_binary_on_path, }, *, @@ -123,6 +124,11 @@ fn resolve_requested_crates_rejects_unknown_lints() { )); } +/// Skip installation when both required Dylint tools are already installed. +/// +/// # Errors +/// +/// Returns an I/O error if the temporary PATH fixture cannot be created. #[rstest] fn ensure_dylint_tools_skips_install_when_installed( test_base_dirs: TestBaseDirs, @@ -138,7 +144,12 @@ fn ensure_dylint_tools_skips_install_when_installed( let mut stderr = Vec::new(); let options = dependency_install_options(&test_base_dirs, &repository_installer, false) .expect("dependency install options should build"); - let result = ensure_dylint_tools_with_options(&executor, &mut stderr, options); + let result = ensure_dylint_tools_with_executor( + &executor, + options.policy, + &mut stderr, + |status, stderr| install_dylint_tools_with_options(&executor, status, stderr, options), + ); assert!(result.is_ok()); assert!(stderr.is_empty()); @@ -147,6 +158,11 @@ fn ensure_dylint_tools_skips_install_when_installed( Ok(()) } +/// Install missing Dylint tools and check the configured progress output. +/// +/// # Errors +/// +/// Returns an I/O error if the temporary PATH fixture cannot be created. #[rstest] #[case::logs_when_not_quiet( false, @@ -162,11 +178,7 @@ fn ensure_dylint_tools_installs_missing_tools( ) -> std::io::Result<()> { with_fake_binary_on_path("dylint-link", || { let executor = StubExecutor::new(vec![ - ExpectedCall { - cmd: "cargo", - args: vec!["dylint", "--version"], - result: Ok(failure_output("missing cargo-dylint")), - }, + cargo_dylint_stale_version_check().expect("stale cargo-dylint version should resolve"), dylint_link_install_list_check() .expect("dylint-link dependency version should resolve"), ExpectedCall { @@ -186,7 +198,12 @@ fn ensure_dylint_tools_installs_missing_tools( let mut stderr = Vec::new(); let options = dependency_install_options(&test_base_dirs, &repository_installer, quiet) .expect("dependency install options should build"); - let result = ensure_dylint_tools_with_options(&executor, &mut stderr, options); + let result = ensure_dylint_tools_with_executor( + &executor, + options.policy, + &mut stderr, + |status, stderr| install_dylint_tools_with_options(&executor, status, stderr, options), + ); assert!(result.is_ok()); let stderr_text = String::from_utf8(stderr).expect("stderr was not UTF-8"); @@ -210,17 +227,18 @@ fn ensure_dylint_tools_installs_missing_tools( Ok(()) } +/// Propagate a failure from the dependency installation command. +/// +/// # Errors +/// +/// Returns an I/O error if the temporary PATH fixture cannot be created. #[rstest] fn ensure_dylint_tools_propagates_install_failures( test_base_dirs: TestBaseDirs, ) -> std::io::Result<()> { with_fake_binary_on_path("dylint-link", || { let executor = StubExecutor::new(vec![ - ExpectedCall { - cmd: "cargo", - args: vec!["dylint", "--version"], - result: Ok(failure_output("missing cargo-dylint")), - }, + cargo_dylint_stale_version_check().expect("stale cargo-dylint version should resolve"), dylint_link_install_list_check() .expect("dylint-link dependency version should resolve"), ExpectedCall { @@ -239,8 +257,13 @@ fn ensure_dylint_tools_propagates_install_failures( let mut stderr = Vec::new(); let options = dependency_install_options(&test_base_dirs, &repository_installer, false) .expect("dependency install options should build"); - let err = ensure_dylint_tools_with_options(&executor, &mut stderr, options) - .expect_err("expected install failure"); + let err = ensure_dylint_tools_with_executor( + &executor, + options.policy, + &mut stderr, + |status, stderr| install_dylint_tools_with_options(&executor, status, stderr, options), + ) + .expect_err("expected install failure"); assert!(matches!( err, diff --git a/installer/src/tracing_config.rs b/installer/src/tracing_config.rs new file mode 100644 index 000000000..ab617c62b --- /dev/null +++ b/installer/src/tracing_config.rs @@ -0,0 +1,278 @@ +//! Configure and test the installer CLI's diagnostic subscriber. + +use std::io::Stderr; + +use tracing::Level; +use tracing_subscriber::{ + filter::LevelFilter, + fmt::{ + SubscriberBuilder, + format::{DefaultFields, Format}, + }, +}; +use whitaker_installer::cli::InstallArgs; + +/// Describe a CLI tracing subscriber registration failure. +#[derive(Debug, thiserror::Error)] +#[error("failed to initialize diagnostic tracing: {source}")] +pub(crate) struct TracingInitializationError { + /// The underlying global subscriber registration failure. + #[source] + source: Box, +} + +/// Enable bounded stderr diagnostics according to install verbosity flags. +/// +/// # Errors +/// +/// Returns an error if another global subscriber has already been installed. +pub(crate) fn configure_tracing( + args: &InstallArgs, +) -> std::result::Result<(), TracingInitializationError> { + tracing_formatter(args) + .try_init() + .map_err(|source| TracingInitializationError { source }) +} + +/// Build the CLI-only formatter without installing a global subscriber. +/// +/// Keep construction separate so tests can capture events using the production +/// filter and ANSI policy. Only CLI startup and this module's tests use it. +fn tracing_formatter( + args: &InstallArgs, +) -> SubscriberBuilder Stderr> { + let max_level = if args.quiet { + Level::ERROR + } else if args.verbosity > 0 { + Level::DEBUG + } else { + Level::WARN + }; + tracing_subscriber::fmt() + .with_ansi(false) + .with_max_level(max_level) + .with_writer(std::io::stderr as fn() -> Stderr) +} + +#[cfg(test)] +mod tests { + //! Exercise the production formatter without global subscriber mutation. + + use std::io::{Read, Seek}; + + #[cfg(unix)] + use clap::Parser; + use rstest::rstest; + #[cfg(unix)] + use whitaker_installer::cli::Cli; + + use super::*; + + #[cfg(unix)] + const PATH_SCAN_PROBE_ENV: &str = "WHITAKER_TRACING_PATH_SCAN_PROBE"; + + /// Verify CLI flags filter the real PATH-scan diagnostic in a child process. + #[cfg(unix)] + #[test] + fn cli_flags_filter_path_scan_diagnostics_on_stderr() { + if std::env::var_os(PATH_SCAN_PROBE_ENV).is_some() { + run_path_scan_probe(); + return; + } + + use camino::Utf8Path; + use cap_std::{ambient_authority, fs_utf8::Dir}; + + let fixture = tempfile::tempdir().expect("PATH fixture directory must be created"); + let invalid_entry = fixture.path().join("not-a-directory"); + let fixture_path = + Utf8Path::from_path(fixture.path()).expect("temporary directory path must be UTF-8"); + let fixture_dir = Dir::open_ambient_dir(fixture_path, ambient_authority()) + .expect("temporary directory must open as a capability"); + fixture_dir + .write("not-a-directory", b"file entry") + .expect("invalid PATH entry must be created"); + fixture_dir + .create_dir("bin") + .expect("binary directory must be created"); + let binary_directory = fixture.path().join("bin"); + let fake_linker = binary_directory.join("dylint-link"); + whitaker_installer::test_utils::dependency_binary_helpers::write_fake_binary( + &fake_linker, + true, + ) + .expect("fake linker must be executable"); + + let original_path = std::env::var_os("PATH").unwrap_or_default(); + let original_paths = std::env::split_paths(&original_path); + let path_entries = std::iter::once(invalid_entry.clone()) + .chain(std::iter::once(binary_directory.clone())) + .chain(original_paths); + let path = std::env::join_paths(path_entries).expect("fixture PATH must be valid"); + + for mode in ["default", "verbose", "quiet"] { + let output = std::process::Command::new( + std::env::current_exe().expect("test executable must be discoverable"), + ) + .args([ + "--exact", + "tracing_config::tests::cli_flags_filter_path_scan_diagnostics_on_stderr", + "--nocapture", + ]) + .env(PATH_SCAN_PROBE_ENV, mode) + .env("PATH", &path) + .output() + .expect("isolated CLI tracing probe must run"); + assert!( + output.status.success(), + "{mode} tracing probe must succeed: {output:?}" + ); + let stderr = String::from_utf8(output.stderr).expect("stderr must be UTF-8"); + let stdout = String::from_utf8(output.stdout).expect("stdout must be UTF-8"); + let has_path_diagnostic = stderr.contains("skipping PATH scan candidate"); + let is_verbose = mode == "verbose"; + assert_eq!(has_path_diagnostic, is_verbose); + assert_eq!(stderr.contains("binary_name=\"dylint-link\""), is_verbose); + assert_eq!( + stderr.contains("failure_category=\"directory_open\""), + is_verbose + ); + assert!(!stderr.contains(invalid_entry.to_string_lossy().as_ref())); + assert!(!stderr.contains(binary_directory.to_string_lossy().as_ref())); + assert!(!stdout.contains("skipping PATH scan candidate")); + assert!(!stderr.contains('\u{1b}'), "stderr must omit ANSI escapes"); + } + } + + /// Run the child-only probe that exercises the production PATH scan event. + #[cfg(unix)] + fn run_path_scan_probe() { + let mode = std::env::var(PATH_SCAN_PROBE_ENV); + let flags: &[&str] = match mode.as_deref() { + Ok("verbose") => &["-v"], + Ok("quiet") => &["-q"], + _ => &[], + }; + let arguments = std::iter::once("whitaker-installer").chain(flags.iter().copied()); + let cli = Cli::try_parse_from(arguments).expect("probe arguments must match the CLI"); + configure_tracing(cli.install_args()) + .expect("child probe tracing subscriber must initialize"); + let _ = crate::install_flow::check_dylint_tools_for_command( + &whitaker_installer::deps::SystemCommandExecutor, + ); + } + + /// Verify CLI initialization sends diagnostics to stderr in its own process. + #[test] + fn tracing_writes_to_stderr() { + const PROBE_ENV: &str = "WHITAKER_TRACING_STDERR_PROBE"; + if std::env::var_os(PROBE_ENV).is_some() { + configure_tracing(&InstallArgs::default()) + .expect("child probe tracing subscriber must initialize"); + tracing::warn!("installer stderr diagnostic"); + return; + } + let output = std::process::Command::new( + std::env::current_exe().expect("test executable must be discoverable"), + ) + .args([ + "--exact", + "tracing_config::tests::tracing_writes_to_stderr", + "--nocapture", + ]) + .env(PROBE_ENV, "1") + .output() + .expect("isolated tracing probe must run"); + assert!( + output.status.success(), + "tracing probe must succeed: {output:?}" + ); + let stderr = String::from_utf8(output.stderr).expect("stderr must be UTF-8"); + let stdout = String::from_utf8(output.stdout).expect("stdout must be UTF-8"); + assert!(stderr.contains("installer stderr diagnostic")); + assert!(!stdout.contains("installer stderr diagnostic")); + assert!(!stderr.contains('\u{1b}'), "stderr must omit ANSI escapes"); + } + + /// Verify a pre-installed global subscriber becomes a reported CLI error. + #[test] + fn tracing_initialization_failure_is_reported() { + const PROBE_ENV: &str = "WHITAKER_TRACING_INITIALIZATION_FAILURE_PROBE"; + if std::env::var_os(PROBE_ENV).is_some() { + tracing::subscriber::set_global_default(tracing_subscriber::registry()) + .expect("probe subscriber must be installed first"); + let error = configure_tracing(&InstallArgs::default()) + .expect_err("a second global subscriber must be rejected"); + assert!( + error + .to_string() + .contains("failed to initialize diagnostic tracing") + ); + return; + } + + let output = std::process::Command::new( + std::env::current_exe().expect("test executable must be discoverable"), + ) + .args([ + "--exact", + "tracing_config::tests::tracing_initialization_failure_is_reported", + "--nocapture", + ]) + .env(PROBE_ENV, "1") + .output() + .expect("isolated tracing failure probe must run"); + assert!( + output.status.success(), + "tracing failure probe must report the error: {output:?}" + ); + } + + /// Verify each verbosity policy through emitted, formatted events. + #[rstest] + #[case::quiet(true, 0, &["error diagnostic"])] + #[case::default(false, 0, &["error diagnostic", "warning diagnostic"])] + #[case::verbose(false, 1, &["error diagnostic", "warning diagnostic", "info diagnostic", "debug diagnostic"])] + #[case::repeated_verbose(false, 2, &["error diagnostic", "warning diagnostic", "info diagnostic", "debug diagnostic"])] + #[case::quiet_overrides_verbose(true, 2, &["error diagnostic"])] + fn tracing_respects_verbosity_without_ansi( + #[case] quiet: bool, + #[case] verbosity: u8, + #[case] expected_messages: &[&str], + ) { + let args = InstallArgs { + quiet, + verbosity, + ..InstallArgs::default() + }; + let mut capture = tempfile::tempfile().expect("diagnostic capture file must be created"); + let subscriber = tracing_formatter(&args) + .with_writer(capture.try_clone().expect("capture file must be cloned")) + .finish(); + tracing::subscriber::with_default(subscriber, || { + tracing::error!("error diagnostic"); + tracing::warn!("warning diagnostic"); + tracing::info!("info diagnostic"); + tracing::debug!("debug diagnostic"); + tracing::trace!("trace diagnostic"); + }); + capture.rewind().expect("capture file must rewind"); + let mut output = String::new(); + capture + .read_to_string(&mut output) + .expect("formatted diagnostics must be UTF-8"); + let messages = output + .lines() + .map(|line| { + line.split_once(": ") + .expect("event must include its formatted message") + .1 + }) + .collect::>(); + assert_eq!(messages, expected_messages); + assert!( + !output.contains('\u{1b}'), + "diagnostics must omit ANSI escapes" + ); + } +} diff --git a/installer/tests/tracing_cli.rs b/installer/tests/tracing_cli.rs new file mode 100644 index 000000000..b617c58f2 --- /dev/null +++ b/installer/tests/tracing_cli.rs @@ -0,0 +1,334 @@ +//! Verify the installer binary wires CLI verbosity into bounded PATH tracing. + +#![cfg(unix)] + +use camino::{Utf8Path, Utf8PathBuf}; +use cap_std::{ + ambient_authority, + fs_utf8::{Dir, Permissions, PermissionsExt}, +}; +use std::{ + error::Error, + ffi::OsString, + io, + process::{Command, Output}, +}; + +/// Keep an isolated workspace, PATH, and home directories alive for CLI runs. +struct TracingFixture { + _root: tempfile::TempDir, + root_path: Utf8PathBuf, + workspace: Utf8PathBuf, + environment: Vec<(OsString, OsString)>, +} + +/// Select one supported installer CLI verbosity mode for the fixture. +#[derive(Clone, Copy)] +enum CliMode { + Default, + Verbose, + Quiet, +} + +impl CliMode { + /// Return the arguments that select this mode. + fn arguments(self) -> &'static [&'static str] { + match self { + Self::Default => &[], + Self::Verbose => &["-v"], + Self::Quiet => &["-q"], + } + } + + /// Return the short label used in assertion messages. + fn label(self) -> &'static str { + match self { + Self::Default => "default", + Self::Verbose => "verbose", + Self::Quiet => "quiet", + } + } + + /// Report whether this mode enables debug diagnostics. + fn is_verbose(self) -> bool { + matches!(self, Self::Verbose) + } +} + +/// Identify one pinned installer dependency without passing package strings. +#[derive(Clone, Copy)] +enum DependencyPackage { + CargoDylint, + DylintLink, +} + +impl DependencyPackage { + /// Return this dependency's embedded manifest key. + fn manifest_name(self) -> &'static str { + match self { + Self::CargoDylint => "cargo-dylint", + Self::DylintLink => "dylint-link", + } + } +} + +/// Hold the pinned versions used to synthesize fake Cargo output. +struct DependencyVersions { + cargo_dylint: &'static str, + dylint_link: &'static str, +} + +/// Select a fake executable whose behaviour is controlled by this fixture. +enum FixtureExecutable { + Cargo(DependencyVersions), + DylintLink, + Rustup, +} + +impl TracingFixture { + /// Create temporary binaries and a minimal current-directory workspace. + /// + /// # Errors + /// + /// Returns an error if the fixture cannot be created or dependency + /// versions cannot be read from the embedded manifest. + fn new() -> Result> { + let root = tempfile::tempdir()?; + let root_path = Utf8PathBuf::from_path_buf(root.path().to_path_buf()).map_err(|path| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("temporary fixture path is not UTF-8: {}", path.display()), + ) + })?; + let root_dir = Dir::open_ambient_dir(&root_path, ambient_authority())?; + let workspace = root_path.join("workspace"); + let binary_directory = root_path.join("bin"); + create_workspace(&root_dir)?; + create_binaries(&root_dir)?; + let environment = create_environment(&root_dir, &root_path, &binary_directory)?; + + Ok(Self { + _root: root, + root_path, + workspace, + environment, + }) + } +} + +/// Create the minimal workspace passed to the installer child process. +/// +/// # Errors +/// +/// Returns a filesystem error if the workspace directory or manifest cannot +/// be created. +fn create_workspace(root_dir: &Dir) -> io::Result<()> { + root_dir.create_dir("workspace")?; + root_dir.open_dir("workspace")?.write( + "Cargo.toml", + "[package]\nname = \"whitaker\"\nversion = \"0.0.0\"\nedition = \"2024\"\n", + ) +} + +/// Create fake dependency binaries and the controlled PATH file entry. +/// +/// # Errors +/// +/// Returns an error if dependency versions cannot be read or fixture files +/// cannot be created. +fn create_binaries(root_dir: &Dir) -> Result<(), Box> { + root_dir.create_dir("bin")?; + root_dir.write("not-a-directory", b"PATH entry is a file")?; + let binary_dir = root_dir.open_dir("bin")?; + let versions = DependencyVersions { + cargo_dylint: dependency_version(DependencyPackage::CargoDylint)?, + dylint_link: dependency_version(DependencyPackage::DylintLink)?, + }; + write_executable(&binary_dir, FixtureExecutable::Cargo(versions))?; + write_executable(&binary_dir, FixtureExecutable::DylintLink)?; + write_executable(&binary_dir, FixtureExecutable::Rustup)?; + Ok(()) +} + +/// Assemble isolated paths and environment variables for child invocations. +/// +/// # Errors +/// +/// Returns an error if a home directory cannot be created or the child PATH +/// cannot be assembled. +fn create_environment( + root_dir: &Dir, + root_path: &Utf8Path, + binary_directory: &Utf8Path, +) -> Result, Box> { + for directory in ["data", "config", "cache", "rustup", "cargo-home"] { + root_dir.create_dir(directory)?; + } + + let data_home = root_path.join("data"); + let config_home = root_path.join("config"); + let cache_home = root_path.join("cache"); + let rustup_home = root_path.join("rustup"); + let cargo_home = root_path.join("cargo-home"); + let invalid_path_entry = root_path.join("not-a-directory"); + let path = std::env::join_paths([ + invalid_path_entry.as_std_path().as_os_str(), + binary_directory.as_std_path().as_os_str(), + ])?; + let mut environment = vec![ + (OsString::from("PATH"), path), + ( + OsString::from("HOME"), + root_path.as_std_path().as_os_str().to_owned(), + ), + ( + OsString::from("XDG_DATA_HOME"), + data_home.as_std_path().as_os_str().to_owned(), + ), + ( + OsString::from("XDG_CONFIG_HOME"), + config_home.as_std_path().as_os_str().to_owned(), + ), + ( + OsString::from("XDG_CACHE_HOME"), + cache_home.as_std_path().as_os_str().to_owned(), + ), + ( + OsString::from("RUSTUP_HOME"), + rustup_home.as_std_path().as_os_str().to_owned(), + ), + ( + OsString::from("CARGO_HOME"), + cargo_home.as_std_path().as_os_str().to_owned(), + ), + ]; + if let Some(ld_library_path) = std::env::var_os("LD_LIBRARY_PATH") { + environment.push((OsString::from("LD_LIBRARY_PATH"), ld_library_path)); + } + + Ok(environment) +} + +/// Exercise the real binary's verbosity parsing and tracing initialization. +/// +/// # Errors +/// +/// Returns an error if fixture construction or a child process fails. +#[test] +fn main_filters_path_scan_diagnostics_by_cli_verbosity() -> Result<(), Box> { + let fixture = TracingFixture::new()?; + for mode in [CliMode::Default, CliMode::Verbose, CliMode::Quiet] { + let output = run_cli_mode(&fixture, mode)?; + assert_cli_output(&output, &fixture, mode); + } + Ok(()) +} + +/// Run the installer with an isolated child environment and selected flags. +/// +/// # Errors +/// +/// Returns an I/O error if the installer process cannot be started or read. +fn run_cli_mode(fixture: &TracingFixture, mode: CliMode) -> io::Result { + Command::new(env!("CARGO_BIN_EXE_whitaker-installer")) + .args(["--toolchain", "nightly-test"]) + .args(mode.arguments()) + .current_dir(fixture.workspace.as_std_path()) + .env_clear() + .envs(fixture.environment.iter().cloned()) + .output() +} + +/// Check the controlled failure and verbosity-filtered diagnostic contract. +fn assert_cli_output(output: &Output, fixture: &TracingFixture, mode: CliMode) { + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + let diagnostic = "skipping PATH scan candidate after lookup failure"; + let mode_label = mode.label(); + let is_verbose = mode.is_verbose(); + + assert_eq!( + output.status.code(), + Some(1), + "{mode_label} mode must reach the fake rustup failure; stderr: {stderr}" + ); + assert!( + stderr.contains("toolchain nightly-test installation failed: controlled rustup failure"), + "{mode_label} mode must reach the controlled failure after dependency checks; stderr: {stderr}" + ); + assert_eq!( + stderr.contains(diagnostic), + is_verbose, + "{mode_label} mode stderr: {stderr}" + ); + assert_eq!( + stderr.contains("binary_name=\"dylint-link\""), + is_verbose, + "{mode_label} mode stderr: {stderr}" + ); + assert_eq!( + stderr.contains("failure_category=\"directory_open\""), + is_verbose, + "{mode_label} mode stderr: {stderr}" + ); + assert!(!stdout.contains(diagnostic), "diagnostic must be on stderr"); + assert!(!stdout.contains(fixture.root_path.as_str())); + assert!(!stderr.contains(fixture.root_path.as_str())); + assert!(!stdout.contains('\u{1b}')); + assert!(!stderr.contains('\u{1b}')); +} + +/// Build the fake Cargo responses from the versions required by the installer. +fn fake_cargo_script(versions: DependencyVersions) -> String { + format!( + concat!( + "#!/bin/sh\n", + "case \"$1 $2\" in\n", + " \"dylint --version\") echo 'cargo-dylint {}' ;;\n", + " \"install --list\") printf '%s\\n' 'cargo-dylint v{}:' ", + "'dylint-link v{}:' ;;\n", + " *) echo 'unexpected fake cargo invocation' >&2; exit 97 ;;\n", + "esac\n", + ), + versions.cargo_dylint, versions.cargo_dylint, versions.dylint_link, + ) +} + +/// Return a dependency binary's version from the embedded manifest. +/// +/// # Errors +/// +/// Returns an error if the manifest cannot be parsed or omits the package. +fn dependency_version(package: DependencyPackage) -> Result<&'static str, Box> { + let dependency = + whitaker_installer::dependency_binaries::find_dependency_binary(package.manifest_name())? + .ok_or_else(|| { + io::Error::new( + io::ErrorKind::NotFound, + format!( + "embedded dependency manifest must include {}", + package.manifest_name() + ), + ) + })?; + Ok(dependency.version()) +} + +/// Write a Unix script that can be found and run through the isolated PATH. +/// +/// # Errors +/// +/// Returns a filesystem error if the script or its executable permissions +/// cannot be written. +fn write_executable(directory: &Dir, executable: FixtureExecutable) -> io::Result<()> { + let (path, contents) = match executable { + FixtureExecutable::Cargo(versions) => ("cargo", fake_cargo_script(versions)), + FixtureExecutable::DylintLink => ("dylint-link", String::from("#!/bin/sh\nexit 0\n")), + FixtureExecutable::Rustup => ( + "rustup", + String::from("#!/bin/sh\necho 'controlled rustup failure' >&2\nexit 1\n"), + ), + }; + directory.write(path, &contents)?; + directory.set_permissions(path, Permissions::from_mode(0o755)) +} diff --git a/installer/tests/ui.rs b/installer/tests/ui.rs index 5bf326536..3b0d41317 100644 --- a/installer/tests/ui.rs +++ b/installer/tests/ui.rs @@ -1,4 +1,4 @@ -//! Compile-fail guards for the `sha2` 0.11 migration. +//! Compile-time guards for installer compatibility contracts. //! //! `sha2` 0.11 made two source-breaking changes that the installer had to work //! around: @@ -18,3 +18,12 @@ fn sha2_0_11_pre_migration_patterns_fail_to_compile() { cases.compile_fail("tests/ui/digest_lowerhex.rs"); cases.compile_fail("tests/ui/hasher_io_write.rs"); } + +/// Keep the public dependency-query result and error variant usable downstream. +#[test] +fn dependency_check_result_contract_is_public() { + let cases = trybuild::TestCases::new(); + cases.pass("tests/ui/pass_dylint_dependency_check_api.rs"); + cases.compile_fail("tests/ui/legacy_dylint_dependency_check_status.rs"); + cases.compile_fail("tests/ui/legacy_installer_error_match.rs"); +} diff --git a/installer/tests/ui/legacy_dylint_dependency_check_status.rs b/installer/tests/ui/legacy_dylint_dependency_check_status.rs new file mode 100644 index 000000000..00f1ebcda --- /dev/null +++ b/installer/tests/ui/legacy_dylint_dependency_check_status.rs @@ -0,0 +1,14 @@ +//! Compile-fail guard for the former bare-status return contract. + +use whitaker_installer::deps::{check_dylint_tools, CommandExecutor, DylintToolStatus}; + +/// Require the removed bare-status result type. +fn legacy_check(executor: &dyn CommandExecutor) { + let status: DylintToolStatus = check_dylint_tools(executor); + let _ = status; +} + +/// Keep the legacy API check reachable during compilation. +fn main() { + let _ = legacy_check; +} diff --git a/installer/tests/ui/legacy_dylint_dependency_check_status.stderr b/installer/tests/ui/legacy_dylint_dependency_check_status.stderr new file mode 100644 index 000000000..47c2beedb --- /dev/null +++ b/installer/tests/ui/legacy_dylint_dependency_check_status.stderr @@ -0,0 +1,14 @@ +error[E0308]: mismatched types + --> tests/ui/legacy_dylint_dependency_check_status.rs:7:36 + | +7 | let status: DylintToolStatus = check_dylint_tools(executor); + | ---------------- ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ expected `DylintToolStatus`, found `Result` + | | + | expected due to this + | + = note: expected struct `DylintToolStatus` + found enum `Result` +help: consider using `Result::expect` to unwrap the `Result` value, panicking if the value is a `Result::Err` + | +7 | let status: DylintToolStatus = check_dylint_tools(executor).expect("REASON"); + | +++++++++++++++++ diff --git a/installer/tests/ui/legacy_installer_error_match.rs b/installer/tests/ui/legacy_installer_error_match.rs new file mode 100644 index 000000000..185bf5a04 --- /dev/null +++ b/installer/tests/ui/legacy_installer_error_match.rs @@ -0,0 +1,37 @@ +//! Compile-fail guard for consumers that do not handle dependency-check errors. + +use whitaker_installer::error::InstallerError; + +/// Match every pre-dependency-check error variant exhaustively. +fn legacy_error_kind(error: &InstallerError) -> &'static str { + match error { + InstallerError::ConflictingSourceOptions { .. } => "source policy", + InstallerError::SourceFallbackForbidden { .. } => "source fallback", + InstallerError::ToolchainDetection { .. } => "toolchain detection", + InstallerError::ToolchainFileNotFound { .. } => "toolchain file", + InstallerError::InvalidToolchainFile { .. } => "toolchain file", + InstallerError::ToolchainNotInstalled { .. } => "toolchain install", + InstallerError::ToolchainInstallFailed { .. } => "toolchain install", + InstallerError::ToolchainComponentInstallFailed { .. } => "toolchain component", + InstallerError::BuildFailed { .. } => "build", + InstallerError::StagingFailed { .. } => "staging", + InstallerError::StubMismatch { .. } => "stub mismatch", + InstallerError::TargetNotWritable { .. } => "target directory", + InstallerError::LintCrateNotFound { .. } => "lint crate", + InstallerError::ExperimentalLintRequiresFlag { .. } => "experimental lint", + InstallerError::WorkspaceNotFound { .. } => "workspace", + InstallerError::SuitePinInWorkspace { .. } => "suite pin", + InstallerError::InvalidCargoToml { .. } => "manifest", + InstallerError::Io(_) => "I/O", + InstallerError::Git { .. } => "Git", + InstallerError::DependencyInstall { .. } => "dependency install", + InstallerError::WrapperGeneration(_) => "wrapper", + InstallerError::ScanFailed { .. } => "scan", + InstallerError::WriteFailed { .. } => "write", + } +} + +/// Keep the legacy exhaustive match reachable during compilation. +fn main() { + let _ = legacy_error_kind; +} diff --git a/installer/tests/ui/legacy_installer_error_match.stderr b/installer/tests/ui/legacy_installer_error_match.stderr new file mode 100644 index 000000000..7403efee9 --- /dev/null +++ b/installer/tests/ui/legacy_installer_error_match.stderr @@ -0,0 +1,20 @@ +error[E0004]: non-exhaustive patterns: `&InstallerError::DependencyCheck { .. }` not covered + --> tests/ui/legacy_installer_error_match.rs:7:11 + | + 7 | match error { + | ^^^^^ pattern `&InstallerError::DependencyCheck { .. }` not covered + | +note: `InstallerError` defined here + --> src/error.rs + | + | pub enum InstallerError { + | ^^^^^^^^^^^^^^^^^^^^^^^ +... + | DependencyCheck { + | --------------- not covered + = note: the matched value is of type `&InstallerError` +help: ensure that all possible cases are being handled by adding a match arm with a wildcard pattern or an explicit pattern as shown + | +30 ~ InstallerError::WriteFailed { .. } => "write", +31 ~ &InstallerError::DependencyCheck { .. } => todo!(), + | diff --git a/installer/tests/ui/pass_dylint_dependency_check_api.rs b/installer/tests/ui/pass_dylint_dependency_check_api.rs new file mode 100644 index 000000000..71bc81ce2 --- /dev/null +++ b/installer/tests/ui/pass_dylint_dependency_check_api.rs @@ -0,0 +1,47 @@ +//! Compile-pass guard for the public installer dependency-check API. + +use whitaker_installer::{ + deps::{check_dylint_tools, CommandExecutor, DylintToolStatus}, + error::{InstallerError, Result}, +}; + +/// Return the dependency-query result through its public API. +fn check(executor: &dyn CommandExecutor) -> Result { + check_dylint_tools(executor) +} + +/// Match every public installer error, including dependency checks. +fn error_kind(error: &InstallerError) -> &'static str { + match error { + InstallerError::ConflictingSourceOptions { .. } => "source policy", + InstallerError::SourceFallbackForbidden { .. } => "source fallback", + InstallerError::ToolchainDetection { .. } => "toolchain detection", + InstallerError::ToolchainFileNotFound { .. } => "toolchain file", + InstallerError::InvalidToolchainFile { .. } => "toolchain file", + InstallerError::ToolchainNotInstalled { .. } => "toolchain install", + InstallerError::ToolchainInstallFailed { .. } => "toolchain install", + InstallerError::ToolchainComponentInstallFailed { .. } => "toolchain component", + InstallerError::BuildFailed { .. } => "build", + InstallerError::StagingFailed { .. } => "staging", + InstallerError::StubMismatch { .. } => "stub mismatch", + InstallerError::TargetNotWritable { .. } => "target directory", + InstallerError::LintCrateNotFound { .. } => "lint crate", + InstallerError::ExperimentalLintRequiresFlag { .. } => "experimental lint", + InstallerError::WorkspaceNotFound { .. } => "workspace", + InstallerError::SuitePinInWorkspace { .. } => "suite pin", + InstallerError::InvalidCargoToml { .. } => "manifest", + InstallerError::Io(_) => "I/O", + InstallerError::Git { .. } => "Git", + InstallerError::DependencyInstall { .. } => "dependency install", + InstallerError::DependencyCheck { .. } => "dependency check", + InstallerError::WrapperGeneration(_) => "wrapper", + InstallerError::ScanFailed { .. } => "scan", + InstallerError::WriteFailed { .. } => "write", + } +} + +/// Exercise the public result and exhaustive error match without a host command. +fn main() { + let _ = check; + let _ = error_kind; +} diff --git a/src/config.rs b/src/config.rs index fe7955f76..390c75948 100644 --- a/src/config.rs +++ b/src/config.rs @@ -8,7 +8,7 @@ //! `dylint.toml` when present and fall back to sensible defaults otherwise. use serde::Deserialize; -use whitaker_common::i18n::normalise_locale; +use whitaker_common::i18n::normalize_locale; /// Shared configuration for the workspace-level crate. #[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq)] @@ -92,7 +92,7 @@ impl SharedConfig { /// behaviour when `dylint.toml` is templated or patched. #[must_use] pub fn locale(&self) -> Option<&str> { - normalise_locale(self.locale.as_deref()) + normalize_locale(self.locale.as_deref()) } } @@ -132,8 +132,9 @@ mod tests { assert!(config.locale().is_none()); } + /// Check that TOML configuration overrides are deserialized. #[rstest] - fn deserialises_overrides_from_toml() { + fn deserializes_overrides_from_toml() { let source = "[module_max_lines]\nmax_lines = 120\n"; // Panic with the TOML parser's message so broken overrides are easy to debug. @@ -143,8 +144,9 @@ mod tests { assert_eq!(config.module_max_lines.max_lines, 120); } + /// Check that a locale override is deserialized. #[rstest] - fn deserialises_locale_override() { + fn deserializes_locale_override() { let source = "locale = \"cy\"\n"; let config = toml::from_str::(source) @@ -163,8 +165,9 @@ mod tests { assert!(config.locale().is_none()); } + /// Check that invalid configuration values return a deserialization error. #[rstest] - fn propagates_deserialisation_failures() { + fn propagates_deserialization_failures() { let source = "[module_max_lines]\nmax_lines = \"a lot\"\n"; let outcome: Result = toml::from_str(source); diff --git a/src/lints/template/validation.rs b/src/lints/template/validation.rs index 04f6a4ccd..481fbd5fc 100644 --- a/src/lints/template/validation.rs +++ b/src/lints/template/validation.rs @@ -97,23 +97,30 @@ pub(crate) fn lint_constant(crate_name: &str) -> String { .collect() } -fn capitalise_segment(segment: &str) -> Option { +/// Capitalize one non-empty segment using ASCII case conversion. +/// +/// Returns `None` for an empty segment; non-ASCII characters are preserved. +fn capitalize_segment(segment: &str) -> Option { let mut characters = segment.chars(); let first = characters.next()?; - let mut capitalised = String::with_capacity(segment.len()); - capitalised.push(first.to_ascii_uppercase()); + let mut capitalized = String::with_capacity(segment.len()); + capitalized.push(first.to_ascii_uppercase()); for character in characters { - capitalised.push(character.to_ascii_lowercase()); + capitalized.push(character.to_ascii_lowercase()); } - Some(capitalised) + Some(capitalized) } +/// Derive the UI pass struct name by joining capitalized crate-name segments. +/// +/// Hyphens and underscores delimit segments, and empty segments are ignored. +/// ASCII letters are case-normalized while other characters are preserved. pub(crate) fn pass_struct_name(crate_name: &str) -> String { crate_name .split(['-', '_']) - .filter_map(capitalise_segment) + .filter_map(capitalize_segment) .collect() } diff --git a/tests/config_loading.rs b/tests/config_loading.rs index cebb68eed..aac92dcd0 100644 --- a/tests/config_loading.rs +++ b/tests/config_loading.rs @@ -12,7 +12,7 @@ use rstest::fixture; use rstest_bdd_macros::{given, scenario, then, when}; use support::locale::StepLocale; use whitaker::SharedConfig; -use whitaker_common::i18n::normalise_locale; +use whitaker_common::i18n::normalize_locale; /// Create empty configuration source state for one scenario. #[fixture] @@ -202,7 +202,7 @@ fn assert_locale( expected: StepLocale, ) { let raw = expected.into_inner(); - let Some(expected_value) = normalise_locale(Some(raw.as_str())) else { + let Some(expected_value) = normalize_locale(Some(raw.as_str())) else { panic!("expected the step to provide a locale value"); }; let config = match loaded_config(load_result) { diff --git a/tests/locale_resolution.rs b/tests/locale_resolution.rs index 6b07dd7e1..cf6131c84 100644 --- a/tests/locale_resolution.rs +++ b/tests/locale_resolution.rs @@ -8,7 +8,7 @@ mod support; use rstest::fixture; use rstest_bdd_macros::{given, scenario, then, when}; use support::locale::StepLocale; -use whitaker_common::i18n::{LocaleSelection, LocaleSource, normalise_locale, resolve_localizer}; +use whitaker_common::i18n::{LocaleSelection, LocaleSource, normalize_locale, resolve_localizer}; #[derive(Default)] struct LocaleWorld { @@ -18,6 +18,8 @@ struct LocaleWorld { resolution: RefCell>, } +/// Create the default locale-resolution test world. +#[whitaker_test_macros::allow_fixture_expansion_lints] #[fixture] fn world() -> LocaleWorld { LocaleWorld::default() @@ -101,11 +103,12 @@ fn assert_source(world: &LocaleWorld, source: StepSource) { assert_eq!(resolution.source(), source.into_inner()); } +/// Check that locale resolution matches the scenario value. #[then("the resolved locale is {value}")] fn assert_locale(world: &LocaleWorld, value: StepLocale) { let resolution = resolved(world); let raw = value.into_inner(); - let expected = normalise_locale(Some(raw.as_str())) + let expected = normalize_locale(Some(raw.as_str())) .unwrap_or_else(|| panic!("expected the step to provide a locale value")); assert_eq!(resolution.locale(), expected); diff --git a/tests/ui_harness.rs b/tests/ui_harness.rs index a0e688d67..27d210828 100644 --- a/tests/ui_harness.rs +++ b/tests/ui_harness.rs @@ -57,6 +57,8 @@ impl Default for HarnessWorld { } } +/// Create the default UI-harness test world. +#[whitaker_test_macros::allow_fixture_expansion_lints] #[fixture] fn harness_world() -> HarnessWorld { HarnessWorld::default() diff --git a/tests/workflow_contracts/compile_contract_allowance_test.py b/tests/workflow_contracts/compile_contract_allowance_test.py index d0342db7b..71b1a81a9 100644 --- a/tests/workflow_contracts/compile_contract_allowance_test.py +++ b/tests/workflow_contracts/compile_contract_allowance_test.py @@ -27,7 +27,7 @@ from ubicloud_workflow_support import REPOSITORY_ROOT #: Bound for compile-contract harnesses under shared compiler admission. -COMPILE_CONTRACT_TIMEOUT: typ.Final[str] = "20m" +COMPILE_CONTRACT_TIMEOUT: typ.Final[str] = "45m" class RustSourceError(OSError): diff --git a/tests/workflow_contracts/coverage_report_contract_test.py b/tests/workflow_contracts/coverage_report_contract_test.py index 9fff42235..645815d8d 100644 --- a/tests/workflow_contracts/coverage_report_contract_test.py +++ b/tests/workflow_contracts/coverage_report_contract_test.py @@ -136,3 +136,22 @@ def test_only_main_uploads_the_explicit_root_lcov_report( assert inputs.get("format") == "lcov" assert inputs.get("mode") == "upload" assert inputs.get("path") == "lcov.info" + + +def test_coverage_lanes_use_the_shared_online_profile_pool( + workflows: Mapping[str, Mapping[str, Any]], +) -> None: + """Keep both workflows on the Makefile's bounded profile pattern.""" + makefile = (REPOSITORY_ROOT / "Makefile").read_text(encoding="utf-8") + assert "COVERAGE_PROFILE_FILE_NAME ?= whitaker-%m.profraw" in makefile + assert "COVERAGE_PROFILE_FILE_NAME ?= whitaker-%p-%m.profraw" not in makefile + + for workflow_name, job_name in ( + ("ci.yml", "coverage-check"), + ("coverage-main.yml", "coverage-upload"), + ): + steps = _job_steps(workflows[workflow_name], job_name) + generate = steps[_named_step_index(steps, "Generate coverage")] + assert generate.get("run") == "make coverage", ( + f"{workflow_name}:{job_name} must use the shared profile policy" + ) diff --git a/tests/workflow_contracts/nextest_profile_test.py b/tests/workflow_contracts/nextest_profile_test.py index 50d249fb8..c064877a9 100644 --- a/tests/workflow_contracts/nextest_profile_test.py +++ b/tests/workflow_contracts/nextest_profile_test.py @@ -41,11 +41,21 @@ } #: The whole-run budget both profiles must declare. -REQUIRED_GLOBAL_TIMEOUT: typ.Final[str] = "60m" +REQUIRED_GLOBAL_TIMEOUT: typ.Final[str] = "85m" -# Parser-pin fixtures share one integration binary, target directory and Cargo -# package cache, so the allowance must cover that whole binary and serialize it. +# Parser-pin fixtures share one integration binary and Cargo package cache, so +# the allowance must cover that whole binary and serialize it. BUILD_SCRIPT_INTEGRATION_FILTER: typ.Final[str] = "binary(build_script_integration)" +NO_DEFAULT_FEATURES_FILTER: typ.Final[str] = "binary(no_default_features_build)" +COMPILE_CONTRACT_FILTER: typ.Final[str] = ( + "test(dependency_check_result_contract_is_public) | " + "test(fixture_expansion_lint_attribute_contract) | " + "test(normalize_settings_is_public_and_the_legacy_name_stays_removed) | " + "test(renamed_common_apis_are_public_and_legacy_names_stay_removed) | " + "test(renamed_sarif_api_is_public_and_legacy_names_stay_removed) | " + "test(sha2_0_11_pre_migration_patterns_fail_to_compile) | " + "test(trybuild_fixtures_compile_without_diagnostics)" +) class Allowance(typ.NamedTuple): @@ -57,20 +67,18 @@ class Allowance(typ.NamedTuple): The whole ``slow-timeout`` table the override must declare. test_group : str or None The group it must name, or None when it names none. The budget - and the group are one claim: a test allowed thirty minutes but - no longer serialized runs concurrently with its siblings on the - shared target directory, which is what the group exists to stop. - retries : dict[str, object] or None - The whole ``retries`` table, or None when the override declares - none. Part of the same claim: the Dylint UI harnesses retry a - transient Windows rename failure, and `tests/nextest_ui_filter.rs` - requires the key, so a change to its count, backoff or delay - would otherwise pass every assertion here. + and group are one claim: nested Cargo builds with long allowances + share one serial group so cold fixtures do not compete for admission. + retries : integer, dict[str, object], or None + The whole ``retries`` setting, or None when the override declares + none. The Dylint UI harnesses retry transient Windows rename failures; + Compile contracts disable retries because each retry repeats a + costly nested Cargo build. """ slow_timeout: dict[str, object] test_group: str | None - retries: dict[str, object] | None = None + retries: int | dict[str, object] | None = None #: The overrides both profiles carry, keyed by the binary or filter they @@ -83,23 +91,38 @@ class Allowance(typ.NamedTuple): #: cover it. #: #: The filters are stated whole and matched exactly, per profile. A -#: substring would accept a narrowed one: dropping a clause from the -#: `ui` disjunction leaves the needle intact while the tests that clause -#: named lose the ten-minute allowance and fall back to the base three -#: hundred seconds. The two profiles genuinely differ here, the default one -#: naming seven more `test(...)` clauses in its shared UI override than -#: `ci`: twelve against five, with `(binary(ui) & test(=ui))` shared and -#: counted in neither. The macro crate's compile contract and parser-pin -#: nested Cargo checks have their own identical allowances in both profiles. +#: substring would accept a narrowed one: dropping a clause from a `ui` +#: disjunction leaves each selected needle intact while the affected tests +#: lose the 20-minute allowance and fall back to the base 300 seconds. The +#: default profile includes platform-specific example tests that `ci` omits, +#: but both profiles give expensive API contracts and nested Cargo fixtures +#: their own explicit allowances. REQUIRED_OVERRIDES: typ.Final[dict[str, dict[str, Allowance]]] = { "default": { BUILD_SCRIPT_INTEGRATION_FILTER: Allowance( slow_timeout={ - "period": "30m", + "period": "45m", + "terminate-after": 1, + "grace-period": "5s", + }, + test_group="serial-nested-cargo-builds", + ), + NO_DEFAULT_FEATURES_FILTER: Allowance( + slow_timeout={ + "period": "20m", + "terminate-after": 1, + "grace-period": "5s", + }, + test_group="serial-nested-cargo-builds", + ), + COMPILE_CONTRACT_FILTER: Allowance( + slow_timeout={ + "period": "45m", "terminate-after": 1, "grace-period": "5s", }, - test_group="serial-build-script-integration", + test_group="serial-dylint-ui", + retries=0, ), ("binary(behaviour_toolchain)"): Allowance( slow_timeout={ @@ -115,10 +138,8 @@ class Allowance(typ.NamedTuple): "test(driver::ui::) | " "test(tests::ui::) | " "test(ui::ui) | " - "test(sha2_0_11_pre_migration_patterns_fail_to_compile) | " "test(example_compiles_without_diagnostics) | " "test(example_harness_collects_call_site_evidence) | " - "test(trybuild_fixtures_compile_without_diagnostics) | " "test(ui::example_compiles_under_test_harness) | " "test(ui::hand_written_test_companion_does_not_exempt_parent_" "function) | " @@ -138,23 +159,32 @@ class Allowance(typ.NamedTuple): test_group="serial-dylint-ui", retries={"backoff": "exponential", "count": 2, "delay": "5s"}, ), - "test(fixture_expansion_lint_attribute_contract)": Allowance( + }, + "ci": { + BUILD_SCRIPT_INTEGRATION_FILTER: Allowance( + slow_timeout={ + "period": "45m", + "terminate-after": 1, + "grace-period": "5s", + }, + test_group="serial-nested-cargo-builds", + ), + NO_DEFAULT_FEATURES_FILTER: Allowance( slow_timeout={ "period": "20m", "terminate-after": 1, "grace-period": "5s", }, - test_group="serial-dylint-ui", + test_group="serial-nested-cargo-builds", ), - }, - "ci": { - BUILD_SCRIPT_INTEGRATION_FILTER: Allowance( + COMPILE_CONTRACT_FILTER: Allowance( slow_timeout={ - "period": "30m", + "period": "45m", "terminate-after": 1, "grace-period": "5s", }, - test_group="serial-build-script-integration", + test_group="serial-dylint-ui", + retries=0, ), ("binary(behaviour_toolchain)"): Allowance( slow_timeout={ @@ -168,8 +198,6 @@ class Allowance(typ.NamedTuple): "test(driver::ui::) | " "test(tests::ui::) | " "test(ui::ui) | " - "test(sha2_0_11_pre_migration_patterns_fail_to_compile) | " - "test(trybuild_fixtures_compile_without_diagnostics) | " "(binary(ui) & test(=ui))" ): Allowance( slow_timeout={ @@ -179,14 +207,6 @@ class Allowance(typ.NamedTuple): }, test_group="serial-dylint-ui", ), - "test(fixture_expansion_lint_attribute_contract)": Allowance( - slow_timeout={ - "period": "20m", - "terminate-after": 1, - "grace-period": "5s", - }, - test_group="serial-dylint-ui", - ), }, } @@ -194,12 +214,12 @@ class Allowance(typ.NamedTuple): #: A group a profile names and ``[test-groups]`` does not declare is a #: configuration nextest refuses, and one declared without #: ``max-threads = 1`` serializes nothing: the UI harnesses would then -#: build lint libraries concurrently against the shared target -#: directory, which is the race the groups exist to stop. +#: build lint libraries concurrently against the shared target directory, +#: while nested Cargo fixtures would compete for compiler admission. REQUIRED_TEST_GROUPS: typ.Final[dict[str, dict[str, object]]] = { "serial-dylint-ui": {"max-threads": 1}, "serial-toolchain-installs": {"max-threads": 1}, - "serial-build-script-integration": {"max-threads": 1}, + "serial-nested-cargo-builds": {"max-threads": 1}, } @@ -282,16 +302,16 @@ def test_each_profile_states_the_overrides_its_own_tests_need( than silent. Each policy key is compared as a whole table, so the grace period is - asserted alongside the period and the multiplier, and the retry - backoff and delay alongside the count. Checking - the period alone would let the grace period be dropped, and the + asserted alongside the period and the multiplier, and the retry policy + alongside its count, backoff and delay. Compile contracts explicitly + disable retries because each attempt repeats a costly nested build. + Checking only the period would let the grace period be dropped, and the watchdog above these budgets is sized to cover exactly that wait. The filter is compared whole and exactly, per profile. A substring - would accept a narrowed filter: dropping a clause from the `ui` - disjunction leaves any needle intact while the tests that clause - named lose the ten-minute allowance and fall back to the base three - hundred seconds. + would accept a narrowed filter: dropping a clause from a `ui` + disjunction leaves each selected needle intact while the affected tests + lose the 20-minute allowance and fall back to the base 300 seconds. Only overrides that declare a budget are counted. The default profile matches ``binary(behaviour_toolchain)`` twice, once for the @@ -321,9 +341,10 @@ def test_each_profile_states_the_overrides_its_own_tests_need( ) assert matching[0].get("retries") == allowance.retries, ( f"[profile.{profile}]'s {needle!r} override must declare retries " - f"{allowance.retries}, got {matching[0].get('retries')}; the Dylint UI " - f"harnesses retry a transient Windows rename failure, and " - f"`tests/nextest_ui_filter.rs` requires the key" + f"{allowance.retries}, got {matching[0].get('retries')}; Dylint UI " + f"harnesses retain retries for transient Windows rename failures, " + f"while the compile-contract override disables retries; " + f"`tests/nextest_ui_filter.rs` also requires the UI retry policy" ) diff --git a/tests/workflow_contracts/timeout_ordering_test.py b/tests/workflow_contracts/timeout_ordering_test.py index af471e044..142e5bdb0 100644 --- a/tests/workflow_contracts/timeout_ordering_test.py +++ b/tests/workflow_contracts/timeout_ordering_test.py @@ -17,8 +17,8 @@ The third tier of the canonical four, the shared coverage action's cargo watchdog, does not exist here and its absence is asserted rather than assumed: `coverage-main.yml` says why the action is not used, and a lane -that adopted it would inherit an undocumented 1,800 s default beneath a -60 m nextest budget. See "Test timeouts: four tiers, outermost last" in +that adopted it would inherit an undocumented 1,800 s default beneath an +85 m nextest budget. See "Test timeouts: four tiers, outermost last" in ``docs/developers-guide.md``, and the canonical wording in `leynos/shared-actions`' `generate-coverage` README. @@ -277,7 +277,7 @@ def test_the_cargo_watchdog_tier_is_absent_rather_than_defaulted() -> None: `cargo llvm-cov` itself and does not use that action, so the tier is absent by choice. A lane that adopted the action without setting `RUN_RUST_CARGO_WAIT_TIMEOUT` would inherit its undocumented 1,800 s - default underneath a 60 m nextest budget, which is the inversion the + default underneath an 85 m nextest budget, which is the inversion the canonical section exists to prevent, so both halves are asserted: the action is not used, and the variable is not set. """ @@ -298,7 +298,7 @@ def test_the_cargo_watchdog_tier_is_absent_rather_than_defaulted() -> None: #: `docs/developers-guide.md` records it. Pinned as well as derived: the #: derivation accepts any ceiling above its requirement, so a value #: nobody chose passes it while drifting away from the guide. -REQUIRED_CEILING_MINUTES: typ.Final[int] = 100 +REQUIRED_CEILING_MINUTES: typ.Final[int] = 120 #: The condition each suite lane legitimately carries, keyed by workflow #: and job, as the step's ``if`` and its job's. @@ -326,8 +326,8 @@ def test_every_suite_lane_carries_the_documented_ceiling( The derivation asserts the ordering holds, and it holds for a range of ceilings, so a lane drifting to a value nobody chose still passes - it. This asserts the 100 minutes the developers' guide pins: a policy - value above the derived requirement of 91 minutes 5 seconds, which + it. This asserts the 120 minutes the developers' guide pins: a policy + value above the derived requirement of 116 minutes 5 seconds, which already carries the fifteen-minute margin. """ wrong = { diff --git a/tests/workflows/test_coverage_target_makefile.py b/tests/workflows/test_coverage_target_makefile.py index a973f3915..f62427743 100644 --- a/tests/workflows/test_coverage_target_makefile.py +++ b/tests/workflows/test_coverage_target_makefile.py @@ -16,8 +16,10 @@ def _write_executable(path: Path, body: str) -> None: path.chmod(0o755) -def test_coverage_shares_its_target_with_nested_cargo(tmp_path: Path) -> None: - """Require coverage and nested Cargo to inherit one absolute target.""" +def test_coverage_shares_target_and_profile_policy_with_nested_cargo( + tmp_path: Path, +) -> None: + """Require coverage and nested Cargo to inherit the shared policy.""" workspace = tmp_path / "workspace" bin_directory = tmp_path / "bin" workspace.mkdir() @@ -29,12 +31,14 @@ def test_coverage_shares_its_target_with_nested_cargo(tmp_path: Path) -> None: cargo, 'case "$1" in\n' ' llvm-cov)\n' - ' printf "outer|%s|%s\\n" "$CARGO_LLVM_COV_TARGET_DIR" "$CARGO_TARGET_DIR" ' + ' printf "outer|%s|%s|%s\\n" "$CARGO_LLVM_COV_TARGET_DIR" "$CARGO_TARGET_DIR" ' + '"$LLVM_PROFILE_FILE_NAME" ' '>> "$COVERAGE_ENV_LOG"\n' ' "$0" nested-cargo\n' ' ;;\n' ' nested-cargo)\n' - ' printf "nested|%s|%s\\n" "$CARGO_LLVM_COV_TARGET_DIR" "$CARGO_TARGET_DIR" ' + ' printf "nested|%s|%s|%s\\n" "$CARGO_LLVM_COV_TARGET_DIR" "$CARGO_TARGET_DIR" ' + '"$LLVM_PROFILE_FILE_NAME" ' '>> "$COVERAGE_ENV_LOG"\n' ' ;;\n' 'esac', @@ -64,6 +68,6 @@ def test_coverage_shares_its_target_with_nested_cargo(tmp_path: Path) -> None: assert result.returncode == 0, result.stderr expected_target = workspace / "target" / "llvm-cov-target" assert environment_log.read_text(encoding="utf-8").splitlines() == [ - f"outer|{expected_target}|{expected_target}", - f"nested|{expected_target}|{expected_target}", - ], "coverage and nested Cargo must use the same absolute target directory" + f"outer|{expected_target}|{expected_target}|whitaker-%m.profraw", + f"nested|{expected_target}|{expected_target}|whitaker-%m.profraw", + ], "coverage and nested Cargo must share the target and profile policy" diff --git a/typos.local.toml b/typos.local.toml index daac599d9..5d83ef1df 100644 --- a/typos.local.toml +++ b/typos.local.toml @@ -13,91 +13,30 @@ accepted = [] [patterns] ignore = [ "(?m)^ \\.env_remove\\(env::CARGO_TERM_COLOR\\)$", - "(?m)^ \\.context\\(\"failed to serialise diagnostic for snapshot\"\\),$", - "(?m)^ Ok\\(parsed\\) => assert_eq!\\(artifact, parsed\\),$", - "(?m)^ \\.artifact_location$", - "(?m)^ artifact_location: ArtifactLocation \\{$", - "(?m)^ artifact_location: whitaker_sarif::ArtifactLocation \\{$", - "(?m)^ assert!\\(!json\\.contains\\(\"\\\\\"artifacts\\\\\"\"\\)\\);$", + # SARIF 2.1.0 (OASIS) spells these properties `artifactLocation` and + # `artifacts`. The Rust fields use the repository's Oxford spelling and pin + # the wire names with `serde(rename)`, so these two literals are serialized + # values fixed by the external schema, not prose. + "(?m)^ #\\[serde\\(rename = \"artifactLocation\"\\)\\]$", + "(?m)^ #\\[serde\\(default, rename = \"artifacts\", skip_serializing_if = \"Vec::is_empty\"\\)\\]$", "(?m)^ !json\\.contains\\(\"\\\\\"artifacts\\\\\"\"\\),$", - "(?m)^ format!\\(\"bumpy\\-road signal rasterisation failed: \\{error\\}\"\\),$", - "(?m)^ Ok\\(json\\) => match serde_json::from_str::\\(&json\\) \\{$", - "(?m)^ \\.artifact_location$", - "(?m)^ \\.expect\\(\"Fluent source should be initialised\"\\);$", - "(?m)^ \\.unwrap_or_else\\(\\|\\| panic!\\(\"localizer should be initialised\"\\)\\)$", - "(?m)^ artifacts: Vec::new\\(\\),$", - "(?m)^ artifacts: self\\.artifacts,$", - "(?m)^ localised_messages\\(&FailingLookup::new\\(\"no_std_fs_operations\"\\), &op\\)$", - "(?m)^ localised_messages\\(localizer, &op\\)$", - "(?m)^ location: ArtifactLocation \\{$", - "(?m)^ location\\.physical_location\\.artifact_location\\.uri, file_uri,$", + "(?m)^ json\\.contains\\(\"\\\\\"artifacts\\\\\"\"\\),$", + "(?m)^ json\\.contains\\(\"\\\\\"artifactLocation\\\\\"\"\\),$", + # Keep schema keys exempt only in JSON fields, lookups and index expressions. + # Keep the removed field spelling only in its compile-fail regression fixture. + '"(?:artifactLocation|artifacts)"[ \t]*:', + '\["(?:artifactLocation|artifacts)"\]', + '\w+\.get\("(?:artifactLocation|artifacts)"\)', + "(?m)^.*run\\.artifacts;.*$", "(?m)^ \"\\-\\-artifact\\-server\\-path\",$", - "(?m)^ \"failed to initialise isolated rustup: \\{\\}\",$", - "(?m)^ TraitItemMetrics::default_method\\(\"serialise\", 8\\),$", - "(?m)^ \\.expect\\(\"failed to initialise isolated rustup environment\"\\);$", - "(?m)^ \\.expect\\(\"settings should be normalised\"\\);$", - "(?m)^ \\.filter_map\\(capitalise_segment\\)$", - "(?m)^ \\.normalised$", - "(?m)^ all_artifacts\\.extend\\(run\\.artifacts\\.clone\\(\\)\\);$", - "(?m)^ artifacts: all_artifacts,$", - "(?m)^ assert_eq!\\(loc\\.physical_location\\.artifact_location\\.uri, \"src/main\\.rs\"\\);$", - "(?m)^ assert_eq!\\(location\\.physical_location\\.artifact_location\\.uri, file_uri\\);$", - "(?m)^ assert_eq!\\(normalise_locale\\(input\\), expected\\);$", - "(?m)^ capitalised\\.push\\(character\\.to_ascii_lowercase\\(\\)\\);$", - "(?m)^ depth: normalise_weight\\(settings\\.weights\\.depth, defaults\\.weights\\.depth\\),$", - "(?m)^ flow: normalise_weight\\(settings\\.weights\\.flow, defaults\\.weights\\.flow\\),$", "(?m)^ let Message::CompilerArtifact\\(artefact\\) = message else \\{$", "(?m)^ let Ok\\(Message::CompilerArtifact\\(artefact\\)\\) = message else \\{$", - "(?m)^ let artifact = Artifact \\{$", - "(?m)^ let artefact = Artifact \\{$", - "(?m)^ let summary = summarise_context\\($", - "(?m)^ let summary = summarise_context\\(entries\\.as_slice\\(\\), has_test_context_ancestry, additional\\);$", - "(?m)^ let summary = summarise_context_with_harness\\($", - "(?m)^ localised_messages\\(&lookup, kind, attribute\\)$", - "(?m)^ match serde_json::to_string\\(&artifact\\) \\{$", - "(?m)^ normalise_locale\\(self\\.locale\\.as_deref\\(\\)\\)$", - "(?m)^ predicate: normalise_weight\\(settings\\.weights\\.predicate, defaults\\.weights\\.predicate\\),$", - "(?m)^ result\\.locations\\[0\\]\\.physical_location\\.artifact_location\\.uri,$", - "(?m)^ self\\.artifacts\\.push\\(artifact\\);$", - "(?m)^ self\\.settings = normalise_settings\\(load_configuration\\(\\)\\.into_settings\\(\\)\\);$", - "(?m)^ uses: actions/download\\-artifact@v4$", "(?m)^ uses: actions/upload\\-artifact@v4$", "(?m)^ uses: actions/upload\\-artifact@v7$", - "(?m)^ world\\.with_localizer\\(\\|localizer\\| localised_messages\\(localizer, kind, attribute\\)\\)$", - "(?m)^ artifact_dir = tmp_path / \"act\\-artifacts\"$", "(?m)^ \\- Download all artefacts \\(actions/download\\-artifact@v4,$", "(?m)^ \\- Upload artefact \\(actions/upload\\-artifact@v4,$", - "(?m)^ /// Appends an artifact reference\\.$", "(?m)^ /// Categorises the input with a single cluster of conditional logic\\.$", - "(?m)^ /// Identifies the artifact \\(file\\)\\.$", - "(?m)^ /// Location of the artifact\\.$", - "(?m)^ /// Optional byte offset from the start of the artifact\\.$", - "(?m)^ /// Optional region within the artifact\\.$", - "(?m)^ /// Referenced source artifacts\\.$", - "(?m)^ /// Relative or absolute URI of the artifact\\.$", - "(?m)^ /// Returns the source artifact URI used in SARIF output\\.$", - "(?m)^ /// Sets the byte offset from the start of the artifact\\.$", - "(?m)^ /// Sets the region within the artifact\\.$", - "(?m)^ /// assert_eq!\\(run\\.artifacts\\.len\\(\\), 1\\);$", "(?m)^ /// executed \\(catches mis\\-specified scenarios missing the When step\\)\\.$", - "(?m)^ And I normalise the settings$", - "(?m)^ And a default method normalise with CC 7$", - "(?m)^ Artifact, ArtifactLocation, Invocation, Level, Location, Message, MultiformatMessageString,$", - "(?m)^ ContextLabel, Localizer, NoExpectMessages, ReceiverCategory, ReceiverLabel, localised_messages,$", - "(?m)^ DEFAULT_THRESHOLD, Settings, Weights, detect_bumps, normalise_settings, top_two_bumps,$", - "(?m)^ Given the lint recognises custom::test as a test attribute$", - "(?m)^ Some\\(capitalised\\)$", - "(?m)^ Then the function is recognised as an rstest fixture$", - "(?m)^ Then the function is recognised as an rstest test$", - "(?m)^ Then the function is recognised as not being an rstest test$", - "(?m)^ Then the function is recognised as not test\\-like$", - "(?m)^ Then the function is recognised as test\\-like$", - "(?m)^ When I localise the diagnostic$", - "(?m)^ When I localise the expect diagnostic$", - "(?m)^ When I localise the std::fs diagnostic$", - "(?m)^ When I summarise the context$", - "(?m)^ When the complexity is finalised$", - "(?m)^ \\.contains\\(\"unrecognised boolean value 'maybe'\"\\)\\);$", "(?m)^ \\.help = Cuir beachd doc ris a tha a’ mìneachadh giùlan \\{ \\$function \\}\\.$", "(?m)^ \\.help = Cuir seachad `cap_std::fs::Dir` agus paramadairean `camino::Utf8Path`/`Utf8PathBuf` tron API seach std::fs a ghairm gu dìreach\\.$", "(?m)^ \\.label = Tha bump iom\\-fhillteachd \\{ \\$index \\} a’ leudachadh thairis air \\{ \\$lines \\} \\{ \\$lines \\->$", @@ -109,111 +48,23 @@ ignore = [ "(?m)^ \\.note = Tha mòidealan mòra nas duilghe an ath\\-sgrùdadh\\.$", "(?m)^ \\.note = Tha na docs airson \\{ \\$test \\} a’ gabhail a\\-steach ceann eisimpleirean no bloca còd le feansa\\.$", "(?m)^ \\.note = Tha teachdaireachdan airson \\{ \\$lint \\} ri fhaighinn ann an Gàidhlig\\.$", - "(?m)^ \\.note = The call originates within \\{ \\$context \\} which is not recognised as a test\\.$", "(?m)^ artefact: &cargo_metadata::Artifact,$", - "(?m)^ artifact_dir = tmp_path / \"act\\-artifacts\"$", - "(?m)^ artifacts: Vec,$", - "(?m)^ assert_eq!\\(normalised, expected\\);$", - "(?m)^ builder\\.add_default_method\\(\"normalise\", 7, false\\);$", - "(?m)^ capitalised\\.push\\(first\\.to_ascii_uppercase\\(\\)\\);$", - "(?m)^ context_label, fallback_messages, localised_messages,$", - "(?m)^ fn artifact_round_trip\\(\\) \\{$", - "(?m)^ fn deserialises_locale_override\\(\\) \\{$", - "(?m)^ fn deserialises_overrides_from_toml\\(\\) \\{$", - "(?m)^ fn normalise_weight\\(candidate: f64, fallback: f64\\) \\-> f64 \\{$", - "(?m)^ fn normalises_candidates\\(#\\[case\\] input: Option<&str>, #\\[case\\] expected: Option<&str>\\) \\{$", - "(?m)^ fn propagates_deserialisation_failures\\(\\) \\{$", - "(?m)^ fn success_message_pluralises_correctly\\(#\\[case\\] count: usize, #\\[case\\] expected: &str\\) \\{$", "(?m)^ let _ = fixture::categorise\\(0\\);$", "(?m)^ let _ = fixture::categorise\\(4\\);$", "(?m)^ let _ = fixture::categorise\\(99\\);$", - "(?m)^ let candidate = normalise_locale\\(raw\\)\\?;$", - "(?m)^ let expected = normalise_locale\\(Some\\(raw\\.as_str\\(\\)\\)\\)$", - "(?m)^ let expected_value = normalise_locale\\(Some\\(raw\\.as_str\\(\\)\\)\\)$", - "(?m)^ let Some\\(expected_value\\) = normalise_locale\\(Some\\(raw\\.as_str\\(\\)\\)\\) else \\{$", - "(?m)^ let file = location\\.physical_location\\.artifact_location\\.uri\\.clone\\(\\);$", - "(?m)^ let help = normalise_isolation_marks\\(messages\\.help\\(\\)\\);$", - "(?m)^ let messages = localised_messages\\(&lookup, &receiver_label, &context_label, category\\)$", - "(?m)^ let mut all_artifacts = Vec::new\\(\\);$", - "(?m)^ let mut capitalised = String::with_capacity\\(segment\\.len\\(\\)\\);$", - "(?m)^ let mut summary = crate::context::summarise_context\\(cx, hir_id\\);$", - "(?m)^ let normalised = normalise_settings\\(settings\\);$", - "(?m)^ let normalized = normalise_settings\\(settings\\);$", - "(?m)^ let note = format!\\(\"The call originates within \\{context\\} which is not recognised as a test\\.\",\\);$", - "(?m)^ let note = normalise_isolation_marks\\(messages\\.note\\(\\)\\);$", - "(?m)^ let primary = normalise_isolation_marks\\(messages\\.primary\\(\\)\\);$", - "(?m)^ let summary = summarise_context\\(&entries, false, &\\[\\]\\);$", - "(?m)^ let summary = summarise_context\\(&entries, false, additional\\.as_slice\\(\\)\\);$", - "(?m)^ let summary = summarise_context\\(&entries, true, &\\[\\]\\);$", - "(?m)^ localised_messages,$", - "(?m)^ localised_messages\\(lookup, receiver, &context, category\\)$", - "(?m)^ normalise_locale, resolve_localizer, safe_resolve_message_set, supports_locale,$", - "(?m)^ normalised: RefCell>,$", - "(?m)^ pub artifact_location: ArtifactLocation,$", - "(?m)^ pub artifacts: Vec,$", "(?m)^ pub fn categorise\\(input: i32\\) \\-> &'static str \\{$", - "(?m)^ pub fn with_artifact\\(mut self, artifact: Artifact\\) \\-> Self \\{$", - "(?m)^ pub location: super::location::ArtifactLocation,$", "(?m)^ types: \\[opened, reopened, synchronize, labeled, ready_for_review\\]$", - "(?m)^ use crate::model::location::ArtifactLocation;$", - "(?m)^ validate_pluralisation_coverage\\(locale, max_branches\\);$", - "(?m)^ world\\.normalised\\.replace\\(Some\\(normalised\\)\\);$", "(?m)^ = help: Cuir seachad `cap_std::fs::Dir` agus paramadairean `camino::Utf8Path`/`Utf8PathBuf` tron API seach std::fs a ghairm gu dìreach\\.$", - "(?m)^ = note: The call originates within function `fail_result` which is not recognised as a test\\.$", - "(?m)^ = note: The call originates within function `handler` which is not recognised as a test\\.$", "(?m)^ = note: The call originates within function `helper` which is not recognised as a test\\.$", "(?m)^ = note: The call originates within function `load` which is not recognised as a test\\.$", - "(?m)^ = note: The call originates within function `main` which is not recognised as a test\\.$", - "(?m)^ = note: The call originates within function `parse_config` which is not recognised as a test\\.$", - "(?m)^ = note: The call originates within function `parse` which is not recognised as a test\\.$", - "(?m)^ = note: The call originates within function `process` which is not recognised as a test\\.$", "(?m)^ = note: The call originates within function `production` which is not recognised as a test\\.$", "(?m)^ CARGO_TERM_COLOR: always$", - "(?m)^ Scenario: Function recognised via configured attribute$", - "(?m)^ Scenario: Recognise configured custom test attribute$", - "(?m)^ Scenario: Recognise rstest decorated functions$", - "(?m)^ Scenario: Recognise tokio::test decorated functions$", - "(?m)^ Scenario: Type methods split into parsing, serialisation, and filesystem groups$", - "(?m)^ Whitaker lints require localised diagnostics with predictable fallbacks\\.$", "(?m)^ `download\\-artifact` merge step would clobber all but the last\\.$", - "(?m)^# Borrowed English nouns typically pluralise with \\-iau \\(Modern Welsh, Gareth$", - "(?m)^# Serialise dylint UI tests that build lint libraries and use$", - "(?m)^# Serialise ignored exclusion integration tests when they are explicitly run\\.$", - "(?m)^## Artifacts and Notes$", - "(?m)^## Artifacts and notes$", + "(?m)^# Borrowed English nouns typically pluralize with \\-iau \\(Modern Welsh, Gareth$", "(?m)^## Cuidhtearan breithneachaidh ga roinn thar linteran Whitaker\\.$", "(?m)^## Seachain `unwrap_or_else` a tha a’ clisgeadh\\.$", "(?m)^## Tha `expect` toirmisgte taobh a\\-muigh deuchainnean\\.$", "(?m)^#\\. Air a shealltainn ann an breithneachaidhean nuair a tha e a’ toirt iomradh air am buadh roimhe\\.$", - "(?m)^#\\[given\\(\"the lint recognises \\{path\\} as a test attribute\"\\)\\]$", - "(?m)^#\\[then\\(\"the function is recognised as an rstest fixture\"\\)\\]$", - "(?m)^#\\[then\\(\"the function is recognised as an rstest test\"\\)\\]$", - "(?m)^#\\[then\\(\"the function is recognised as not being an rstest test\"\\)\\]$", - "(?m)^#\\[then\\(\"the function is recognised as not test\\-like\"\\)\\]$", - "(?m)^#\\[then\\(\"the function is recognised as test\\-like\"\\)\\]$", - "(?m)^#\\[when\\(\"I localise the diagnostic\"\\)\\]$", - "(?m)^#\\[when\\(\"I localise the expect diagnostic\"\\)\\]$", - "(?m)^#\\[when\\(\"I localise the std::fs diagnostic\"\\)\\]$", - "(?m)^#\\[when\\(\"I normalise the settings\"\\)\\]$", - "(?m)^#\\[when\\(\"I summarise the context\"\\)\\]$", - "(?m)^#\\[when\\(\"the complexity is finalised\"\\)\\]$", - "(?m)^//! SARIF run, tool, invocation, and artifact types\\.$", - "(?m)^//! The fixture config recognises `#\\[tokio::test\\]`, but `parse_config` remains$", - "(?m)^/// artifacts: Vec::new\\(\\),$", - "(?m)^/// A location within an artifact \\(source file\\)\\.$", - "(?m)^/// A reference to an artifact by URI\\.$", - "(?m)^/// A region within an artifact, identified by line and column numbers\\.$", - "(?m)^/// A source artifact referenced by results\\.$", - "(?m)^/// and deduplicated\\. Artifacts and invocations are concatenated\\.$", - "(?m)^/// arbitrary `const`\\-only module for a synthesised rstest harness descriptor\\.$", - "(?m)^/// assert_eq!\\(artifact\\.location\\.uri, \"src/main\\.rs\"\\);$", - "(?m)^/// let artifact = Artifact \\{$", - "(?m)^Feature: Localised diagnostics for expect usage$", - "(?m)^Feature: Localised diagnostics for function attribute ordering$", - "(?m)^Feature: Localised diagnostics for std::fs usage$", - "(?m)^Feature: Localiser helpers$", - "(?m)^Feature: Summarise traversal context for `\\.expect\\(\\.\\.\\)` linting$", - "(?m)^\\- Helpers to build rules, results, locations, artifacts, and invocations\\.$", "(?m)^\\- Lexical segment matching can mis\\-handle renamed imports, glob imports, or$", "(?m)^\\- `\\-\\-artifact\\-server\\-path `: export uploaded artefacts to a host$", "(?m)^bumpy_road_function = Tha iomadh cruinneachadh de loidsig chumhachail neadaichte ann an `\\{ \\$name \\}`\\.$", @@ -221,45 +72,38 @@ ignore = [ "(?m)^cargo dylint list \\-\\-color never \\| Where\\-Object \\{\\{$", "(?m)^cargo dylint list \\-\\-color never \\| awk \\-v suite=\"\\{suite_crate\\}\" '\\$0 ~ \"\\^\" suite \"\\(\\[\\[:space:\\]\\]\\|\\$\\)\" \\{\\{ print \\}\\}'$", "(?m)^error: Tha gnìomh std::fs `std::fs::read` a’ seachnadh a’ phoileasaidh comasan airson an t\\-siostaim fhaidhlichean\\.$", - "(?m)^fn capitalise_segment\\(segment: &str\\) \\-> Option \\{$", - "(?m)^fn localised_help_attributes_are_complete\\(\\) \\{$", - "(?m)^fn localised_messages\\($", - "(?m)^fn normalise_isolation_marks\\(text: &str\\) \\-> String \\{$", - "(?m)^fn normalise_settings_falls_back_to_defaults\\($", - "(?m)^fn pluralisation_covers_sample_range\\(#\\[case\\] locale: &str, #\\[case\\] max_branches: i64\\) \\{$", - "(?m)^fn recognises_std_fs_paths\\(#\\[case\\] path: &str, #\\[case\\] expected: bool\\) \\{$", - "(?m)^fn recognises_test_attribute\\(\\) \\{$", - "(?m)^fn resolve_additional_components_parametrised\\(#\\[case\\] cranelift: bool, #\\[case\\] expected: &\\[&str\\]\\) \\{$", - "(?m)^fn scenario_recognises_custom\\(function: FunctionFixture, evaluation: Evaluation\\) \\{$", - "(?m)^fn summarise_context_with_harness<'tcx>\\($", - "(?m)^fn summarises_plain_context\\(\\) \\{$", - "(?m)^fn validate_pluralisation_coverage\\(locale: &str, max_branches: i64\\) \\{$", - "(?m)^fn when_finalised\\(world: &CcWorld\\) \\{$", - "(?m)^fn when_localise\\(world: &WorldCell\\) \\{$", - "(?m)^fn when_normalise\\(world: &World\\) \\{$", - "(?m)^fn when_summarise\\(world: &ContextWorld\\) \\{$", "(?m)^module_max_lines = Tha mòideal \\{ \\$module \\} a’ leudachadh gu \\{ \\$lines \\} loidhnichean agus a’ briseadh an crìoch \\{ \\$limit \\}\\.$", "(?m)^no_std_fs_operations = Tha gnìomh std::fs `\\{ \\$operation \\}` a’ seachnadh a’ phoileasaidh comasan airson an t\\-siostaim fhaidhlichean\\.$", - "(?m)^pub fn normalise_locale\\(input: Option<&str>\\) \\-> Option<&str> \\{$", - "(?m)^pub fn normalise_settings\\(settings: Settings\\) \\-> Settings \\{$", - "(?m)^pub fn rasterise_signal\\($", - "(?m)^pub struct Artifact \\{$", - "(?m)^pub struct ArtifactLocation \\{$", - "(?m)^pub use location::\\{ArtifactLocation, Location, PhysicalLocation, Region, RelatedLocation\\};$", - "(?m)^pub use run::\\{Artifact, Invocation, Run, Tool, ToolComponent\\};$", - "(?m)^pub use selection::\\{LocaleSelection, LocaleSource, normalise_locale, resolve_localizer\\};$", - "(?m)^pub\\(crate\\) fn localised_messages\\($", - "(?m)^pub\\(crate\\) fn summarise_context<'tcx>\\($", - "(?m)^pub\\(crate\\) fn summarise_context\\($", - "(?m)^use crate::analysis::\\{Settings, detect_bumps, normalise_settings\\};$", - "(?m)^use crate::context::\\{ContextSummary, summarise_context\\};$", - "(?m)^use crate::context::\\{collect_context, is_cfg_test_attribute, summarise_context\\};$", - "(?m)^use crate::context::summarise_context;$", - "(?m)^use crate::diagnostics::\\{StdFsMessages, localised_messages\\};$", - "(?m)^use crate::model::location::\\{ArtifactLocation, Location, PhysicalLocation, Region\\};$", - "(?m)^use crate::model::run::\\{Artifact, Invocation, Run, Tool, ToolComponent\\};$", - "(?m)^use whitaker_common::i18n::\\{LocaleSelection, LocaleSource, normalise_locale, resolve_localizer\\};$", - "(?m)^use whitaker_common::i18n::normalise_locale;$", + # Preserve removed API names only on exact fixture and diagnostic lines. + # If a trybuild snapshot changes, update these patterns with the fixture. + '(?m)^use whitaker_common::complexity_signal::rasterise_signal;$', + '(?m)^use whitaker_common::normalise_locale;$', + '(?m)^ let _ = normalise_locale\(Some\("en-GB"\)\);$', + '(?m)^ let _ = rasterise_signal\(1\.\.=1, &\[\]\);$', + '(?m)^3 \| use whitaker_common::complexity_signal::rasterise_signal;$', + '(?m)^3 - use whitaker_common::complexity_signal::rasterise_signal;$', + '(?m)^4 \| use whitaker_common::normalise_locale;$', + '(?m)^4 - use whitaker_common::normalise_locale;$', + '(?m)^use bumpy_road_function::analysis::normalise_settings;$', + '(?m)^ let _ = normalise_settings\(Default::default\(\)\);$', + '(?m)^3 \| use bumpy_road_function::analysis::normalise_settings;$', + '(?m)^3 - use bumpy_road_function::analysis::normalise_settings;$', + '(?m)^use whitaker_sarif::\{Artifact, ArtifactLocation, RunBuilder\};$', + '(?m)^ let _ = RunBuilder::new\("whitaker", "0\.3\.0"\)\.with_artifact\(Artifact \{$', + '(?m)^ location: ArtifactLocation \{$', + '(?m)^3 \| use whitaker_sarif::\{Artifact, ArtifactLocation, RunBuilder\};$', + '(?m)^3 - use whitaker_sarif::\{Artifact, ArtifactLocation, RunBuilder\};$', + '(?m)^3 \+ use whitaker_sarif::\{Artefact, ArtifactLocation, RunBuilder\};$', + '(?m)^3 \+ use whitaker_sarif::\{Artifact, ArtefactLocation, RunBuilder\};$', + '(?m)^7 \| let _ = RunBuilder::new\("whitaker", "0\.3\.0"\)\.with_artifact\(Artifact \{$', + '(?m)^7 - let _ = RunBuilder::new\("whitaker", "0\.3\.0"\)\.with_artifact\(Artifact \{$', + '(?m)^7 \+ let _ = RunBuilder::new\("whitaker", "0\.3\.0"\)\.with_artefact\(Artifact \{$', + '(?m)^ artifact_location: ArtefactLocation \{$', + '(?m)^ let _ = RunBuilder::new\("whitaker", "0\.3\.0"\)\.with_artifact\(Artefact \{$', + '(?m)^8 \| artifact_location: ArtefactLocation \{$', + '(?m)^8 - artifact_location: ArtefactLocation \{$', + '(?m)^16 \| let _ = RunBuilder::new\("whitaker", "0\.3\.0"\)\.with_artifact\(Artefact \{$', + '(?m)^16 - let _ = RunBuilder::new\("whitaker", "0\.3\.0"\)\.with_artifact\(Artefact \{$', # Inline code spans are masked; the shared dictionary does not mask them yet. '`[^`\n]+`', ] diff --git a/typos.toml b/typos.toml index 313a143eb..0730eebab 100644 --- a/typos.toml +++ b/typos.toml @@ -34,92 +34,24 @@ extend-exclude = [ [default] locale = "en-gb" extend-ignore-re = [ - "(?m)^ \\.context\\(\"failed to serialise diagnostic for snapshot\"\\),$", + "\"(?:artifactLocation|artifacts)\"[ \\t]*:", "(?m)^ !json\\.contains\\(\"\\\\\"artifacts\\\\\"\"\\),$", - "(?m)^ Ok\\(parsed\\) => assert_eq!\\(artifact, parsed\\),$", - "(?m)^ \\.artifact_location$", - "(?m)^ artifact_location: ArtifactLocation \\{$", - "(?m)^ artifact_location: whitaker_sarif::ArtifactLocation \\{$", - "(?m)^ assert!\\(!json\\.contains\\(\"\\\\\"artifacts\\\\\"\"\\)\\);$", - "(?m)^ format!\\(\"bumpy\\-road signal rasterisation failed: \\{error\\}\"\\),$", - "(?m)^ Ok\\(json\\) => match serde_json::from_str::\\(&json\\) \\{$", - "(?m)^ \\.artifact_location$", + "(?m)^ json\\.contains\\(\"\\\\\"artifactLocation\\\\\"\"\\),$", + "(?m)^ json\\.contains\\(\"\\\\\"artifacts\\\\\"\"\\),$", "(?m)^ \\.env_remove\\(env::CARGO_TERM_COLOR\\)$", - "(?m)^ \\.expect\\(\"Fluent source should be initialised\"\\);$", - "(?m)^ \\.unwrap_or_else\\(\\|\\| panic!\\(\"localizer should be initialised\"\\)\\)$", - "(?m)^ artifacts: Vec::new\\(\\),$", - "(?m)^ artifacts: self\\.artifacts,$", - "(?m)^ localised_messages\\(&FailingLookup::new\\(\"no_std_fs_operations\"\\), &op\\)$", - "(?m)^ localised_messages\\(localizer, &op\\)$", - "(?m)^ location: ArtifactLocation \\{$", - "(?m)^ location\\.physical_location\\.artifact_location\\.uri, file_uri,$", "(?m)^ \"\\-\\-artifact\\-server\\-path\",$", - "(?m)^ \"failed to initialise isolated rustup: \\{\\}\",$", - "(?m)^ TraitItemMetrics::default_method\\(\"serialise\", 8\\),$", - "(?m)^ \\.expect\\(\"failed to initialise isolated rustup environment\"\\);$", - "(?m)^ \\.expect\\(\"settings should be normalised\"\\);$", - "(?m)^ \\.filter_map\\(capitalise_segment\\)$", - "(?m)^ \\.normalised$", - "(?m)^ all_artifacts\\.extend\\(run\\.artifacts\\.clone\\(\\)\\);$", - "(?m)^ artifacts: all_artifacts,$", - "(?m)^ assert_eq!\\(loc\\.physical_location\\.artifact_location\\.uri, \"src/main\\.rs\"\\);$", - "(?m)^ assert_eq!\\(location\\.physical_location\\.artifact_location\\.uri, file_uri\\);$", - "(?m)^ assert_eq!\\(normalise_locale\\(input\\), expected\\);$", - "(?m)^ capitalised\\.push\\(character\\.to_ascii_lowercase\\(\\)\\);$", - "(?m)^ depth: normalise_weight\\(settings\\.weights\\.depth, defaults\\.weights\\.depth\\),$", - "(?m)^ flow: normalise_weight\\(settings\\.weights\\.flow, defaults\\.weights\\.flow\\),$", + "(?m)^ artifact_location: ArtefactLocation \\{$", "(?m)^ let Message::CompilerArtifact\\(artefact\\) = message else \\{$", "(?m)^ let Ok\\(Message::CompilerArtifact\\(artefact\\)\\) = message else \\{$", - "(?m)^ let artefact = Artifact \\{$", - "(?m)^ let artifact = Artifact \\{$", - "(?m)^ let summary = summarise_context\\($", - "(?m)^ let summary = summarise_context\\(entries\\.as_slice\\(\\), has_test_context_ancestry, additional\\);$", - "(?m)^ let summary = summarise_context_with_harness\\($", - "(?m)^ localised_messages\\(&lookup, kind, attribute\\)$", - "(?m)^ match serde_json::to_string\\(&artifact\\) \\{$", - "(?m)^ normalise_locale\\(self\\.locale\\.as_deref\\(\\)\\)$", - "(?m)^ predicate: normalise_weight\\(settings\\.weights\\.predicate, defaults\\.weights\\.predicate\\),$", - "(?m)^ result\\.locations\\[0\\]\\.physical_location\\.artifact_location\\.uri,$", - "(?m)^ self\\.artifacts\\.push\\(artifact\\);$", - "(?m)^ self\\.settings = normalise_settings\\(load_configuration\\(\\)\\.into_settings\\(\\)\\);$", - "(?m)^ uses: actions/download\\-artifact@v4$", + "(?m)^ location: ArtifactLocation \\{$", "(?m)^ uses: actions/upload\\-artifact@v4$", "(?m)^ uses: actions/upload\\-artifact@v7$", - "(?m)^ world\\.with_localizer\\(\\|localizer\\| localised_messages\\(localizer, kind, attribute\\)\\)$", - "(?m)^ artifact_dir = tmp_path / \"act\\-artifacts\"$", "(?m)^ \\- Download all artefacts \\(actions/download\\-artifact@v4,$", "(?m)^ \\- Upload artefact \\(actions/upload\\-artifact@v4,$", - "(?m)^ /// Appends an artifact reference\\.$", + "(?m)^ #\\[serde\\(default, rename = \"artifacts\", skip_serializing_if = \"Vec::is_empty\"\\)\\]$", + "(?m)^ #\\[serde\\(rename = \"artifactLocation\"\\)\\]$", "(?m)^ /// Categorises the input with a single cluster of conditional logic\\.$", - "(?m)^ /// Identifies the artifact \\(file\\)\\.$", - "(?m)^ /// Location of the artifact\\.$", - "(?m)^ /// Optional byte offset from the start of the artifact\\.$", - "(?m)^ /// Optional region within the artifact\\.$", - "(?m)^ /// Referenced source artifacts\\.$", - "(?m)^ /// Relative or absolute URI of the artifact\\.$", - "(?m)^ /// Returns the source artifact URI used in SARIF output\\.$", - "(?m)^ /// Sets the byte offset from the start of the artifact\\.$", - "(?m)^ /// Sets the region within the artifact\\.$", - "(?m)^ /// assert_eq!\\(run\\.artifacts\\.len\\(\\), 1\\);$", "(?m)^ /// executed \\(catches mis\\-specified scenarios missing the When step\\)\\.$", - "(?m)^ And I normalise the settings$", - "(?m)^ And a default method normalise with CC 7$", - "(?m)^ Artifact, ArtifactLocation, Invocation, Level, Location, Message, MultiformatMessageString,$", - "(?m)^ ContextLabel, Localizer, NoExpectMessages, ReceiverCategory, ReceiverLabel, localised_messages,$", - "(?m)^ DEFAULT_THRESHOLD, Settings, Weights, detect_bumps, normalise_settings, top_two_bumps,$", - "(?m)^ Given the lint recognises custom::test as a test attribute$", - "(?m)^ Some\\(capitalised\\)$", - "(?m)^ Then the function is recognised as an rstest fixture$", - "(?m)^ Then the function is recognised as an rstest test$", - "(?m)^ Then the function is recognised as not being an rstest test$", - "(?m)^ Then the function is recognised as not test\\-like$", - "(?m)^ Then the function is recognised as test\\-like$", - "(?m)^ When I localise the diagnostic$", - "(?m)^ When I localise the expect diagnostic$", - "(?m)^ When I localise the std::fs diagnostic$", - "(?m)^ When I summarise the context$", - "(?m)^ When the complexity is finalised$", - "(?m)^ \\.contains\\(\"unrecognised boolean value 'maybe'\"\\)\\);$", "(?m)^ \\.help = Cuir beachd doc ris a tha a’ mìneachadh giùlan \\{ \\$function \\}\\.$", "(?m)^ \\.help = Cuir seachad `cap_std::fs::Dir` agus paramadairean `camino::Utf8Path`/`Utf8PathBuf` tron API seach std::fs a ghairm gu dìreach\\.$", "(?m)^ \\.label = Tha bump iom\\-fhillteachd \\{ \\$index \\} a’ leudachadh thairis air \\{ \\$lines \\} \\{ \\$lines \\->$", @@ -131,111 +63,46 @@ extend-ignore-re = [ "(?m)^ \\.note = Tha mòidealan mòra nas duilghe an ath\\-sgrùdadh\\.$", "(?m)^ \\.note = Tha na docs airson \\{ \\$test \\} a’ gabhail a\\-steach ceann eisimpleirean no bloca còd le feansa\\.$", "(?m)^ \\.note = Tha teachdaireachdan airson \\{ \\$lint \\} ri fhaighinn ann an Gàidhlig\\.$", - "(?m)^ \\.note = The call originates within \\{ \\$context \\} which is not recognised as a test\\.$", "(?m)^ artefact: &cargo_metadata::Artifact,$", - "(?m)^ artifact_dir = tmp_path / \"act\\-artifacts\"$", - "(?m)^ artifacts: Vec,$", - "(?m)^ assert_eq!\\(normalised, expected\\);$", - "(?m)^ builder\\.add_default_method\\(\"normalise\", 7, false\\);$", - "(?m)^ capitalised\\.push\\(first\\.to_ascii_uppercase\\(\\)\\);$", - "(?m)^ context_label, fallback_messages, localised_messages,$", - "(?m)^ fn artifact_round_trip\\(\\) \\{$", - "(?m)^ fn deserialises_locale_override\\(\\) \\{$", - "(?m)^ fn deserialises_overrides_from_toml\\(\\) \\{$", - "(?m)^ fn normalise_weight\\(candidate: f64, fallback: f64\\) \\-> f64 \\{$", - "(?m)^ fn normalises_candidates\\(#\\[case\\] input: Option<&str>, #\\[case\\] expected: Option<&str>\\) \\{$", - "(?m)^ fn propagates_deserialisation_failures\\(\\) \\{$", - "(?m)^ fn success_message_pluralises_correctly\\(#\\[case\\] count: usize, #\\[case\\] expected: &str\\) \\{$", - "(?m)^ let Some\\(expected_value\\) = normalise_locale\\(Some\\(raw\\.as_str\\(\\)\\)\\) else \\{$", + "(?m)^ let _ = RunBuilder::new\\(\"whitaker\", \"0\\.3\\.0\"\\)\\.with_artifact\\(Artefact \\{$", + "(?m)^ let _ = RunBuilder::new\\(\"whitaker\", \"0\\.3\\.0\"\\)\\.with_artifact\\(Artifact \\{$", "(?m)^ let _ = fixture::categorise\\(0\\);$", "(?m)^ let _ = fixture::categorise\\(4\\);$", "(?m)^ let _ = fixture::categorise\\(99\\);$", - "(?m)^ let candidate = normalise_locale\\(raw\\)\\?;$", - "(?m)^ let expected = normalise_locale\\(Some\\(raw\\.as_str\\(\\)\\)\\)$", - "(?m)^ let expected_value = normalise_locale\\(Some\\(raw\\.as_str\\(\\)\\)\\)$", - "(?m)^ let file = location\\.physical_location\\.artifact_location\\.uri\\.clone\\(\\);$", - "(?m)^ let help = normalise_isolation_marks\\(messages\\.help\\(\\)\\);$", - "(?m)^ let messages = localised_messages\\(&lookup, &receiver_label, &context_label, category\\)$", - "(?m)^ let mut all_artifacts = Vec::new\\(\\);$", - "(?m)^ let mut capitalised = String::with_capacity\\(segment\\.len\\(\\)\\);$", - "(?m)^ let mut summary = crate::context::summarise_context\\(cx, hir_id\\);$", - "(?m)^ let normalised = normalise_settings\\(settings\\);$", - "(?m)^ let normalized = normalise_settings\\(settings\\);$", - "(?m)^ let note = format!\\(\"The call originates within \\{context\\} which is not recognised as a test\\.\",\\);$", - "(?m)^ let note = normalise_isolation_marks\\(messages\\.note\\(\\)\\);$", - "(?m)^ let primary = normalise_isolation_marks\\(messages\\.primary\\(\\)\\);$", - "(?m)^ let summary = summarise_context\\(&entries, false, &\\[\\]\\);$", - "(?m)^ let summary = summarise_context\\(&entries, false, additional\\.as_slice\\(\\)\\);$", - "(?m)^ let summary = summarise_context\\(&entries, true, &\\[\\]\\);$", - "(?m)^ localised_messages,$", - "(?m)^ localised_messages\\(lookup, receiver, &context, category\\)$", - "(?m)^ normalise_locale, resolve_localizer, safe_resolve_message_set, supports_locale,$", - "(?m)^ normalised: RefCell>,$", - "(?m)^ pub artifact_location: ArtifactLocation,$", - "(?m)^ pub artifacts: Vec,$", + "(?m)^ let _ = normalise_locale\\(Some\\(\"en-GB\"\\)\\);$", + "(?m)^ let _ = normalise_settings\\(Default::default\\(\\)\\);$", + "(?m)^ let _ = rasterise_signal\\(1\\.\\.=1, &\\[\\]\\);$", "(?m)^ pub fn categorise\\(input: i32\\) \\-> &'static str \\{$", - "(?m)^ pub fn with_artifact\\(mut self, artifact: Artifact\\) \\-> Self \\{$", - "(?m)^ pub location: super::location::ArtifactLocation,$", "(?m)^ types: \\[opened, reopened, synchronize, labeled, ready_for_review\\]$", - "(?m)^ use crate::model::location::ArtifactLocation;$", - "(?m)^ validate_pluralisation_coverage\\(locale, max_branches\\);$", - "(?m)^ world\\.normalised\\.replace\\(Some\\(normalised\\)\\);$", "(?m)^ = help: Cuir seachad `cap_std::fs::Dir` agus paramadairean `camino::Utf8Path`/`Utf8PathBuf` tron API seach std::fs a ghairm gu dìreach\\.$", - "(?m)^ = note: The call originates within function `fail_result` which is not recognised as a test\\.$", - "(?m)^ = note: The call originates within function `handler` which is not recognised as a test\\.$", "(?m)^ = note: The call originates within function `helper` which is not recognised as a test\\.$", "(?m)^ = note: The call originates within function `load` which is not recognised as a test\\.$", - "(?m)^ = note: The call originates within function `main` which is not recognised as a test\\.$", - "(?m)^ = note: The call originates within function `parse_config` which is not recognised as a test\\.$", - "(?m)^ = note: The call originates within function `parse` which is not recognised as a test\\.$", - "(?m)^ = note: The call originates within function `process` which is not recognised as a test\\.$", "(?m)^ = note: The call originates within function `production` which is not recognised as a test\\.$", "(?m)^ CARGO_TERM_COLOR: always$", - "(?m)^ Scenario: Function recognised via configured attribute$", - "(?m)^ Scenario: Recognise configured custom test attribute$", - "(?m)^ Scenario: Recognise rstest decorated functions$", - "(?m)^ Scenario: Recognise tokio::test decorated functions$", - "(?m)^ Scenario: Type methods split into parsing, serialisation, and filesystem groups$", - "(?m)^ Whitaker lints require localised diagnostics with predictable fallbacks\\.$", "(?m)^ `download\\-artifact` merge step would clobber all but the last\\.$", - "(?m)^# Borrowed English nouns typically pluralise with \\-iau \\(Modern Welsh, Gareth$", - "(?m)^# Serialise dylint UI tests that build lint libraries and use$", - "(?m)^# Serialise ignored exclusion integration tests when they are explicitly run\\.$", - "(?m)^## Artifacts and Notes$", - "(?m)^## Artifacts and notes$", + "(?m)^# Borrowed English nouns typically pluralize with \\-iau \\(Modern Welsh, Gareth$", "(?m)^## Cuidhtearan breithneachaidh ga roinn thar linteran Whitaker\\.$", "(?m)^## Seachain `unwrap_or_else` a tha a’ clisgeadh\\.$", "(?m)^## Tha `expect` toirmisgte taobh a\\-muigh deuchainnean\\.$", "(?m)^#\\. Air a shealltainn ann an breithneachaidhean nuair a tha e a’ toirt iomradh air am buadh roimhe\\.$", - "(?m)^#\\[given\\(\"the lint recognises \\{path\\} as a test attribute\"\\)\\]$", - "(?m)^#\\[then\\(\"the function is recognised as an rstest fixture\"\\)\\]$", - "(?m)^#\\[then\\(\"the function is recognised as an rstest test\"\\)\\]$", - "(?m)^#\\[then\\(\"the function is recognised as not being an rstest test\"\\)\\]$", - "(?m)^#\\[then\\(\"the function is recognised as not test\\-like\"\\)\\]$", - "(?m)^#\\[then\\(\"the function is recognised as test\\-like\"\\)\\]$", - "(?m)^#\\[when\\(\"I localise the diagnostic\"\\)\\]$", - "(?m)^#\\[when\\(\"I localise the expect diagnostic\"\\)\\]$", - "(?m)^#\\[when\\(\"I localise the std::fs diagnostic\"\\)\\]$", - "(?m)^#\\[when\\(\"I normalise the settings\"\\)\\]$", - "(?m)^#\\[when\\(\"I summarise the context\"\\)\\]$", - "(?m)^#\\[when\\(\"the complexity is finalised\"\\)\\]$", - "(?m)^//! SARIF run, tool, invocation, and artifact types\\.$", - "(?m)^//! The fixture config recognises `#\\[tokio::test\\]`, but `parse_config` remains$", - "(?m)^/// artifacts: Vec::new\\(\\),$", - "(?m)^/// A location within an artifact \\(source file\\)\\.$", - "(?m)^/// A reference to an artifact by URI\\.$", - "(?m)^/// A region within an artifact, identified by line and column numbers\\.$", - "(?m)^/// A source artifact referenced by results\\.$", - "(?m)^/// and deduplicated\\. Artifacts and invocations are concatenated\\.$", - "(?m)^/// arbitrary `const`\\-only module for a synthesised rstest harness descriptor\\.$", - "(?m)^/// assert_eq!\\(artifact\\.location\\.uri, \"src/main\\.rs\"\\);$", - "(?m)^/// let artifact = Artifact \\{$", - "(?m)^Feature: Localised diagnostics for expect usage$", - "(?m)^Feature: Localised diagnostics for function attribute ordering$", - "(?m)^Feature: Localised diagnostics for std::fs usage$", - "(?m)^Feature: Localiser helpers$", - "(?m)^Feature: Summarise traversal context for `\\.expect\\(\\.\\.\\)` linting$", - "(?m)^\\- Helpers to build rules, results, locations, artifacts, and invocations\\.$", + "(?m)^.*run\\.artifacts;.*$", + "(?m)^16 - let _ = RunBuilder::new\\(\"whitaker\", \"0\\.3\\.0\"\\)\\.with_artifact\\(Artefact \\{$", + "(?m)^16 \\| let _ = RunBuilder::new\\(\"whitaker\", \"0\\.3\\.0\"\\)\\.with_artifact\\(Artefact \\{$", + "(?m)^3 - use bumpy_road_function::analysis::normalise_settings;$", + "(?m)^3 - use whitaker_common::complexity_signal::rasterise_signal;$", + "(?m)^3 - use whitaker_sarif::\\{Artifact, ArtifactLocation, RunBuilder\\};$", + "(?m)^3 \\+ use whitaker_sarif::\\{Artefact, ArtifactLocation, RunBuilder\\};$", + "(?m)^3 \\+ use whitaker_sarif::\\{Artifact, ArtefactLocation, RunBuilder\\};$", + "(?m)^3 \\| use bumpy_road_function::analysis::normalise_settings;$", + "(?m)^3 \\| use whitaker_common::complexity_signal::rasterise_signal;$", + "(?m)^3 \\| use whitaker_sarif::\\{Artifact, ArtifactLocation, RunBuilder\\};$", + "(?m)^4 - use whitaker_common::normalise_locale;$", + "(?m)^4 \\| use whitaker_common::normalise_locale;$", + "(?m)^7 - let _ = RunBuilder::new\\(\"whitaker\", \"0\\.3\\.0\"\\)\\.with_artifact\\(Artifact \\{$", + "(?m)^7 \\+ let _ = RunBuilder::new\\(\"whitaker\", \"0\\.3\\.0\"\\)\\.with_artefact\\(Artifact \\{$", + "(?m)^7 \\| let _ = RunBuilder::new\\(\"whitaker\", \"0\\.3\\.0\"\\)\\.with_artifact\\(Artifact \\{$", + "(?m)^8 - artifact_location: ArtefactLocation \\{$", + "(?m)^8 \\| artifact_location: ArtefactLocation \\{$", "(?m)^\\- Lexical segment matching can mis\\-handle renamed imports, glob imports, or$", "(?m)^\\- `\\-\\-artifact\\-server\\-path `: export uploaded artefacts to a host$", "(?m)^bumpy_road_function = Tha iomadh cruinneachadh de loidsig chumhachail neadaichte ann an `\\{ \\$name \\}`\\.$", @@ -243,51 +110,19 @@ extend-ignore-re = [ "(?m)^cargo dylint list \\-\\-color never \\| Where\\-Object \\{\\{$", "(?m)^cargo dylint list \\-\\-color never \\| awk \\-v suite=\"\\{suite_crate\\}\" '\\$0 ~ \"\\^\" suite \"\\(\\[\\[:space:\\]\\]\\|\\$\\)\" \\{\\{ print \\}\\}'$", "(?m)^error: Tha gnìomh std::fs `std::fs::read` a’ seachnadh a’ phoileasaidh comasan airson an t\\-siostaim fhaidhlichean\\.$", - "(?m)^fn capitalise_segment\\(segment: &str\\) \\-> Option \\{$", - "(?m)^fn localised_help_attributes_are_complete\\(\\) \\{$", - "(?m)^fn localised_messages\\($", - "(?m)^fn normalise_isolation_marks\\(text: &str\\) \\-> String \\{$", - "(?m)^fn normalise_settings_falls_back_to_defaults\\($", - "(?m)^fn pluralisation_covers_sample_range\\(#\\[case\\] locale: &str, #\\[case\\] max_branches: i64\\) \\{$", - "(?m)^fn recognises_std_fs_paths\\(#\\[case\\] path: &str, #\\[case\\] expected: bool\\) \\{$", - "(?m)^fn recognises_test_attribute\\(\\) \\{$", - "(?m)^fn resolve_additional_components_parametrised\\(#\\[case\\] cranelift: bool, #\\[case\\] expected: &\\[&str\\]\\) \\{$", - "(?m)^fn scenario_recognises_custom\\(function: FunctionFixture, evaluation: Evaluation\\) \\{$", - "(?m)^fn summarise_context_with_harness<'tcx>\\($", - "(?m)^fn summarises_plain_context\\(\\) \\{$", - "(?m)^fn validate_pluralisation_coverage\\(locale: &str, max_branches: i64\\) \\{$", - "(?m)^fn when_finalised\\(world: &CcWorld\\) \\{$", - "(?m)^fn when_localise\\(world: &WorldCell\\) \\{$", - "(?m)^fn when_normalise\\(world: &World\\) \\{$", - "(?m)^fn when_summarise\\(world: &ContextWorld\\) \\{$", "(?m)^module_max_lines = Tha mòideal \\{ \\$module \\} a’ leudachadh gu \\{ \\$lines \\} loidhnichean agus a’ briseadh an crìoch \\{ \\$limit \\}\\.$", "(?m)^no_std_fs_operations = Tha gnìomh std::fs `\\{ \\$operation \\}` a’ seachnadh a’ phoileasaidh comasan airson an t\\-siostaim fhaidhlichean\\.$", - "(?m)^pub fn normalise_locale\\(input: Option<&str>\\) \\-> Option<&str> \\{$", - "(?m)^pub fn normalise_settings\\(settings: Settings\\) \\-> Settings \\{$", - "(?m)^pub fn rasterise_signal\\($", - "(?m)^pub struct Artifact \\{$", - "(?m)^pub struct ArtifactLocation \\{$", - "(?m)^pub use location::\\{ArtifactLocation, Location, PhysicalLocation, Region, RelatedLocation\\};$", - "(?m)^pub use run::\\{Artifact, Invocation, Run, Tool, ToolComponent\\};$", - "(?m)^pub use selection::\\{LocaleSelection, LocaleSource, normalise_locale, resolve_localizer\\};$", - "(?m)^pub\\(crate\\) fn localised_messages\\($", - "(?m)^pub\\(crate\\) fn summarise_context<'tcx>\\($", - "(?m)^pub\\(crate\\) fn summarise_context\\($", - "(?m)^use crate::analysis::\\{Settings, detect_bumps, normalise_settings\\};$", - "(?m)^use crate::context::\\{ContextSummary, summarise_context\\};$", - "(?m)^use crate::context::\\{collect_context, is_cfg_test_attribute, summarise_context\\};$", - "(?m)^use crate::context::summarise_context;$", - "(?m)^use crate::diagnostics::\\{StdFsMessages, localised_messages\\};$", - "(?m)^use crate::model::location::\\{ArtifactLocation, Location, PhysicalLocation, Region\\};$", - "(?m)^use crate::model::run::\\{Artifact, Invocation, Run, Tool, ToolComponent\\};$", - "(?m)^use whitaker_common::i18n::\\{LocaleSelection, LocaleSource, normalise_locale, resolve_localizer\\};$", - "(?m)^use whitaker_common::i18n::normalise_locale;$", + "(?m)^use bumpy_road_function::analysis::normalise_settings;$", + "(?m)^use whitaker_common::complexity_signal::rasterise_signal;$", + "(?m)^use whitaker_common::normalise_locale;$", + "(?m)^use whitaker_sarif::\\{Artifact, ArtifactLocation, RunBuilder\\};$", "(?s)```.*?```", "--[A-Za-z0-9_-]*color(?:ed|s|ing|ize[ds]?|izing)?(?:\\b|_)", "Azure\\s+Architecture\\s+Center\\s*\\|\\s*Microsoft\\s+Learn\\b", "GitHub\\s+Flavored\\s+Markdown\\b(?:\\s\\(GFM\\))?", "HashiCorp", "How to organise your Rust tests", + "\\[\"(?:artifactLocation|artifacts)\"\\]", "\\b(?:background|theme)_color\\b", "\\b(?:carousel|dropdown|footer|indicator|items|navbar|toast)-center\\b", "\\bAppFactory