diff --git a/.config/nextest.toml b/.config/nextest.toml index c68dca64f..1ade17167 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -14,11 +14,13 @@ 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. 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. +# so a test may spend its budget in full. The latest candidate run reached +# 1,861 of 1,871 tests at 120 minutes; a retry was rebuilding after a +# cross-worktree harness-lock wait, and 10 tests remained unrun. The 140-minute +# budget gives that retry and the suite tail time to finish under shared Cargo +# and compiler queues. # See "Test timeouts: four tiers, outermost last" in the developers' guide. -global-timeout = "85m" +global-timeout = "140m" # CI profile — run the full suite including slow installer tests. [profile.ci] @@ -32,7 +34,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 = "85m" +global-timeout = "140m" [[profile.ci.overrides]] # The toolchain auto-install behavioural tests may download and install a @@ -44,13 +46,14 @@ test-group = "serial-toolchain-installs" [[profile.ci.overrides]] # Dylint UI harnesses build lint libraries; the default profile gives them # the same allowance below. Compile-contract tests use the dedicated -# 45-minute override below. +# 90-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) | (binary(ui) & test(=ui))" +filter = "test(driver::ui::) | test(tests::ui::) | test(ui::ui) | test(exported_entry_point_registers_suite_lints) | (binary(ui) & test(=ui))" slow-timeout = { period = "20m", terminate-after = 1, grace-period = "5s" } test-group = "serial-dylint-ui" @@ -58,7 +61,7 @@ test-group = "serial-dylint-ui" # 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" } +slow-timeout = { period = "90m", terminate-after = 1, grace-period = "5s" } test-group = "serial-dylint-ui" retries = 0 @@ -66,7 +69,7 @@ retries = 0 # 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 = "45m", terminate-after = 1, grace-period = "5s" } +slow-timeout = { period = "60m", terminate-after = 1, grace-period = "5s" } test-group = "serial-nested-cargo-builds" [[profile.ci.overrides]] @@ -76,6 +79,13 @@ filter = "binary(no_default_features_build)" slow-timeout = { period = "20m", terminate-after = 1, grace-period = "5s" } test-group = "serial-nested-cargo-builds" +[[profile.ci.overrides]] +# Package-isolated checks can queue for shared cache and compiler resources; +# serialize them with the other nested builds. +filter = "test(crate_builds_without_dylint_driver_feature) | test(fluent_bundles_are_included_in_the_package_tarball)" +slow-timeout = { period = "30m", terminate-after = 1, grace-period = "5s" } +test-group = "serial-nested-cargo-builds" + # 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 @@ -88,6 +98,8 @@ serial-nested-cargo-builds = { max-threads = 1 } [[profile.default.overrides]] # Include crate-local UI entrypoints that wrap dylint UI fixtures. +# The suite registration check also rebuilds the suite library through Dylint, +# so give it the same serialized lane and 20-minute allowance. # On Windows, `dylint::driver_builder::get` persists a temporary file via # `tempfile::NamedTempFile::persist()`. Windows Defender or indexing services # can briefly hold a handle on the file, causing the rename to fail with @@ -120,7 +132,7 @@ serial-nested-cargo-builds = { max-threads = 1 } # Dylint example compilation for that crate is compiled away on Windows. # 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))" +filter = "test(driver::ui::) | test(tests::ui::) | test(ui::ui) | test(exported_entry_point_registers_suite_lints) | 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" } @@ -129,7 +141,7 @@ 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" } +slow-timeout = { period = "90m", terminate-after = 1, grace-period = "5s" } test-group = "serial-dylint-ui" retries = 0 @@ -137,7 +149,7 @@ retries = 0 # 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 = "45m", terminate-after = 1, grace-period = "5s" } +slow-timeout = { period = "60m", terminate-after = 1, grace-period = "5s" } test-group = "serial-nested-cargo-builds" [[profile.default.overrides]] @@ -145,6 +157,13 @@ 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]] +# These isolated Cargo checks also wait for shared cache and compiler +# resources; serialize them with the other nested builds. +filter = "test(crate_builds_without_dylint_driver_feature) | test(fluent_bundles_are_included_in_the_package_tarball)" +slow-timeout = { period = "30m", terminate-after = 1, grace-period = "5s" } +test-group = "serial-nested-cargo-builds" + [[profile.default.overrides]] # Serialize ignored exclusion integration tests when they are explicitly run. # They build and stage the lint library before invoking `cargo dylint`, so they diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f0e2e284a..ead5f86a7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -73,11 +73,11 @@ jobs: # fails closed on a hosted runner, and the glibc-baseline contract is pinned # to this runner image. runs-on: ubicloud-standard-2-ubuntu-2404 - # Above the 85 m nextest global timeout plus a cold build and the work + # Above the 120 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: 120 + timeout-minutes: 180 defaults: run: shell: bash @@ -562,8 +562,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. - # The 120 m job ceiling includes the 85 m suite budget and cold setup. - timeout-minutes: 120 + # The 180 m job ceiling includes the 120 m suite budget and cold setup. + timeout-minutes: 180 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 1491456df..2fa0f77fd 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 85 m nextest global timeout plus a cold build and the work + # Above the 120 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: 120 + timeout-minutes: 180 defaults: run: shell: bash diff --git a/Cargo.lock b/Cargo.lock index c6936a2b5..06664cc81 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1772,6 +1772,7 @@ dependencies = [ "dylint_testing", "log", "newt-hype", + "proptest", "rstest", "rstest-bdd", "rstest-bdd-macros", @@ -3683,6 +3684,7 @@ dependencies = [ "glob", "log", "logtest", + "metrics", "once_cell", "parking_lot", "proptest", @@ -3690,8 +3692,10 @@ dependencies = [ "rstest", "rstest-bdd", "rstest-bdd-macros", + "temp-env", "tempfile", "thiserror 2.0.21", + "tracing", "trybuild", "unic-langid", "whitaker_test_macros", @@ -3750,6 +3754,7 @@ dependencies = [ "rstest-bdd", "rstest-bdd-macros", "rustc_lexer", + "semver", "serde_json", "sha2", "tempfile", @@ -3784,6 +3789,7 @@ dependencies = [ "bumpy_road_function", "conditional_max_n_branches", "dylint_linting", + "dylint_testing", "function_attrs_follow_docs", "module_max_lines", "module_must_have_inner_docs", @@ -3799,6 +3805,8 @@ dependencies = [ "rustc_session", "rustc_span", "test_must_not_have_example", + "whitaker", + "whitaker-common", "whitaker_test_macros", ] diff --git a/Cargo.toml b/Cargo.toml index cd64646f7..1bde175f4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -44,6 +44,7 @@ thiserror = "2" trybuild = "1.0.116" log = "0.4.28" sha2 = "0.11.0" +semver = "1.0.28" tar = "0.4.44" ureq = "3.2.0" zstd = "0.14.0" diff --git a/Makefile b/Makefile index b6903292f..212807c14 100644 --- a/Makefile +++ b/Makefile @@ -339,7 +339,24 @@ spelling: ## Enforce en-GB-oxendict in tracked text nixie: # CI currently requires --no-sandbox; remove once nixie supports # environment variable control for this option - export PATH="$$PATH:$(TOOL_PATH_SUFFIX)"; $(NIXIE) --no-sandbox + # Probe candidates by creating a directory: writable permissions alone do + # not show whether the filesystem still has space for Nixie's scratch files. + export PATH="$$PATH:$(TOOL_PATH_SUFFIX)"; \ + nixie_tmpdir=""; \ + for candidate in "$${XDG_RUNTIME_DIR:-}" "$${TMPDIR:-}" \ + "/run/user/$$(id -u)" "$${HOME:-}" /tmp /var/tmp /usr/tmp .; do \ + [ -n "$$candidate" ] || continue; \ + [ -d "$$candidate" ] && [ -w "$$candidate" ] && [ -x "$$candidate" ] || continue; \ + probe="$$(mktemp -d "$$candidate/nixie.XXXXXX" 2>/dev/null)" || continue; \ + rmdir "$$probe" || continue; \ + nixie_tmpdir="$$candidate"; \ + break; \ + done; \ + if [ -z "$$nixie_tmpdir" ]; then \ + echo "nixie: no usable temporary directory is available" >&2; \ + exit 1; \ + fi; \ + TMPDIR="$$nixie_tmpdir" $(NIXIE) --no-sandbox typecheck: RUSTFLAGS="-C prefer-dynamic -Z force-unstable-if-unmarked $(RUST_FLAGS)" $(CARGO) check $(CARGO_LOCKED) $(CARGO_FLAGS) diff --git a/common/Cargo.toml b/common/Cargo.toml index d53847a6e..eec37e923 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -17,10 +17,13 @@ camino = { workspace = true } fluent-templates = { workspace = true } glob = "0.3.3" log = { workspace = true } +metrics = { workspace = true } once_cell = { workspace = true } parking_lot = { workspace = true } +temp-env = { workspace = true } tempfile = "3.14.0" thiserror = { workspace = true } +tracing = { workspace = true } unic-langid = { workspace = true } [dev-dependencies] diff --git a/common/src/attributes/attribute.rs b/common/src/attributes/attribute.rs index 5284d7113..b1d25e4c7 100644 --- a/common/src/attributes/attribute.rs +++ b/common/src/attributes/attribute.rs @@ -22,7 +22,7 @@ impl Attribute { /// assert!(attribute.is_outer()); /// ``` #[must_use] - pub fn new(path: AttributePath, kind: AttributeKind) -> Self { + pub const fn new(path: AttributePath, kind: AttributeKind) -> Self { Self { path, kind, @@ -87,7 +87,7 @@ impl Attribute { /// assert!(attribute.path().is_doc()); /// ``` #[must_use] - pub fn path(&self) -> &AttributePath { + pub const fn path(&self) -> &AttributePath { &self.path } diff --git a/common/src/attributes/tests.rs b/common/src/attributes/tests.rs index 376e818a3..bdf17cd8b 100644 --- a/common/src/attributes/tests.rs +++ b/common/src/attributes/tests.rs @@ -5,8 +5,8 @@ use rstest::rstest; #[rstest] #[case::empty(Vec::::new())] -#[case::single(vec!["dead_code".to_string()])] -#[case::complex(vec!["cfg(feature = \"test\")".to_string(), "path(\"std::io\")".to_string()])] +#[case::single(vec!["dead_code".to_owned()])] +#[case::complex(vec!["cfg(feature = \"test\")".to_owned(), "path(\"std::io\")".to_owned()])] fn attribute_with_arguments_preserves_inputs(#[case] arguments: Vec) { let attribute = Attribute::with_arguments( AttributePath::from("allow"), diff --git a/common/src/brain_trait_metrics/diagnostic.rs b/common/src/brain_trait_metrics/diagnostic.rs index 00ca41510..5bb9a0407 100644 --- a/common/src/brain_trait_metrics/diagnostic.rs +++ b/common/src/brain_trait_metrics/diagnostic.rs @@ -72,44 +72,44 @@ impl BrainTraitDiagnostic { /// Returns the evaluation disposition. #[must_use] - pub fn disposition(&self) -> BrainTraitDisposition { + pub const fn disposition(&self) -> BrainTraitDisposition { self.disposition } /// Returns the number of required methods. #[must_use] - pub fn required_method_count(&self) -> usize { + pub const fn required_method_count(&self) -> usize { self.required_method_count } /// Returns the number of default methods. #[must_use] - pub fn default_method_count(&self) -> usize { + pub const fn default_method_count(&self) -> usize { self.default_method_count } /// Returns the total method count (required + default). #[must_use] - pub fn total_method_count(&self) -> usize { + pub const fn total_method_count(&self) -> usize { self.required_method_count + self.default_method_count } /// Returns the sum of default method cognitive complexity values. #[must_use] - pub fn default_method_cc_sum(&self) -> usize { + pub const fn default_method_cc_sum(&self) -> usize { self.default_method_cc_sum } /// Returns the total number of trait items (methods + associated /// types + associated consts). #[must_use] - pub fn total_item_count(&self) -> usize { + pub const fn total_item_count(&self) -> usize { self.total_item_count } /// Returns implementor burden (required method count). #[must_use] - pub fn implementor_burden(&self) -> usize { + pub const fn implementor_burden(&self) -> usize { self.implementor_burden } } diff --git a/common/src/brain_trait_metrics/evaluation.rs b/common/src/brain_trait_metrics/evaluation.rs index fa887ff22..992ac8490 100644 --- a/common/src/brain_trait_metrics/evaluation.rs +++ b/common/src/brain_trait_metrics/evaluation.rs @@ -84,20 +84,20 @@ impl BrainTraitThresholds { /// Total method count at or above which the warn rule's method /// condition is met. #[must_use] - pub fn methods_warn(&self) -> usize { + pub const fn methods_warn(&self) -> usize { self.methods_warn } /// Total method count at or above which the deny rule triggers. #[must_use] - pub fn methods_deny(&self) -> usize { + pub const fn methods_deny(&self) -> usize { self.methods_deny } /// Default method CC sum at or above which the warn rule's /// complexity condition is met. #[must_use] - pub fn default_cc_warn(&self) -> usize { + pub const fn default_cc_warn(&self) -> usize { self.default_cc_warn } } @@ -138,7 +138,7 @@ pub struct BrainTraitThresholdsBuilder { impl BrainTraitThresholdsBuilder { /// Creates a builder with all thresholds set to their defaults. #[must_use] - pub fn new() -> Self { + pub const fn new() -> Self { Self { methods_warn: DEFAULT_METHODS_WARN, methods_deny: DEFAULT_METHODS_DENY, @@ -148,28 +148,28 @@ impl BrainTraitThresholdsBuilder { /// Sets the method count warn threshold. #[must_use] - pub fn methods_warn(mut self, value: usize) -> Self { + pub const fn methods_warn(mut self, value: usize) -> Self { self.methods_warn = value; self } /// Sets the method count deny threshold. #[must_use] - pub fn methods_deny(mut self, value: usize) -> Self { + pub const fn methods_deny(mut self, value: usize) -> Self { self.methods_deny = value; self } /// Sets the default method CC sum warn threshold. #[must_use] - pub fn default_cc_warn(mut self, value: usize) -> Self { + pub const fn default_cc_warn(mut self, value: usize) -> Self { self.default_cc_warn = value; self } /// Consumes the builder and returns the completed thresholds. #[must_use] - pub fn build(self) -> BrainTraitThresholds { + pub const fn build(self) -> BrainTraitThresholds { BrainTraitThresholds { methods_warn: self.methods_warn, methods_deny: self.methods_deny, @@ -190,19 +190,19 @@ impl Default for BrainTraitThresholdsBuilder { /// Computes total method count (required + default), excluding /// associated types and consts. -fn total_method_count(metrics: &TraitMetrics) -> usize { +const fn total_method_count(metrics: &TraitMetrics) -> usize { metrics.required_method_count() + metrics.default_method_count() } /// Returns `true` when the deny condition holds (OR-based). #[must_use] -fn is_deny_triggered(metrics: &TraitMetrics, thresholds: &BrainTraitThresholds) -> bool { +const fn is_deny_triggered(metrics: &TraitMetrics, thresholds: &BrainTraitThresholds) -> bool { total_method_count(metrics) >= thresholds.methods_deny } /// Returns `true` when all warn conditions hold simultaneously (AND-based). #[must_use] -fn is_warn_triggered(metrics: &TraitMetrics, thresholds: &BrainTraitThresholds) -> bool { +const fn is_warn_triggered(metrics: &TraitMetrics, thresholds: &BrainTraitThresholds) -> bool { total_method_count(metrics) >= thresholds.methods_warn && metrics.default_method_cc_sum() >= thresholds.default_cc_warn } @@ -233,7 +233,7 @@ fn is_warn_triggered(metrics: &TraitMetrics, thresholds: &BrainTraitThresholds) /// ); /// ``` #[must_use] -pub fn evaluate_brain_trait( +pub const fn evaluate_brain_trait( metrics: &TraitMetrics, thresholds: &BrainTraitThresholds, ) -> BrainTraitDisposition { diff --git a/common/src/brain_trait_metrics/item.rs b/common/src/brain_trait_metrics/item.rs index eff9fb770..a592ed798 100644 --- a/common/src/brain_trait_metrics/item.rs +++ b/common/src/brain_trait_metrics/item.rs @@ -147,7 +147,7 @@ impl TraitItemMetrics { /// assert_eq!(item.kind(), TraitItemKind::RequiredMethod); /// ``` #[must_use] - pub fn kind(&self) -> TraitItemKind { + pub const fn kind(&self) -> TraitItemKind { self.kind } @@ -162,7 +162,7 @@ impl TraitItemMetrics { /// assert_eq!(item.default_method_cc(), Some(9)); /// ``` #[must_use] - pub fn default_method_cc(&self) -> Option { + pub const fn default_method_cc(&self) -> Option { self.default_method_cc } @@ -211,7 +211,7 @@ impl TraitItemMetrics { /// assert_eq!(trait_item_count(&items), 2); /// ``` #[must_use] -pub fn trait_item_count(items: &[TraitItemMetrics]) -> usize { +pub const fn trait_item_count(items: &[TraitItemMetrics]) -> usize { items.len() } diff --git a/common/src/brain_trait_metrics/metrics.rs b/common/src/brain_trait_metrics/metrics.rs index 62489260e..b1fd8e780 100644 --- a/common/src/brain_trait_metrics/metrics.rs +++ b/common/src/brain_trait_metrics/metrics.rs @@ -56,7 +56,7 @@ impl TraitMetrics { /// assert_eq!(builder.build().total_item_count(), 1); /// ``` #[must_use] - pub fn total_item_count(&self) -> usize { + pub const fn total_item_count(&self) -> usize { self.total_item_count } @@ -72,7 +72,7 @@ impl TraitMetrics { /// assert_eq!(builder.build().required_method_count(), 1); /// ``` #[must_use] - pub fn required_method_count(&self) -> usize { + pub const fn required_method_count(&self) -> usize { self.required_method_count } @@ -88,7 +88,7 @@ impl TraitMetrics { /// assert_eq!(builder.build().default_method_count(), 1); /// ``` #[must_use] - pub fn default_method_count(&self) -> usize { + pub const fn default_method_count(&self) -> usize { self.default_method_count } @@ -105,7 +105,7 @@ impl TraitMetrics { /// assert_eq!(builder.build().default_method_cc_sum(), 12); /// ``` #[must_use] - pub fn default_method_cc_sum(&self) -> usize { + pub const fn default_method_cc_sum(&self) -> usize { self.default_method_cc_sum } @@ -122,7 +122,7 @@ impl TraitMetrics { /// assert_eq!(builder.build().implementor_burden(), 2); /// ``` #[must_use] - pub fn implementor_burden(&self) -> usize { + pub const fn implementor_burden(&self) -> usize { self.required_method_count } } @@ -255,7 +255,7 @@ impl TraitMetricsBuilder { /// assert!(TraitMetricsBuilder::new("Parser").is_empty()); /// ``` #[must_use] - pub fn is_empty(&self) -> bool { + pub const fn is_empty(&self) -> bool { self.items.is_empty() } @@ -277,44 +277,21 @@ impl TraitMetricsBuilder { /// ``` #[must_use] pub fn build(self) -> TraitMetrics { - let (total_item_count, required_method_count, default_method_count, default_method_cc_sum) = - self.items.iter().fold( - (0, 0, 0, 0), - |( - total_item_count, - required_method_count, - default_method_count, - default_method_cc_sum, - ), - item| { - let total_item_count = total_item_count + 1; - let (required_method_count, default_method_count, default_method_cc_sum) = - match item.kind() { - TraitItemKind::RequiredMethod => ( - required_method_count + 1, - default_method_count, - default_method_cc_sum, - ), - TraitItemKind::DefaultMethod => ( - required_method_count, - default_method_count + 1, - default_method_cc_sum + item.default_method_cc().unwrap_or(0), - ), - TraitItemKind::AssociatedType | TraitItemKind::AssociatedConst => ( - required_method_count, - default_method_count, - default_method_cc_sum, - ), - }; + let total_item_count = self.items.len(); + let mut required_method_count = 0; + let mut default_method_count = 0; + let mut default_method_cc_sum = 0; - ( - total_item_count, - required_method_count, - default_method_count, - default_method_cc_sum, - ) - }, - ); + for item in &self.items { + match item.kind() { + TraitItemKind::RequiredMethod => required_method_count += 1, + TraitItemKind::DefaultMethod => { + default_method_count += 1; + default_method_cc_sum += item.default_method_cc().unwrap_or(0); + } + TraitItemKind::AssociatedType | TraitItemKind::AssociatedConst => {} + } + } TraitMetrics { trait_name: self.trait_name, diff --git a/common/src/brain_trait_metrics/tests.rs b/common/src/brain_trait_metrics/tests.rs index 697147801..7eb3b707f 100644 --- a/common/src/brain_trait_metrics/tests.rs +++ b/common/src/brain_trait_metrics/tests.rs @@ -28,7 +28,7 @@ struct ExpectedTraitMetrics { burden: usize, } -fn assert_trait_metrics(metrics: &TraitMetrics, expected: ExpectedTraitMetrics) { +fn assert_trait_metrics(metrics: &TraitMetrics, expected: &ExpectedTraitMetrics) { assert_eq!(metrics.trait_name(), expected.name); assert_eq!(metrics.total_item_count(), expected.total); assert_eq!(metrics.required_method_count(), expected.required); @@ -133,7 +133,7 @@ fn builder_builds_mixed_trait_metrics() { assert_trait_metrics( &metrics, - ExpectedTraitMetrics { + &ExpectedTraitMetrics { name: "Parser", total: 4, required: 1, @@ -154,7 +154,7 @@ fn builder_add_item_supports_prebuilt_entries() { assert_trait_metrics( &metrics, - ExpectedTraitMetrics { + &ExpectedTraitMetrics { name: "Renderer", total: 2, required: 1, @@ -176,7 +176,7 @@ fn builder_filters_macro_expanded_default_methods() { assert_trait_metrics( &metrics, - ExpectedTraitMetrics { + &ExpectedTraitMetrics { name: "Parser", total: 2, required: 1, @@ -208,7 +208,7 @@ fn empty_trait_has_zeroed_metrics() { assert_trait_metrics( &metrics, - ExpectedTraitMetrics { + &ExpectedTraitMetrics { name: "EmptyTrait", total: 0, required: 0, diff --git a/common/src/brain_type_metrics/cognitive_complexity.rs b/common/src/brain_type_metrics/cognitive_complexity.rs index 117de893c..317b769e3 100644 --- a/common/src/brain_type_metrics/cognitive_complexity.rs +++ b/common/src/brain_type_metrics/cognitive_complexity.rs @@ -18,7 +18,7 @@ #[path = "cognitive_complexity_tests.rs"] mod tests; -/// Incrementally computes cognitive complexity following SonarSource +/// Incrementally computes cognitive complexity following `SonarSource` /// rules, with macro-expansion filtering. /// /// The HIR walker calls builder methods for each relevant node, @@ -30,7 +30,7 @@ mod tests; /// /// - **Structural** (+1): `if`, `else if`, `else`, `match`, `for`, /// `while`, `loop`, `?` operator, catch-equivalent constructs. -/// - **Nesting** (+effective_depth): applied alongside structural for +/// - **Nesting** (+`effective_depth`): applied alongside structural for /// constructs that also incur a nesting penalty. /// - **Fundamental** (+1): boolean operator sequence breaks (`&&`, /// `||`). @@ -77,7 +77,7 @@ impl CognitiveComplexityBuilder { /// assert_eq!(cc.effective_depth(), 0); /// ``` #[must_use] - pub fn new() -> Self { + pub const fn new() -> Self { Self { score: 0, nesting_stack: Vec::new(), @@ -104,13 +104,13 @@ impl CognitiveComplexityBuilder { /// cc.record_structural_increment(true); // macro — filtered /// assert_eq!(cc.score(), 1); /// ``` - pub fn record_structural_increment(&mut self, is_from_expansion: bool) { + pub const fn record_structural_increment(&mut self, is_from_expansion: bool) { if !is_from_expansion { self.score += 1; } } - /// Records a nesting increment (+effective_depth). + /// Records a nesting increment (+`effective_depth`). /// /// Called alongside [`record_structural_increment`](Self::record_structural_increment) /// for constructs that also incur a nesting penalty (e.g. `if`, @@ -130,7 +130,7 @@ impl CognitiveComplexityBuilder { /// cc.pop_nesting(); /// assert_eq!(cc.build(), 1); /// ``` - pub fn record_nesting_increment(&mut self, is_from_expansion: bool) { + pub const fn record_nesting_increment(&mut self, is_from_expansion: bool) { if !is_from_expansion { self.score += self.effective_depth; } @@ -155,7 +155,7 @@ impl CognitiveComplexityBuilder { /// cc.record_fundamental_increment(true); // macro — filtered /// assert_eq!(cc.score(), 1); /// ``` - pub fn record_fundamental_increment(&mut self, is_from_expansion: bool) { + pub const fn record_fundamental_increment(&mut self, is_from_expansion: bool) { if !is_from_expansion { self.score += 1; } @@ -224,7 +224,7 @@ impl CognitiveComplexityBuilder { /// cc.pop_nesting(); /// ``` #[must_use] - pub fn effective_depth(&self) -> usize { + pub const fn effective_depth(&self) -> usize { self.effective_depth } @@ -240,7 +240,7 @@ impl CognitiveComplexityBuilder { /// assert_eq!(cc.score(), 1); /// ``` #[must_use] - pub fn score(&self) -> usize { + pub const fn score(&self) -> usize { self.score } diff --git a/common/src/brain_type_metrics/diagnostic.rs b/common/src/brain_type_metrics/diagnostic.rs index 95f21bcb3..0008fccf4 100644 --- a/common/src/brain_type_metrics/diagnostic.rs +++ b/common/src/brain_type_metrics/diagnostic.rs @@ -7,8 +7,6 @@ //! See `docs/brain-trust-lints-design.md` §Diagnostic output for the //! full format specification. -use std::fmt::Write; - use super::evaluation::BrainTypeDisposition; use super::{MethodMetrics, TypeMetrics}; use crate::decomposition_advice::{ @@ -72,25 +70,25 @@ impl BrainTypeDiagnostic { /// Returns the evaluation disposition. #[must_use] - pub fn disposition(&self) -> BrainTypeDisposition { + pub const fn disposition(&self) -> BrainTypeDisposition { self.disposition } /// Returns the Weighted Methods Count. #[must_use] - pub fn wmc(&self) -> usize { + pub const fn wmc(&self) -> usize { self.wmc } /// Returns the LCOM4 connected component count. #[must_use] - pub fn lcom4(&self) -> usize { + pub const fn lcom4(&self) -> usize { self.lcom4 } /// Returns the foreign reach count. #[must_use] - pub fn foreign_reach(&self) -> usize { + pub const fn foreign_reach(&self) -> usize { self.foreign_reach } @@ -177,25 +175,22 @@ fn format_primary_with_many_brain_methods( let lcom4 = diagnostic.lcom4(); let fr_suffix = foreign_reach_suffix(diagnostic); let n = methods.len(); - let mut msg = format!( + let method_list = methods + .iter() + .map(|bm| { + format!( + "`{}` (CC={}, LOC={})", + bm.name(), + bm.cognitive_complexity(), + bm.lines_of_code(), + ) + }) + .collect::>() + .join(", "); + format!( "`{name}` has WMC={wmc}, LCOM4={lcom4}{fr_suffix}, \ - and {n} brain methods: ", - ); - for (i, bm) in methods.iter().enumerate() { - if i > 0 { - msg.push_str(", "); - } - // Write cannot fail on String. - let _ = write!( - msg, - "`{}` (CC={}, LOC={})", - bm.name(), - bm.cognitive_complexity(), - bm.lines_of_code(), - ); - } - msg.push('.'); - msg + and {n} brain methods: {method_list}.", + ) } /// Returns the foreign reach suffix for the primary message, or an @@ -229,24 +224,26 @@ fn foreign_reach_suffix(diagnostic: &BrainTypeDiagnostic) -> String { /// ``` #[must_use] pub fn format_note(diagnostic: &BrainTypeDiagnostic) -> String { - let mut note = String::from("WMC measures total cognitive complexity across all methods."); + let mut sentences = vec![String::from( + "WMC measures total cognitive complexity across all methods.", + )]; if !diagnostic.brain_methods().is_empty() { - note.push_str(" Brain methods are methods with high complexity and size."); + sentences.push(String::from( + "Brain methods are methods with high complexity and size.", + )); } if diagnostic.lcom4() >= 2 { - note.push_str( - " LCOM4 >= 2 indicates the type has multiple unrelated \ - responsibilities.", - ); + sentences.push(String::from( + "LCOM4 >= 2 indicates the type has multiple unrelated responsibilities.", + )); } if diagnostic.foreign_reach() > 0 { - let _ = write!( - note, - " Foreign reach of {} indicates coupling to external modules.", + sentences.push(format!( + "Foreign reach of {} indicates coupling to external modules.", diagnostic.foreign_reach(), - ); + )); } - note + sentences.join(" ") } /// Formats a decomposition note from precomputed community suggestions. diff --git a/common/src/brain_type_metrics/evaluation.rs b/common/src/brain_type_metrics/evaluation.rs index 79912dec9..bc708c00e 100644 --- a/common/src/brain_type_metrics/evaluation.rs +++ b/common/src/brain_type_metrics/evaluation.rs @@ -80,31 +80,31 @@ pub struct BrainTypeThresholds { impl BrainTypeThresholds { /// WMC at or above which the warn rule's WMC condition is met. #[must_use] - pub fn wmc_warn(&self) -> usize { + pub const fn wmc_warn(&self) -> usize { self.wmc_warn } /// WMC at or above which the deny rule triggers (OR-based). #[must_use] - pub fn wmc_deny(&self) -> usize { + pub const fn wmc_deny(&self) -> usize { self.wmc_deny } /// LCOM4 at or above which the warn rule's cohesion condition is met. #[must_use] - pub fn lcom4_warn(&self) -> usize { + pub const fn lcom4_warn(&self) -> usize { self.lcom4_warn } /// LCOM4 at or above which the deny rule triggers (OR-based). #[must_use] - pub fn lcom4_deny(&self) -> usize { + pub const fn lcom4_deny(&self) -> usize { self.lcom4_deny } /// Brain method count at or above which the deny rule triggers. #[must_use] - pub fn brain_method_deny_count(&self) -> usize { + pub const fn brain_method_deny_count(&self) -> usize { self.brain_method_deny_count } } @@ -149,7 +149,7 @@ pub struct BrainTypeThresholdsBuilder { impl BrainTypeThresholdsBuilder { /// Creates a builder with all thresholds set to their defaults. #[must_use] - pub fn new() -> Self { + pub const fn new() -> Self { Self { wmc_warn: DEFAULT_WMC_WARN, wmc_deny: DEFAULT_WMC_DENY, @@ -161,42 +161,42 @@ impl BrainTypeThresholdsBuilder { /// Sets the WMC warn threshold. #[must_use] - pub fn wmc_warn(mut self, value: usize) -> Self { + pub const fn wmc_warn(mut self, value: usize) -> Self { self.wmc_warn = value; self } /// Sets the WMC deny threshold. #[must_use] - pub fn wmc_deny(mut self, value: usize) -> Self { + pub const fn wmc_deny(mut self, value: usize) -> Self { self.wmc_deny = value; self } /// Sets the LCOM4 warn threshold. #[must_use] - pub fn lcom4_warn(mut self, value: usize) -> Self { + pub const fn lcom4_warn(mut self, value: usize) -> Self { self.lcom4_warn = value; self } /// Sets the LCOM4 deny threshold. #[must_use] - pub fn lcom4_deny(mut self, value: usize) -> Self { + pub const fn lcom4_deny(mut self, value: usize) -> Self { self.lcom4_deny = value; self } /// Sets the brain method count deny threshold. #[must_use] - pub fn brain_method_deny_count(mut self, value: usize) -> Self { + pub const fn brain_method_deny_count(mut self, value: usize) -> Self { self.brain_method_deny_count = value; self } /// Consumes the builder and returns the completed thresholds. #[must_use] - pub fn build(self) -> BrainTypeThresholds { + pub const fn build(self) -> BrainTypeThresholds { BrainTypeThresholds { wmc_warn: self.wmc_warn, wmc_deny: self.wmc_deny, @@ -219,7 +219,7 @@ impl Default for BrainTypeThresholdsBuilder { /// Returns `true` when any single deny condition holds (OR-based). #[must_use] -fn is_deny_triggered(metrics: &TypeMetrics, thresholds: &BrainTypeThresholds) -> bool { +const fn is_deny_triggered(metrics: &TypeMetrics, thresholds: &BrainTypeThresholds) -> bool { metrics.wmc() >= thresholds.wmc_deny || metrics.brain_method_count() >= thresholds.brain_method_deny_count || metrics.lcom4() >= thresholds.lcom4_deny @@ -227,7 +227,7 @@ fn is_deny_triggered(metrics: &TypeMetrics, thresholds: &BrainTypeThresholds) -> /// Returns `true` when all warn conditions hold simultaneously (AND-based). #[must_use] -fn is_warn_triggered(metrics: &TypeMetrics, thresholds: &BrainTypeThresholds) -> bool { +const fn is_warn_triggered(metrics: &TypeMetrics, thresholds: &BrainTypeThresholds) -> bool { metrics.wmc() >= thresholds.wmc_warn && metrics.brain_method_count() >= 1 && metrics.lcom4() >= thresholds.lcom4_warn @@ -257,7 +257,7 @@ fn is_warn_triggered(metrics: &TypeMetrics, thresholds: &BrainTypeThresholds) -> /// ); /// ``` #[must_use] -pub fn evaluate_brain_type( +pub const fn evaluate_brain_type( metrics: &TypeMetrics, thresholds: &BrainTypeThresholds, ) -> BrainTypeDisposition { diff --git a/common/src/brain_type_metrics/foreign_reach.rs b/common/src/brain_type_metrics/foreign_reach.rs index 16387bc7b..efff8dfb4 100644 --- a/common/src/brain_type_metrics/foreign_reach.rs +++ b/common/src/brain_type_metrics/foreign_reach.rs @@ -121,7 +121,7 @@ impl ForeignReferenceSet { /// assert!(refs.references().contains("std::io")); /// ``` #[must_use] - pub fn references(&self) -> &BTreeSet { + pub const fn references(&self) -> &BTreeSet { &self.references } } diff --git a/common/src/brain_type_metrics/mod.rs b/common/src/brain_type_metrics/mod.rs index e255feea9..bb4fc6a2c 100644 --- a/common/src/brain_type_metrics/mod.rs +++ b/common/src/brain_type_metrics/mod.rs @@ -81,13 +81,13 @@ impl MethodMetrics { /// Returns the cognitive complexity (CC) value. #[must_use] - pub fn cognitive_complexity(&self) -> usize { + pub const fn cognitive_complexity(&self) -> usize { self.cognitive_complexity } /// Returns the lines of code (LOC) count. #[must_use] - pub fn lines_of_code(&self) -> usize { + pub const fn lines_of_code(&self) -> usize { self.lines_of_code } @@ -106,7 +106,7 @@ impl MethodMetrics { /// assert!(!m.is_brain_method(25, 200)); /// ``` #[must_use] - pub fn is_brain_method(&self, cc_threshold: usize, loc_threshold: usize) -> bool { + pub const fn is_brain_method(&self, cc_threshold: usize, loc_threshold: usize) -> bool { self.cognitive_complexity >= cc_threshold && self.lines_of_code >= loc_threshold } } @@ -215,7 +215,7 @@ impl TypeMetrics { /// Weighted Methods Count (sum of CC across all methods). #[must_use] - pub fn wmc(&self) -> usize { + pub const fn wmc(&self) -> usize { self.wmc } @@ -229,30 +229,30 @@ impl TypeMetrics { /// /// Callers that need a collected `Vec` should use `.collect()`. pub fn brain_method_names(&self) -> impl Iterator { - self.brain_methods.iter().map(|m| m.name()) + self.brain_methods.iter().map(MethodMetrics::name) } /// Number of brain methods detected. #[must_use] - pub fn brain_method_count(&self) -> usize { + pub const fn brain_method_count(&self) -> usize { self.brain_methods.len() } /// LCOM4 connected component count (1 = cohesive, >= 2 = low cohesion). #[must_use] - pub fn lcom4(&self) -> usize { + pub const fn lcom4(&self) -> usize { self.lcom4 } /// Count of distinct external modules or types referenced. #[must_use] - pub fn foreign_reach(&self) -> usize { + pub const fn foreign_reach(&self) -> usize { self.foreign_reach } /// Total number of methods in the type. #[must_use] - pub fn method_count(&self) -> usize { + pub const fn method_count(&self) -> usize { self.method_count } } @@ -326,12 +326,12 @@ impl TypeMetricsBuilder { } /// Records the LCOM4 value (connected component count). - pub fn set_lcom4(&mut self, lcom4: usize) { + pub const fn set_lcom4(&mut self, lcom4: usize) { self.lcom4 = Some(lcom4); } /// Records the foreign reach count. - pub fn set_foreign_reach(&mut self, count: usize) { + pub const fn set_foreign_reach(&mut self, count: usize) { self.foreign_reach = Some(count); } diff --git a/common/src/brain_type_metrics/tests.rs b/common/src/brain_type_metrics/tests.rs index b994a33cd..75e3c066b 100644 --- a/common/src/brain_type_metrics/tests.rs +++ b/common/src/brain_type_metrics/tests.rs @@ -99,7 +99,10 @@ fn brain_methods_one_qualifying_method() { ]; let result = brain_methods(&methods, 25, 80); assert_eq!(result.len(), 1); - assert_eq!(result[0].name(), "parse"); + let first = result + .first() + .expect("expected exactly one qualifying brain method"); + assert_eq!(first.name(), "parse"); } #[rstest] @@ -110,9 +113,8 @@ fn brain_methods_multiple_qualifying_in_order() { MethodMetrics::new("gamma", 40, 200), ]; let result = brain_methods(&methods, 25, 80); - assert_eq!(result.len(), 2); - assert_eq!(result[0].name(), "alpha"); - assert_eq!(result[1].name(), "gamma"); + let names: Vec<&str> = result.iter().map(|method| method.name()).collect(); + assert_eq!(names, ["alpha", "gamma"]); } #[rstest] diff --git a/common/src/complexity_signal.rs b/common/src/complexity_signal.rs index 29d6caffd..835d36798 100644 --- a/common/src/complexity_signal.rs +++ b/common/src/complexity_signal.rs @@ -24,6 +24,11 @@ impl LineSegment { /// /// Line numbers are one-based and ranges are inclusive. /// + /// # Errors + /// + /// - Returns [`SegmentError::LineNumberMustBeOneBased`] when either line is `0`. + /// - Returns [`SegmentError::StartAfterEnd`] when `start_line` exceeds `end_line`. + /// /// # Examples /// /// ``` @@ -34,7 +39,7 @@ impl LineSegment { /// assert_eq!(segment.end_line(), 5); /// ``` #[must_use = "Inspect the segment creation result to handle invalid ranges"] - pub fn new(start_line: usize, end_line: usize, value: f64) -> Result { + pub const fn new(start_line: usize, end_line: usize, value: f64) -> Result { if start_line == 0 || end_line == 0 { return Err(SegmentError::LineNumberMustBeOneBased { start_line, @@ -80,13 +85,23 @@ impl LineSegment { pub enum SegmentError { /// Line numbers must be one-based (line 0 is invalid). #[error("line numbers must be one-based (got start_line={start_line}, end_line={end_line})")] - LineNumberMustBeOneBased { start_line: usize, end_line: usize }, + LineNumberMustBeOneBased { + /// One-based first line requested for the segment. + start_line: usize, + /// One-based last line requested for the segment. + end_line: usize, + }, /// The segment start must not occur after its end. #[error( "segment start must not occur after end (start_line={start_line}, end_line={end_line})" )] - StartAfterEnd { start_line: usize, end_line: usize }, + StartAfterEnd { + /// One-based first line requested for the segment. + start_line: usize, + /// One-based last line requested for the segment. + end_line: usize, + }, } /// Errors emitted when building a per-line signal. @@ -96,27 +111,41 @@ pub enum SignalBuildError { #[error( "function line range must be one-based (got start_line={start_line}, end_line={end_line})" )] - FunctionLineRangeMustBeOneBased { start_line: usize, end_line: usize }, + FunctionLineRangeMustBeOneBased { + /// One-based first line of the supplied function range. + start_line: usize, + /// One-based last line of the supplied function range. + end_line: usize, + }, /// The function start must not occur after its end. #[error( "function start must not occur after end (start_line={start_line}, end_line={end_line})" )] - FunctionStartAfterEnd { start_line: usize, end_line: usize }, + FunctionStartAfterEnd { + /// One-based first line of the supplied function range. + start_line: usize, + /// One-based last line of the supplied function range. + end_line: usize, + }, /// A segment does not intersect the function's line range. #[error( "segment lies outside function range (segment={segment_start}..={segment_end}, function={function_start}..={function_end})" )] SegmentOutsideFunctionRange { + /// One-based first line covered by the offending segment. segment_start: usize, + /// One-based last line covered by the offending segment. segment_end: usize, + /// One-based first line of the function range. function_start: usize, + /// One-based last line of the function range. function_end: usize, }, } -fn validate_function_range( +const fn validate_function_range( function_start: usize, function_end: usize, ) -> Result<(), SignalBuildError> { @@ -137,7 +166,7 @@ fn validate_function_range( Ok(()) } -fn validate_segment_in_range( +const fn validate_segment_in_range( segment: &LineSegment, function_start: usize, function_end: usize, @@ -154,6 +183,10 @@ fn validate_segment_in_range( Ok(()) } +#[expect( + clippy::float_arithmetic, + reason = "bumpy-road signal processing operates on user-configured floating-point weights" +)] fn apply_segment_to_diff(segment: &LineSegment, diff: &mut [f64], function_start: usize) { let segment_start = segment.start_line().saturating_sub(function_start); let segment_end = segment.end_line().saturating_sub(function_start); @@ -167,6 +200,10 @@ fn apply_segment_to_diff(segment: &LineSegment, diff: &mut [f64], function_start } } +#[expect( + clippy::float_arithmetic, + reason = "bumpy-road signal processing operates on user-configured floating-point weights" +)] fn accumulate_signal_from_diff(diff: &[f64], len: usize) -> Vec { let mut signal = Vec::with_capacity(len); let mut running = 0.0_f64; @@ -230,11 +267,17 @@ pub fn rasterize_signal( pub enum SmoothingError { /// The moving average window must be positive. #[error("smoothing window must be positive (got {window})")] - WindowMustBePositive { window: usize }, + WindowMustBePositive { + /// The rejected window size, in lines. + window: usize, + }, /// The moving average window must be odd so the average is centred. #[error("smoothing window must be odd (got {window})")] - WindowMustBeOdd { window: usize }, + WindowMustBeOdd { + /// The rejected window size, in lines. + window: usize, + }, } /// Applies a centred moving-average smoothing window. @@ -257,16 +300,16 @@ pub enum SmoothingError { /// assert_eq!(smoothed, vec![0.0, 1.0, 1.0, 1.0, 0.0]); /// ``` #[must_use = "Inspect the smoothing result to handle invalid window sizes"] +#[expect( + clippy::float_arithmetic, + reason = "bumpy-road signal processing operates on user-configured floating-point weights" +)] pub fn smooth_moving_average(signal: &[f64], window: usize) -> Result, SmoothingError> { if window == 0 { return Err(SmoothingError::WindowMustBePositive { window }); } - fn is_even(value: usize) -> bool { - (value & 1) == 0 - } - - if is_even(window) { + if (window & 1) == 0 { return Err(SmoothingError::WindowMustBeOdd { window }); } @@ -274,11 +317,11 @@ pub fn smooth_moving_average(signal: &[f64], window: usize) -> Result, return Ok(Vec::new()); } - let half_window = window / 2; + let half_window = window.div_euclid(2); let mut prefix = Vec::with_capacity(signal.len() + 1); prefix.push(0.0_f64); for &value in signal { - let next = prefix[prefix.len() - 1] + value; + let next = prefix.last().copied().unwrap_or(0.0) + value; prefix.push(next); } @@ -287,8 +330,9 @@ pub fn smooth_moving_average(signal: &[f64], window: usize) -> Result, for idx in 0..signal.len() { let start = idx.saturating_sub(half_window); let end = (idx + half_window).min(last_index); - let sum = prefix[end + 1] - prefix[start]; - let count = (end - start + 1) as f64; + let sum = + prefix.get(end + 1).copied().unwrap_or(0.0) - prefix.get(start).copied().unwrap_or(0.0); + let count = u32::try_from(end - start + 1).map_or_else(|_| f64::from(u32::MAX), f64::from); smoothed.push(sum / count); } diff --git a/common/src/context.rs b/common/src/context.rs index 2d7b7a845..43b33db64 100644 --- a/common/src/context.rs +++ b/common/src/context.rs @@ -89,7 +89,7 @@ impl ContextEntry { /// Returns a mutable reference to the attributes for in-place updates. #[must_use] - pub fn attributes_mut(&mut self) -> &mut Vec { + pub const fn attributes_mut(&mut self) -> &mut Vec { &mut self.attributes } diff --git a/common/src/decomposition_advice/community.rs b/common/src/decomposition_advice/community.rs index 92a890604..3773cb773 100644 --- a/common/src/decomposition_advice/community.rs +++ b/common/src/decomposition_advice/community.rs @@ -2,85 +2,36 @@ use std::collections::BTreeMap; -use super::vector::{ - MIN_COSINE_THRESHOLD_DENOMINATOR_SQUARED, MIN_COSINE_THRESHOLD_NUMERATOR_SQUARED, - MethodFeatureVector, cosine_threshold_met, dot_product, -}; - -#[derive(Clone, Debug, Eq, PartialEq)] -pub(crate) struct SimilarityEdge { - left: usize, - right: usize, - weight: u64, -} - -impl SimilarityEdge { - // Used by test_support::decomposition::adjacency_report and unit tests. - pub(crate) fn new(left: usize, right: usize, weight: u64) -> Self { - Self { - left, - right, - weight, - } - } - - #[cfg(test)] - pub(crate) fn left(&self) -> usize { - self.left - } - - #[cfg(test)] - pub(crate) fn right(&self) -> usize { - self.right - } - - #[cfg(test)] - pub(crate) fn weight(&self) -> u64 { - self.weight - } -} - -pub(crate) fn build_similarity_edges(vectors: &[MethodFeatureVector]) -> Vec { - let mut edges = Vec::new(); - - for left in 0..vectors.len() { - for right in (left + 1)..vectors.len() { - if !cosine_threshold_met( - &vectors[left], - &vectors[right], - MIN_COSINE_THRESHOLD_NUMERATOR_SQUARED, - MIN_COSINE_THRESHOLD_DENOMINATOR_SQUARED, - ) { - continue; - } - - let weight = dot_product(vectors[left].weights(), vectors[right].weights()); - if weight == 0 { - continue; - } - - edges.push(SimilarityEdge { - left, - right, - weight, - }); - } - } - - edges -} - -pub(crate) fn detect_communities(vectors: &[MethodFeatureVector]) -> Vec> { +mod propagation; +mod selection; + +use super::error::DecompositionError; +pub(crate) use super::graph::{SimilarityEdge, build_similarity_edges}; +use super::vector::MethodFeatureVector; +use propagation::labels_are_stable; +#[cfg(test)] +use propagation::run_propagation_pass; +pub(crate) use propagation::{LabelPropagationReport, propagate_labels_report}; + +/// Detect communities and reject malformed intermediate graph state. +/// +/// # Errors +/// +/// Returns an error when adjacency construction or label propagation finds +/// invalid node or label state. +pub(crate) fn detect_communities( + vectors: &[MethodFeatureVector], +) -> Result>, DecompositionError> { if vectors.is_empty() { - return Vec::new(); + return Ok(Vec::new()); } let edges = build_similarity_edges(vectors); - let adjacency = build_adjacency(vectors.len(), &edges); + let adjacency = build_adjacency(vectors.len(), &edges)?; let max_iterations = vectors.len().saturating_mul(2).max(1); - let report = propagate_labels_report(vectors, &adjacency, max_iterations); + let report = propagate_labels_report(vectors, &adjacency, max_iterations)?; if log::log_enabled!(log::Level::Debug) { - let converged = labels_are_stable(vectors, &adjacency, &report.labels); + let converged = labels_are_stable(vectors, &adjacency, &report.labels)?; log::debug!( "label propagation complete: nodes={}, iterations={}, converged={}", vectors.len(), @@ -88,224 +39,107 @@ pub(crate) fn detect_communities(vectors: &[MethodFeatureVector]) -> Vec> = BTreeMap::new(); - for (node, label) in labels.into_iter().enumerate() { - groups.entry(label).or_default().push(node); - } - let mut communities: Vec> = groups.into_values().collect(); - for community in &mut communities { - community.sort_by(|left, right| { - vectors[*left] - .method_name() - .cmp(vectors[*right].method_name()) - }); - } - - communities.sort_by(|left, right| { - right.len().cmp(&left.len()).then_with(|| { - vectors[left[0]] - .method_name() - .cmp(vectors[right[0]].method_name()) - }) - }); - communities + let groups = group_nodes_by_label(report.labels); + Ok(sort_communities(groups, vectors)) } -/// Observable output from deterministic label propagation. +/// Build symmetric adjacency lists with neighbours ordered by node index. /// -/// `labels` contains one final community label per input method vector. Each -/// label is always a valid node index because propagation starts from the -/// identity labelling `0..vectors.len()` and only adopts labels already owned -/// by neighbours. +/// # Errors /// -/// `iteration_count` records how many full passes over the active-node set -/// were executed. The count increments once per attempted propagation pass, -/// including the final pass that detects convergence. A value of `0` therefore -/// means either `max_iterations == 0` or the graph had no active nodes. -#[derive(Clone, Debug, Eq, PartialEq)] -pub(crate) struct LabelPropagationReport { - pub(crate) labels: Vec, - pub(crate) iteration_count: usize, -} - +/// Returns an error without exposing a partial graph when an endpoint is +/// outside `0..node_count`. pub(crate) fn build_adjacency( node_count: usize, edges: &[SimilarityEdge], -) -> Vec> { - let mut adjacency = vec![Vec::new(); node_count]; - - for edge in edges { - adjacency[edge.left].push((edge.right, edge.weight)); - adjacency[edge.right].push((edge.left, edge.weight)); - } +) -> Result>, DecompositionError> { + let mut adjacency = build_adjacency_rows_unordered(node_count, edges)?; for neighbours in &mut adjacency { neighbours.sort_by_key(|left| left.0); } - - adjacency + Ok(adjacency) } -/// Runs deterministic weighted label propagation and reports its final state. +/// Build symmetric adjacency rows before deterministic neighbour ordering. /// -/// The returned report owns the final labels and the number of propagation -/// passes that were actually executed. Callers inside the crate can therefore -/// inspect the labels without re-running propagation or borrowing the input -/// graph. +/// Keep this helper private to community graph construction. Production callers +/// use `build_adjacency` for ordered rows, and generated graph properties check +/// ordering through the production function. /// -/// Callers must ensure that every node index referenced from `adjacency` is in -/// bounds for the initial self labelling `0..vectors.len()`. The helper -/// assumes validated adjacency input and may panic if a neighbour index falls -/// outside that range. +/// # Errors /// -/// If `adjacency` contains no active nodes, or if `max_iterations` is `0`, it -/// returns the initial self labelling with an `iteration_count` of `0`. -pub(crate) fn propagate_labels_report( - vectors: &[MethodFeatureVector], - adjacency: &[Vec<(usize, u64)>], - max_iterations: usize, -) -> LabelPropagationReport { - assert_eq!( - adjacency.len(), - vectors.len(), - "propagate_labels_report requires adjacency rows to match vectors" - ); - let mut labels: Vec = (0..vectors.len()).collect(); - // Each `node` comes from `adjacency`; keep rows aligned with `labels` so - // `labels[node]` stays in bounds. - let active_nodes: Vec<_> = adjacency +/// Returns an error without exposing partial rows when an endpoint is outside +/// `0..node_count`. +fn build_adjacency_rows_unordered( + node_count: usize, + edges: &[SimilarityEdge], +) -> Result>, DecompositionError> { + if edges .iter() - .enumerate() - .filter_map(|(node, neighbours)| (!neighbours.is_empty()).then_some(node)) - .collect(); - if active_nodes.is_empty() { - log::debug!( - "label propagation: no active nodes, skipping (total_nodes={})", - vectors.len(), - ); - return LabelPropagationReport { - labels, - iteration_count: 0, - }; - } - let mut iteration_count = 0; - - for _ in 0..max_iterations { - iteration_count += 1; - let mut changed = false; - - for &node in &active_nodes { - let Some(best_label) = best_neighbour_label(node, &labels, adjacency, vectors) else { - continue; - }; - - if best_label != labels[node] { - labels[node] = best_label; - changed = true; - } - } - - if !changed { - log::debug!( - "label propagation converged: nodes={}, active_nodes={}, iterations={}", - vectors.len(), - active_nodes.len(), - iteration_count, - ); - break; - } + .any(|edge| edge.left >= node_count || edge.right >= node_count) + { + return Err(DecompositionError::InvalidEdgeEndpoint { + node_count, + edge_count: edges.len(), + }); } - if iteration_count == max_iterations { - log::debug!( - "label propagation reached iteration limit: nodes={}, active_nodes={}, max_iterations={}", - vectors.len(), - active_nodes.len(), - max_iterations, - ); + let mut adjacency = vec![Vec::new(); node_count]; + for edge in edges { + let Some(left_neighbours) = adjacency.get_mut(edge.left) else { + return Err(DecompositionError::InvalidEdgeEndpoint { + node_count, + edge_count: edges.len(), + }); + }; + left_neighbours.push((edge.right, edge.weight)); + let Some(right_neighbours) = adjacency.get_mut(edge.right) else { + return Err(DecompositionError::InvalidEdgeEndpoint { + node_count, + edge_count: edges.len(), + }); + }; + right_neighbours.push((edge.left, edge.weight)); } - LabelPropagationReport { - labels, - iteration_count, - } + Ok(adjacency) } -fn best_neighbour_label( - node: usize, - labels: &[usize], - adjacency: &[Vec<(usize, u64)>], - vectors: &[MethodFeatureVector], -) -> Option { - let neighbours = &adjacency[node]; - if neighbours.is_empty() { - return None; - } - - let mut scores = BTreeMap::new(); - let mut best: Option<(usize, u64)> = None; - - for &(neighbour, weight) in neighbours { - let label = labels[neighbour]; - let score = score_label(&mut scores, label, weight); - - if should_replace_best(best, label, score, vectors) { - best = Some((label, score)); - } +/// Group each node index under its propagated community label. +fn group_nodes_by_label(labels: Vec) -> Vec> { + let mut groups = BTreeMap::>::new(); + for (node, label) in labels.into_iter().enumerate() { + groups.entry(label).or_default().push(node); } - - best.map(|(label, _)| label) + groups.into_values().collect() } -fn labels_are_stable( +/// Sort nodes and communities by their stable method-name ordering. +fn sort_communities( + mut communities: Vec>, vectors: &[MethodFeatureVector], - adjacency: &[Vec<(usize, u64)>], - labels: &[usize], -) -> bool { - adjacency.iter().enumerate().all(|(node, neighbours)| { - if neighbours.is_empty() { - return true; - } +) -> Vec> { + // Node indices come from `0..vectors.len()`, so these lookups cannot miss. + let method_name = |node: usize| vectors.get(node).map(MethodFeatureVector::method_name); - match best_neighbour_label(node, labels, adjacency, vectors) { - Some(best_label) => labels[node] == best_label, - None => true, - } - }) -} + for community in &mut communities { + community.sort_by(|left, right| method_name(*left).cmp(&method_name(*right))); + } -fn score_label(scores: &mut BTreeMap, label: usize, weight: u64) -> u64 { - let score = scores.entry(label).or_default(); - *score += weight; - *score + communities.sort_by(|left, right| { + right.len().cmp(&left.len()).then_with(|| { + let left_name = left.first().and_then(|&node| method_name(node)); + let right_name = right.first().and_then(|&node| method_name(node)); + left_name.cmp(&right_name) + }) + }); + communities } -fn should_replace_best( - current_best: Option<(usize, u64)>, - candidate_label: usize, - candidate_score: u64, - vectors: &[MethodFeatureVector], -) -> bool { - match current_best { - None => true, - Some((best_label, best_score)) => { - // Prefer higher score; on tie, pick the lexically earlier method - // name and then the smaller label index to keep runs deterministic. - if candidate_score != best_score { - candidate_score > best_score - } else { - let candidate_name = vectors[candidate_label].method_name(); - let best_name = vectors[best_label].method_name(); - - candidate_name < best_name - || (candidate_name == best_name && candidate_label < best_label) - } - } - } -} +#[cfg(test)] +mod tests; #[cfg(kani)] #[path = "community_kani/mod.rs"] diff --git a/common/src/decomposition_advice/community/propagation.rs b/common/src/decomposition_advice/community/propagation.rs new file mode 100644 index 000000000..c35f14fb7 --- /dev/null +++ b/common/src/decomposition_advice/community/propagation.rs @@ -0,0 +1,183 @@ +//! Run deterministic weighted label propagation over checked adjacency rows. + +use super::super::error::DecompositionError; +use super::super::vector::MethodFeatureVector; +use super::selection::best_neighbour_label; + +/// Report the final labels and number of propagation passes. +/// +/// `labels` contains one final community label per input method vector. Each +/// label is a valid node index because propagation starts from the identity +/// labelling `0..vectors.len()` and only adopts checked neighbour labels. +/// +/// `iteration_count` records how many full passes over the active-node set were +/// executed. The count includes the final pass that detects convergence. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct LabelPropagationReport { + pub(crate) labels: Vec, + pub(crate) iteration_count: usize, +} + +/// Carry the active nodes and pass bound for one propagation invocation. +struct PropagationPlan<'a> { + active_nodes: &'a [usize], + max_iterations: usize, +} + +/// Propagate labels and report the completed passes. +/// +/// If adjacency contains no active nodes, or `max_iterations` is zero, the +/// function returns the initial self-labelling with zero iterations. +/// +/// # Errors +/// +/// Returns an error when adjacency rows, neighbour labels, or label slots do +/// not match the supplied vectors. +pub(crate) fn propagate_labels_report( + vectors: &[MethodFeatureVector], + adjacency: &[Vec<(usize, u64)>], + max_iterations: usize, +) -> Result { + if adjacency.len() != vectors.len() { + return Err(DecompositionError::AdjacencyRowCountMismatch { + expected_rows: vectors.len(), + actual_rows: adjacency.len(), + }); + } + + let mut labels: Vec = (0..vectors.len()).collect(); + let active_nodes = active_nodes(adjacency); + if active_nodes.is_empty() { + log::debug!( + "label propagation: no active nodes, skipping (total_nodes={})", + vectors.len(), + ); + return Ok(LabelPropagationReport { + labels, + iteration_count: 0, + }); + } + + let plan = PropagationPlan { + active_nodes: &active_nodes, + max_iterations, + }; + let iteration_count = run_iterations(vectors, adjacency, &plan, &mut labels)?; + Ok(LabelPropagationReport { + labels, + iteration_count, + }) +} + +/// Collect nodes that have at least one neighbour. +fn active_nodes(adjacency: &[Vec<(usize, u64)>]) -> Vec { + adjacency + .iter() + .enumerate() + .filter_map(|(node, neighbours)| (!neighbours.is_empty()).then_some(node)) + .collect() +} + +/// Run passes until labels stabilize or the configured limit is reached. +/// +/// # Errors +/// +/// Returns an error when an active node or one of its propagated labels is +/// invalid. +fn run_iterations( + vectors: &[MethodFeatureVector], + adjacency: &[Vec<(usize, u64)>], + plan: &PropagationPlan<'_>, + labels: &mut [usize], +) -> Result { + let mut iteration_count = 0; + for _ in 0..plan.max_iterations { + iteration_count += 1; + if !run_propagation_pass(vectors, adjacency, plan.active_nodes, labels)? { + log::debug!( + "label propagation converged: nodes={}, active_nodes={}, iterations={}", + vectors.len(), + plan.active_nodes.len(), + iteration_count, + ); + break; + } + } + + if iteration_count == plan.max_iterations { + log::debug!( + "label propagation reached iteration limit: nodes={}, active_nodes={}, max_iterations={}", + vectors.len(), + plan.active_nodes.len(), + plan.max_iterations, + ); + } + Ok(iteration_count) +} + +/// Run one label-propagation pass over the active nodes. +/// +/// Return `true` when a node adopts a new label. +/// +/// # Errors +/// +/// Returns an error when an active node has no corresponding label slot or +/// one of its neighbours has an invalid label. +pub(super) fn run_propagation_pass( + vectors: &[MethodFeatureVector], + adjacency: &[Vec<(usize, u64)>], + active_nodes: &[usize], + labels: &mut [usize], +) -> Result { + let mut changed = false; + for &node in active_nodes { + let Some(best_label) = best_neighbour_label(node, labels, adjacency, vectors)? else { + continue; + }; + let node_count = labels.len(); + let label_slot = labels + .get_mut(node) + .ok_or(DecompositionError::InvalidLabelSlot { node_count })?; + if *label_slot != best_label { + *label_slot = best_label; + changed = true; + } + } + Ok(changed) +} + +/// Check that every active node already holds its best neighbour label. +/// +/// # Errors +/// +/// Returns an error when adjacency references a missing node or label. +pub(super) fn labels_are_stable( + vectors: &[MethodFeatureVector], + adjacency: &[Vec<(usize, u64)>], + labels: &[usize], +) -> Result { + for (node, neighbours) in adjacency.iter().enumerate() { + if !neighbours.is_empty() && !node_holds_best_label(node, vectors, adjacency, labels)? { + return Ok(false); + } + } + Ok(true) +} + +/// Compare a node's current label with its best adjacent label. +fn node_holds_best_label( + node: usize, + vectors: &[MethodFeatureVector], + adjacency: &[Vec<(usize, u64)>], + labels: &[usize], +) -> Result { + let Some(best_label) = best_neighbour_label(node, labels, adjacency, vectors)? else { + return Ok(true); + }; + let current_label = labels + .get(node) + .ok_or(DecompositionError::InvalidLabelSlot { + node_count: labels.len(), + })?; + Ok(*current_label == best_label) +} diff --git a/common/src/decomposition_advice/community/selection.rs b/common/src/decomposition_advice/community/selection.rs new file mode 100644 index 000000000..f767222ca --- /dev/null +++ b/common/src/decomposition_advice/community/selection.rs @@ -0,0 +1,106 @@ +//! Score neighbouring labels and apply deterministic tie-breaking. + +use std::collections::BTreeMap; + +use super::super::error::DecompositionError; +use super::super::vector::MethodFeatureVector; + +/// Select the highest-scoring label among a node's neighbours. +/// +/// # Errors +/// +/// Returns an error when a neighbour or its propagated label is outside the +/// vector range. +pub(super) fn best_neighbour_label( + node: usize, + labels: &[usize], + adjacency: &[Vec<(usize, u64)>], + vectors: &[MethodFeatureVector], +) -> Result, DecompositionError> { + let neighbours = adjacency + .get(node) + .ok_or(DecompositionError::InvalidLabelSlot { + node_count: labels.len(), + })?; + if neighbours.is_empty() { + return Ok(None); + } + + let mut scores = BTreeMap::new(); + let mut best: Option<(usize, u64)> = None; + for &(neighbour, weight) in neighbours { + let label = checked_neighbour_label(neighbour, labels, vectors.len())?; + let score = score_label(&mut scores, label, weight); + if should_replace_best(best, label, score, vectors)? { + best = Some((label, score)); + } + } + Ok(best.map(|(label, _)| label)) +} + +/// Return a neighbour's propagated label after checking both index domains. +/// +/// # Errors +/// +/// Returns an error when either the neighbour index or its label is invalid. +fn checked_neighbour_label( + neighbour: usize, + labels: &[usize], + vector_count: usize, +) -> Result { + let label = *labels + .get(neighbour) + .ok_or(DecompositionError::InvalidNeighbourLabel { + node_count: labels.len(), + })?; + if label >= vector_count { + return Err(DecompositionError::InvalidNeighbourLabel { + node_count: vector_count, + }); + } + Ok(label) +} + +/// Add `weight` to a label's accumulated score and return the new total. +fn score_label(scores: &mut BTreeMap, label: usize, weight: u64) -> u64 { + let score = scores.entry(label).or_default(); + *score += weight; + *score +} + +/// Compare candidates by score, method name, and label index. +/// +/// # Errors +/// +/// Returns an error if either label does not identify a method vector. +fn should_replace_best( + current_best: Option<(usize, u64)>, + candidate_label: usize, + candidate_score: u64, + vectors: &[MethodFeatureVector], +) -> Result { + let Some((best_label, best_score)) = current_best else { + return Ok(true); + }; + if candidate_score != best_score { + return Ok(candidate_score > best_score); + } + Ok(label_order_key(candidate_label, vectors)? < label_order_key(best_label, vectors)?) +} + +/// Return the lexical method-name and label-index tie-break key. +/// +/// # Errors +/// +/// Returns an error when the label does not identify a method vector. +fn label_order_key( + label: usize, + vectors: &[MethodFeatureVector], +) -> Result<(&str, usize), DecompositionError> { + let vector = vectors + .get(label) + .ok_or(DecompositionError::InvalidNeighbourLabel { + node_count: vectors.len(), + })?; + Ok((vector.method_name(), label)) +} diff --git a/common/src/decomposition_advice/community/tests.rs b/common/src/decomposition_advice/community/tests.rs new file mode 100644 index 000000000..c6a0d4c45 --- /dev/null +++ b/common/src/decomposition_advice/community/tests.rs @@ -0,0 +1,26 @@ +//! Tests for propagation failures that cannot arise from validated graphs. + +use crate::decomposition_advice::{ + community::run_propagation_pass, error::DecompositionError, minimal_feature_vector, +}; + +/// Reject a propagation pass whose active node has no label slot. +#[test] +fn propagation_rejects_an_invalid_label_slot_without_partial_updates() { + let vectors = [ + minimal_feature_vector("alpha"), + minimal_feature_vector("beta"), + minimal_feature_vector("gamma"), + ]; + let adjacency = vec![Vec::new(), Vec::new(), vec![(1, 5)]]; + let active_nodes = [2]; + let mut labels = vec![0, 1]; + + let result = run_propagation_pass(&vectors, &adjacency, &active_nodes, &mut labels); + + assert!(matches!( + result, + Err(DecompositionError::InvalidLabelSlot { node_count: 2 }) + )); + assert_eq!(labels, [0, 1]); +} diff --git a/common/src/decomposition_advice/community_kani/adjacency.rs b/common/src/decomposition_advice/community_kani/adjacency.rs deleted file mode 100644 index b9b3a3eb6..000000000 --- a/common/src/decomposition_advice/community_kani/adjacency.rs +++ /dev/null @@ -1,94 +0,0 @@ -//! Bounded model-checking harnesses for `build_adjacency`. - -use super::shared::{incident_degree, is_edge_in_input, symbolic_adjacency}; - -/// Verifies that `build_adjacency` returns exactly `node_count` buckets. -#[kani::proof] -#[kani::unwind(7)] -fn verify_build_adjacency_length() { - let (node_count, _edges, adjacency) = symbolic_adjacency(); - - assert_eq!(adjacency.len(), node_count); -} - -/// Verifies that every input edge is preserved in both directions. -#[kani::proof] -#[kani::unwind(7)] -fn verify_build_adjacency_preserves_edges() { - let (node_count, edges, adjacency) = symbolic_adjacency(); - kani::assume(node_count > 0); - - for edge in &edges { - let forward_found = adjacency[edge.left] - .iter() - .any(|&(neighbour, weight)| neighbour == edge.right && weight == edge.weight); - assert!(forward_found); - - let reverse_found = adjacency[edge.right] - .iter() - .any(|&(neighbour, weight)| neighbour == edge.left && weight == edge.weight); - assert!(reverse_found); - } -} - -/// Verifies that every neighbour index in every adjacency bucket is within -/// bounds. -#[kani::proof] -#[kani::unwind(7)] -fn verify_build_adjacency_indices_in_bounds() { - let (node_count, _edges, adjacency) = symbolic_adjacency(); - - for bucket in &adjacency { - for &(neighbour, _weight) in bucket { - assert!(neighbour < node_count); - } - } -} - -/// Verifies that adjacency lists are symmetric. -#[kani::proof] -#[kani::unwind(7)] -fn verify_build_adjacency_symmetry() { - let (node_count, _edges, adjacency) = symbolic_adjacency(); - kani::assume(node_count > 0); - - for (node, bucket) in adjacency.iter().enumerate() { - for &(neighbour, weight) in bucket { - let mirror_found = - adjacency[neighbour] - .iter() - .any(|&(mirror_neighbour, mirror_weight)| { - mirror_neighbour == node && mirror_weight == weight - }); - assert!(mirror_found); - } - } -} - -/// Verifies that `build_adjacency` emits only edges present in the input. -#[kani::proof] -#[kani::unwind(7)] -fn verify_build_adjacency_no_spurious_edges() { - let (node_count, edges, adjacency) = symbolic_adjacency(); - kani::assume(node_count > 0); - - for (node, bucket) in adjacency.iter().enumerate() { - assert_eq!(bucket.len(), incident_degree(&edges, node)); - for &(neighbour, weight) in bucket { - assert!(is_edge_in_input(&edges, node, neighbour, weight)); - } - } -} - -/// Verifies that each per-node neighbour list is sorted by neighbour index. -#[kani::proof] -#[kani::unwind(7)] -fn verify_build_adjacency_sorted_neighbours() { - let (_node_count, _edges, adjacency) = symbolic_adjacency(); - - for bucket in &adjacency { - for window in bucket.windows(2) { - assert!(window[0].0 <= window[1].0); - } - } -} diff --git a/common/src/decomposition_advice/community_kani/mod.rs b/common/src/decomposition_advice/community_kani/mod.rs index e2280854a..5549028a2 100644 --- a/common/src/decomposition_advice/community_kani/mod.rs +++ b/common/src/decomposition_advice/community_kani/mod.rs @@ -1,5 +1,4 @@ //! Bounded model-checking harnesses for decomposition-advice community helpers. -mod adjacency; mod propagate_labels; mod shared; diff --git a/common/src/decomposition_advice/community_kani/propagate_labels.rs b/common/src/decomposition_advice/community_kani/propagate_labels.rs index 809dc4ffe..f3a2d2796 100644 --- a/common/src/decomposition_advice/community_kani/propagate_labels.rs +++ b/common/src/decomposition_advice/community_kani/propagate_labels.rs @@ -7,6 +7,7 @@ use super::shared::bounded_iteration_count; const NODE_COUNT: usize = 3; +/// Create fixed vectors with distinct names for deterministic tie-breaking. fn symbolic_vectors() -> [crate::decomposition_advice::vector::MethodFeatureVector; NODE_COUNT] { [ minimal_feature_vector("gamma"), @@ -15,6 +16,12 @@ fn symbolic_vectors() -> [crate::decomposition_advice::vector::MethodFeatureVect ] } +/// Generate a valid graph without ordering rows that propagation reads as sets. +/// +/// Label scores aggregate in a `BTreeMap`, and equal scores use an explicit +/// method-name tie-break. The generated row order therefore does not affect +/// these proofs; sorted rows are checked through production adjacency in the +/// property suite. fn symbolic_adjacency() -> [Vec<(usize, u64)>; NODE_COUNT] { let mut node_0 = Vec::new(); let mut node_1 = Vec::new(); @@ -24,13 +31,10 @@ fn symbolic_adjacency() -> [Vec<(usize, u64)>; NODE_COUNT] { add_symbolic_edge(&mut node_0, 0, &mut node_2, 2); add_symbolic_edge(&mut node_1, 1, &mut node_2, 2); - node_0.sort_by_key(|(neighbour, _)| *neighbour); - node_1.sort_by_key(|(neighbour, _)| *neighbour); - node_2.sort_by_key(|(neighbour, _)| *neighbour); - [node_0, node_1, node_2] } +/// Add a symbolic undirected edge when its activation bit is set. fn add_symbolic_edge( left: &mut Vec<(usize, u64)>, left_index: usize, @@ -55,7 +59,8 @@ fn verify_propagate_labels_returns_vector_per_input() { let adjacency = symbolic_adjacency(); let vectors = symbolic_vectors(); - let report = propagate_labels_report(&vectors, &adjacency, bounded_iteration_count()); + let report = propagate_labels_report(&vectors, &adjacency, bounded_iteration_count()) + .expect("symbolic adjacency rows match the vector count"); assert_eq!(report.labels.len(), NODE_COUNT); } @@ -67,7 +72,8 @@ fn verify_propagate_labels_preserves_label_indices() { let adjacency = symbolic_adjacency(); let vectors = symbolic_vectors(); - let report = propagate_labels_report(&vectors, &adjacency, bounded_iteration_count()); + let report = propagate_labels_report(&vectors, &adjacency, bounded_iteration_count()) + .expect("symbolic adjacency rows match the vector count"); for &label in &report.labels { assert!(label < NODE_COUNT); @@ -81,7 +87,8 @@ fn verify_propagate_labels_zero_iterations_keeps_initial_labels() { let adjacency = symbolic_adjacency(); let vectors = symbolic_vectors(); - let report = propagate_labels_report(&vectors, &adjacency, 0); + let report = propagate_labels_report(&vectors, &adjacency, 0) + .expect("symbolic adjacency rows match the vector count"); assert_eq!(report.iteration_count, 0); for (index, &label) in report.labels.iter().enumerate() { @@ -98,7 +105,8 @@ fn verify_propagate_labels_bounded_return_for_any_max_iterations() { let vectors = symbolic_vectors(); let max_iterations = bounded_iteration_count(); - let report = propagate_labels_report(&vectors, &adjacency, max_iterations); + let report = propagate_labels_report(&vectors, &adjacency, max_iterations) + .expect("symbolic adjacency rows match the vector count"); assert_eq!(report.labels.len(), NODE_COUNT); assert!(report.iteration_count <= max_iterations); diff --git a/common/src/decomposition_advice/community_kani/shared.rs b/common/src/decomposition_advice/community_kani/shared.rs index 8a6e994b4..4e7d2f3b0 100644 --- a/common/src/decomposition_advice/community_kani/shared.rs +++ b/common/src/decomposition_advice/community_kani/shared.rs @@ -1,83 +1,11 @@ -//! Shared bounded symbolic models for decomposition-advice Kani proofs. - -use super::super::{SimilarityEdge, build_adjacency}; - -/// Maximum number of nodes in the bounded model. -pub(super) const MAX_NODES: usize = 3; - -/// Maximum number of edges in the bounded model. -pub(super) const MAX_EDGES: usize = 3; +//! Shared bounded iteration model for decomposition-advice Kani proofs. /// Maximum iteration bound explored by propagation proofs. pub(super) const MAX_ITERATIONS: usize = 3; -/// Generates a symbolic node count constrained to `[0, MAX_NODES]`. -pub(super) fn constrained_node_count() -> usize { - let node_count: usize = kani::any(); - kani::assume(node_count <= MAX_NODES); - node_count -} - /// Generates a symbolic iteration count constrained to `[0, MAX_ITERATIONS]`. pub(super) fn bounded_iteration_count() -> usize { let max_iterations: usize = kani::any(); kani::assume(max_iterations <= MAX_ITERATIONS); max_iterations } - -/// Materializes a concrete `Vec` from symbolic choices, -/// constraining each active edge to the production contract. -pub(super) fn constrained_edges(node_count: usize) -> Vec { - let active_count: usize = kani::any(); - kani::assume(active_count <= MAX_EDGES); - - let mut edges = Vec::new(); - let mut seen = [[false; MAX_NODES]; MAX_NODES]; - - for _ in 0..active_count { - let left: usize = kani::any(); - let right: usize = kani::any(); - let weight: u64 = kani::any(); - - kani::assume(left < right); - kani::assume(right < node_count); - kani::assume(weight > 0); - kani::assume(!seen[left][right]); - - seen[left][right] = true; - edges.push(SimilarityEdge::new(left, right, weight)); - } - - edges -} - -/// Returns `(node_count, edges, adjacency)` from a fully constrained symbolic -/// model. -pub(super) fn symbolic_adjacency() -> (usize, Vec, Vec>) { - let node_count = constrained_node_count(); - let edges = constrained_edges(node_count); - let adjacency = build_adjacency(node_count, &edges); - (node_count, edges, adjacency) -} - -/// Returns the number of input edges incident on `node` (as either endpoint). -pub(super) fn incident_degree(edges: &[SimilarityEdge], node: usize) -> usize { - edges - .iter() - .filter(|edge| edge.left == node || edge.right == node) - .count() -} - -/// Returns `true` when `(node -> neighbour, weight)` is backed by an input -/// edge in either orientation. -pub(super) fn is_edge_in_input( - edges: &[SimilarityEdge], - node: usize, - neighbour: usize, - weight: u64, -) -> bool { - edges.iter().any(|edge| { - (edge.left == node && edge.right == neighbour && edge.weight == weight) - || (edge.right == node && edge.left == neighbour && edge.weight == weight) - }) -} diff --git a/common/src/decomposition_advice/error.rs b/common/src/decomposition_advice/error.rs new file mode 100644 index 000000000..2b48fb959 --- /dev/null +++ b/common/src/decomposition_advice/error.rs @@ -0,0 +1,129 @@ +//! Typed failures for malformed decomposition-analysis state. +//! +//! Keep error reasons finite so diagnostics and metrics remain useful without +//! exposing method names, feature keys, or other unbounded values. + +/// Identifies invalid graph or feature state rejected during advice analysis. +#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)] +pub(crate) enum DecompositionError { + /// An edge endpoint lies outside the graph's node range. + #[error("similarity edge endpoint is outside the graph's node range")] + InvalidEdgeEndpoint { + /// Number of nodes expected in the graph. + node_count: usize, + /// Number of edges supplied for validation. + edge_count: usize, + }, + /// The adjacency row count differs from the node count. + #[error("adjacency row count differs from the node count")] + AdjacencyRowCountMismatch { + /// Number of graph nodes expected by propagation. + expected_rows: usize, + /// Number of adjacency rows supplied by the caller. + actual_rows: usize, + }, + /// A neighbour cannot be mapped to an existing label. + #[error("neighbour index is outside the label range")] + InvalidNeighbourLabel { + /// Number of labels available to propagation. + node_count: usize, + }, + /// A node cannot be mapped to an existing label slot. + #[error("node index is outside the label range")] + InvalidLabelSlot { + /// Number of labels available to propagation. + node_count: usize, + }, + /// A community contains an index outside the feature-vector range. + #[error("community index is outside the feature-vector range")] + InvalidCommunityIndex { + /// Number of feature vectors available to the suggestion builder. + vector_count: usize, + /// Number of node indices in the malformed community. + community_count: usize, + }, + /// A feature weight has no corresponding display metadata. + #[error("feature metadata is missing for a weighted feature")] + MissingFeatureMetadata { + /// Number of feature vectors considered during aggregation. + vector_count: usize, + }, +} + +impl DecompositionError { + /// Return finite metric labels and bounded context for this failure. + const fn context(&self) -> (&'static str, &'static str, Option, Option) { + match self { + Self::InvalidEdgeEndpoint { + node_count, + edge_count, + } => ( + "build_adjacency", + "invalid_edge_endpoint", + Some(*node_count), + Some(*edge_count), + ), + Self::AdjacencyRowCountMismatch { + expected_rows, + actual_rows, + } => ( + "propagate_labels", + "adjacency_row_count_mismatch", + Some(*expected_rows), + Some(*actual_rows), + ), + Self::InvalidNeighbourLabel { node_count } => ( + "propagate_labels", + "invalid_neighbour_label", + Some(*node_count), + None, + ), + Self::InvalidLabelSlot { node_count } => ( + "propagate_labels", + "invalid_label_slot", + Some(*node_count), + None, + ), + Self::InvalidCommunityIndex { + vector_count, + community_count, + } => ( + "build_suggestion", + "invalid_community_index", + Some(*vector_count), + Some(*community_count), + ), + Self::MissingFeatureMetadata { vector_count } => ( + "aggregate_features", + "missing_feature_metadata", + Some(*vector_count), + None, + ), + } + } +} + +/// Emit a warning and fixed-cardinality counter for rejected analysis state. +pub(crate) fn report_decomposition_error(error: &DecompositionError) { + let (operation, reason, node_count, item_count) = error.context(); + metrics::describe_counter!( + "whitaker.decomposition.invalid_state", + "Invalid decomposition analysis state rejected before advice is rendered" + ); + metrics::counter!( + "whitaker.decomposition.invalid_state", + "operation" => operation, + "reason" => reason + ) + .increment(1); + tracing::warn!( + operation, + reason, + node_count = ?node_count, + item_count = ?item_count, + "decomposition analysis rejected invalid state" + ); +} + +#[cfg(test)] +mod tests; diff --git a/common/src/decomposition_advice/error/tests.rs b/common/src/decomposition_advice/error/tests.rs new file mode 100644 index 000000000..3b7e76616 --- /dev/null +++ b/common/src/decomposition_advice/error/tests.rs @@ -0,0 +1,37 @@ +//! Tests for the bounded observability context attached to analysis errors. + +use super::DecompositionError; +use rstest::rstest; + +/// Keep every error mapped to a finite operation and reason pair. +#[rstest] +#[case( + DecompositionError::InvalidEdgeEndpoint { node_count: 4, edge_count: 3 }, + ("build_adjacency", "invalid_edge_endpoint", Some(4), Some(3)) +)] +#[case( + DecompositionError::AdjacencyRowCountMismatch { expected_rows: 4, actual_rows: 3 }, + ("propagate_labels", "adjacency_row_count_mismatch", Some(4), Some(3)) +)] +#[case( + DecompositionError::InvalidNeighbourLabel { node_count: 4 }, + ("propagate_labels", "invalid_neighbour_label", Some(4), None) +)] +#[case( + DecompositionError::InvalidLabelSlot { node_count: 4 }, + ("propagate_labels", "invalid_label_slot", Some(4), None) +)] +#[case( + DecompositionError::InvalidCommunityIndex { vector_count: 4, community_count: 3 }, + ("build_suggestion", "invalid_community_index", Some(4), Some(3)) +)] +#[case( + DecompositionError::MissingFeatureMetadata { vector_count: 4 }, + ("aggregate_features", "missing_feature_metadata", Some(4), None) +)] +fn errors_use_bounded_observability_labels( + #[case] error: DecompositionError, + #[case] expected: (&'static str, &'static str, Option, Option), +) { + assert_eq!(error.context(), expected); +} diff --git a/common/src/decomposition_advice/graph.rs b/common/src/decomposition_advice/graph.rs new file mode 100644 index 000000000..578c6df85 --- /dev/null +++ b/common/src/decomposition_advice/graph.rs @@ -0,0 +1,77 @@ +//! Build weighted similarity edges for decomposition community detection. +//! +//! Keep this module private to decomposition advice. It owns vector-pair edge +//! construction; adjacency storage and propagation remain in `community`. + +use super::vector::{ + MIN_COSINE_THRESHOLD_DENOMINATOR_SQUARED, MIN_COSINE_THRESHOLD_NUMERATOR_SQUARED, + MethodFeatureVector, cosine_threshold_met, dot_product, +}; + +/// Weighted connection between two method feature vectors. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct SimilarityEdge { + pub(super) left: usize, + pub(super) right: usize, + pub(super) weight: u64, +} + +impl SimilarityEdge { + /// Create an edge with its node endpoints and similarity weight. + pub(crate) const fn new(left: usize, right: usize, weight: u64) -> Self { + Self { + left, + right, + weight, + } + } + + /// Return the left endpoint. + #[cfg(test)] + pub(crate) const fn left(&self) -> usize { + self.left + } + + /// Return the right endpoint. + #[cfg(test)] + pub(crate) const fn right(&self) -> usize { + self.right + } + + /// Return the edge weight. + #[cfg(test)] + pub(crate) const fn weight(&self) -> u64 { + self.weight + } +} + +/// Connect vector pairs that meet the configured cosine similarity threshold. +pub(crate) fn build_similarity_edges(vectors: &[MethodFeatureVector]) -> Vec { + let mut edges = Vec::new(); + + for (left, left_vector) in vectors.iter().enumerate() { + for (right, right_vector) in vectors.iter().enumerate().skip(left + 1) { + if !cosine_threshold_met( + left_vector, + right_vector, + MIN_COSINE_THRESHOLD_NUMERATOR_SQUARED, + MIN_COSINE_THRESHOLD_DENOMINATOR_SQUARED, + ) { + continue; + } + + let weight = dot_product(left_vector.weights(), right_vector.weights()); + if weight == 0 { + continue; + } + + edges.push(SimilarityEdge { + left, + right, + weight, + }); + } + } + + edges +} diff --git a/common/src/decomposition_advice/mod.rs b/common/src/decomposition_advice/mod.rs index e4966aac8..5bf4fbb59 100644 --- a/common/src/decomposition_advice/mod.rs +++ b/common/src/decomposition_advice/mod.rs @@ -8,6 +8,8 @@ //! `rustc_private` dependencies to `common`. pub(crate) mod community; +mod error; +mod graph; mod note; mod profile; mod suggestion; diff --git a/common/src/decomposition_advice/note.rs b/common/src/decomposition_advice/note.rs index 3f24bfa73..ad2664696 100644 --- a/common/src/decomposition_advice/note.rs +++ b/common/src/decomposition_advice/note.rs @@ -63,13 +63,17 @@ pub fn format_diagnostic_note( return None; } - let visible_suggestions = &suggestions[..suggestions.len().min(MAX_SUGGESTIONS)]; let mut lines = vec![format!( "Potential decomposition for `{}`:", context.subject_name() )]; - lines.extend(visible_suggestions.iter().map(render_suggestion_line)); + lines.extend( + suggestions + .iter() + .take(MAX_SUGGESTIONS) + .map(render_suggestion_line), + ); let omitted_suggestions = suggestions.len().saturating_sub(MAX_SUGGESTIONS); if omitted_suggestions > 0 { @@ -99,17 +103,17 @@ fn render_suggestion_line(suggestion: &DecompositionSuggestion) -> String { } fn render_method_list(methods: &[String]) -> String { - let visible_methods = &methods[..methods.len().min(MAX_METHODS_PER_SUGGESTION)]; - let mut rendered = visible_methods + let rendered = methods .iter() + .take(MAX_METHODS_PER_SUGGESTION) .map(|method| format!("`{method}`")) .collect::>() .join(", "); let omitted_methods = methods.len().saturating_sub(MAX_METHODS_PER_SUGGESTION); if omitted_methods > 0 { - rendered.push_str(&format!(", +{omitted_methods} more methods")); + format!("{rendered}, +{omitted_methods} more methods") + } else { + rendered } - - rendered } diff --git a/common/src/decomposition_advice/profile.rs b/common/src/decomposition_advice/profile.rs index 65d6bc89e..1a304c04a 100644 --- a/common/src/decomposition_advice/profile.rs +++ b/common/src/decomposition_advice/profile.rs @@ -67,7 +67,7 @@ impl DecompositionContext { /// Returns the analysed subject kind. #[must_use] - pub fn subject_kind(&self) -> SubjectKind { + pub const fn subject_kind(&self) -> SubjectKind { self.subject_kind } } @@ -112,25 +112,25 @@ impl MethodProfile { /// Returns accessed fields. #[must_use] - pub fn accessed_fields(&self) -> &BTreeSet { + pub const fn accessed_fields(&self) -> &BTreeSet { &self.accessed_fields } /// Returns types used in the method signature. #[must_use] - pub fn signature_types(&self) -> &BTreeSet { + pub const fn signature_types(&self) -> &BTreeSet { &self.signature_types } /// Returns types used in local variables. #[must_use] - pub fn local_types(&self) -> &BTreeSet { + pub const fn local_types(&self) -> &BTreeSet { &self.local_types } /// Returns external domains used by the method. #[must_use] - pub fn external_domains(&self) -> &BTreeSet { + pub const fn external_domains(&self) -> &BTreeSet { &self.external_domains } } diff --git a/common/src/decomposition_advice/suggestion.rs b/common/src/decomposition_advice/suggestion.rs index 32f75910f..a1dba9d4e 100644 --- a/common/src/decomposition_advice/suggestion.rs +++ b/common/src/decomposition_advice/suggestion.rs @@ -3,6 +3,7 @@ use std::collections::BTreeMap; use super::community::detect_communities; +use super::error::{DecompositionError, report_decomposition_error}; use super::profile::{DecompositionContext, MethodProfile, SubjectKind}; use super::vector::{FeatureCategory, MethodFeatureVector, build_feature_vector}; @@ -31,6 +32,7 @@ pub enum SuggestedExtractionKind { impl std::str::FromStr for SuggestedExtractionKind { type Err = String; + /// Parse a supported extraction-kind label. fn from_str(s: &str) -> Result { match s { "helper struct" => Ok(Self::HelperStruct), @@ -42,6 +44,7 @@ impl std::str::FromStr for SuggestedExtractionKind { } impl std::fmt::Display for SuggestedExtractionKind { + /// Format the stable extraction-kind label. fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(match self { Self::HelperStruct => "helper struct", @@ -92,25 +95,25 @@ pub struct DecompositionSuggestion { } impl DecompositionSuggestion { - /// Returns the community label. + /// Return the community label. #[must_use] pub fn label(&self) -> &str { &self.label } - /// Returns the suggested extraction kind. + /// Return the suggested extraction kind. #[must_use] - pub fn extraction_kind(&self) -> SuggestedExtractionKind { + pub const fn extraction_kind(&self) -> SuggestedExtractionKind { self.extraction_kind } - /// Returns method names in the community. + /// Return method names in the community. #[must_use] pub fn methods(&self) -> &[String] { &self.methods } - /// Returns the dominant features that motivated the suggestion. + /// Return the dominant features that motivated the suggestion. #[must_use] pub fn rationale(&self) -> &[String] { &self.rationale @@ -124,10 +127,12 @@ struct AggregatedFeature { score: u64, } -/// Generates decomposition suggestions for the provided methods. +/// Generate decomposition suggestions for the provided methods. /// /// Returns an empty vector when the method set does not yield at least two -/// non-singleton communities. +/// non-singleton communities. Invalid intermediate graph or feature state is +/// logged and metered, and also returns an empty vector instead of partial +/// advice. /// /// # Examples /// @@ -155,24 +160,40 @@ pub fn suggest_decomposition( context: &DecompositionContext, methods: &[MethodProfile], ) -> Vec { + match try_suggest_decomposition(context, methods) { + Ok(suggestions) => suggestions, + Err(error) => { + report_decomposition_error(&error); + Vec::new() + } + } +} + +/// Build all suggestions or return the first malformed intermediate state. +/// +/// # Errors +/// +/// Returns an error when community detection or feature aggregation receives +/// inconsistent graph or feature metadata. +fn try_suggest_decomposition( + context: &DecompositionContext, + methods: &[MethodProfile], +) -> Result, DecompositionError> { let mut vectors: Vec<_> = methods.iter().map(build_feature_vector).collect(); vectors.sort(); - let communities: Vec<_> = detect_communities(&vectors) + let communities: Vec<_> = detect_communities(&vectors)? .into_iter() .filter(|community| community.len() > 1) .collect(); if communities.len() < 2 { - return Vec::new(); + return Ok(Vec::new()); } - let mut suggestions: Vec<_> = communities - .iter() - .filter_map(|community| build_suggestion(context, community, &vectors)) - .collect(); + let mut suggestions = build_suggestions(context, &communities, &vectors)?; if suggestions.len() < 2 { - return Vec::new(); + return Ok(Vec::new()); } suggestions.sort_by(|left, right| { @@ -182,38 +203,90 @@ pub fn suggest_decomposition( .cmp(&left.methods().len()) .then_with(|| left.label().cmp(right.label())) }); - suggestions + Ok(suggestions) +} + +/// Build suggestions for every community without returning partial output. +/// +/// # Errors +/// +/// Returns an error when a community index or feature metadata entry is +/// invalid. +fn build_suggestions( + context: &DecompositionContext, + communities: &[Vec], + vectors: &[MethodFeatureVector], +) -> Result, DecompositionError> { + let mut suggestions = Vec::new(); + for community in communities { + if let Some(suggestion) = build_suggestion(context, community, vectors)? { + suggestions.push(suggestion); + } + } + Ok(suggestions) } +/// Render one community when it has a suitable extraction label. +/// +/// # Errors +/// +/// Returns an error when the community references a missing vector or feature +/// metadata entry. fn build_suggestion( context: &DecompositionContext, community: &[usize], vectors: &[MethodFeatureVector], -) -> Option { - let aggregated = aggregate_features(community, vectors); - let label_feature = choose_label_feature(&aggregated)?; +) -> Result, DecompositionError> { + let aggregated = aggregate_features(community, vectors)?; + let Some(label_feature) = choose_label_feature(&aggregated) else { + return Ok(None); + }; let rationale = choose_rationale(&aggregated); - Some(DecompositionSuggestion { + let mut methods = Vec::with_capacity(community.len()); + for &index in community { + let Some(vector) = vectors.get(index) else { + return Err(DecompositionError::InvalidCommunityIndex { + vector_count: vectors.len(), + community_count: community.len(), + }); + }; + methods.push(vector.method_name().to_owned()); + } + + Ok(Some(DecompositionSuggestion { label: label_feature.display.clone(), extraction_kind: infer_extraction_kind(context.subject_kind(), label_feature.category), - methods: community - .iter() - .map(|index| vectors[*index].method_name().to_owned()) - .collect(), + methods, rationale, - }) + })) } +/// Sum feature weights for a community while validating its source metadata. +/// +/// # Errors +/// +/// Returns an error when a community index or weighted feature metadata is +/// missing. fn aggregate_features( community: &[usize], vectors: &[MethodFeatureVector], -) -> Vec { +) -> Result, DecompositionError> { let mut aggregated: BTreeMap = BTreeMap::new(); - for method_index in community { - for (feature_key, weight) in vectors[*method_index].weights() { - let metadata = &vectors[*method_index].metadata()[feature_key]; + for &method_index in community { + let Some(vector) = vectors.get(method_index) else { + return Err(DecompositionError::InvalidCommunityIndex { + vector_count: vectors.len(), + community_count: community.len(), + }); + }; + for (feature_key, weight) in vector.weights() { + let Some(metadata) = vector.metadata().get(feature_key) else { + return Err(DecompositionError::MissingFeatureMetadata { + vector_count: vectors.len(), + }); + }; let entry = aggregated .entry(feature_key.clone()) @@ -226,9 +299,10 @@ fn aggregate_features( } } - aggregated.into_values().collect() + Ok(aggregated.into_values().collect()) } +/// Select the highest-priority feature category and its strongest label. fn choose_label_feature(features: &[AggregatedFeature]) -> Option<&AggregatedFeature> { const LABEL_PRIORITIES: &[FeatureCategory] = &[ FeatureCategory::Domain, @@ -238,28 +312,20 @@ fn choose_label_feature(features: &[AggregatedFeature]) -> Option<&AggregatedFea FeatureCategory::LocalType, ]; - for category in LABEL_PRIORITIES { - let mut matches: Vec<_> = features + LABEL_PRIORITIES.iter().find_map(|category| { + features .iter() .filter(|feature| feature.category == *category) - .collect(); - - if matches.is_empty() { - continue; - } - - matches.sort_by(|left, right| { - right - .score - .cmp(&left.score) - .then_with(|| left.display.cmp(&right.display)) - }); - return Some(matches[0]); - } - - None + .min_by(|left, right| { + right + .score + .cmp(&left.score) + .then_with(|| left.display.cmp(&right.display)) + }) + }) } +/// Return the three strongest features in deterministic display order. fn choose_rationale(features: &[AggregatedFeature]) -> Vec { let mut ordered: Vec<_> = features.iter().collect(); ordered.sort_by(|left, right| { @@ -281,6 +347,7 @@ fn choose_rationale(features: &[AggregatedFeature]) -> Vec { .collect() } +/// Choose an extraction shape from the subject and selected feature category. fn infer_extraction_kind( subject_kind: SubjectKind, label_category: FeatureCategory, @@ -293,3 +360,6 @@ fn infer_extraction_kind( SubjectKind::Type => SuggestedExtractionKind::HelperStruct, } } + +#[cfg(test)] +mod tests; diff --git a/common/src/decomposition_advice/suggestion/tests.rs b/common/src/decomposition_advice/suggestion/tests.rs new file mode 100644 index 000000000..3e0d770a0 --- /dev/null +++ b/common/src/decomposition_advice/suggestion/tests.rs @@ -0,0 +1,66 @@ +//! Tests for all-or-nothing suggestion construction on malformed state. + +use super::{build_suggestion, build_suggestions}; +use crate::decomposition_advice::{ + DecompositionContext, MethodProfileBuilder, SubjectKind, + error::DecompositionError, + vector::{build_feature_vector, minimal_feature_vector, test_feature_vector}, +}; + +/// Construct a feature vector with internally consistent metadata. +fn valid_vector() -> crate::decomposition_advice::vector::MethodFeatureVector { + let mut profile = MethodProfileBuilder::new("parse_config"); + profile.record_accessed_field("grammar"); + build_feature_vector(&profile.build()) +} + +/// Return a representative type context for suggestion rendering. +fn context() -> DecompositionContext { + DecompositionContext::new("Parser", SubjectKind::Type) +} + +/// Reject a community containing an index outside the feature-vector set. +#[test] +fn build_suggestion_rejects_invalid_community_index() { + let vectors = [minimal_feature_vector("parse_config")]; + + let result = build_suggestion(&context(), &[0, 1], &vectors); + + assert!(matches!( + result, + Err(DecompositionError::InvalidCommunityIndex { + vector_count: 1, + community_count: 2, + }) + )); +} + +/// Reject weighted features that have no display metadata. +#[test] +fn build_suggestion_rejects_missing_feature_metadata() { + let vectors = [test_feature_vector("parse_config", &[("field:grammar", 6)])]; + + let result = build_suggestion(&context(), &[0], &vectors); + + assert!(matches!( + result, + Err(DecompositionError::MissingFeatureMetadata { vector_count: 1 }) + )); +} + +/// Return no partial suggestions when a later community is malformed. +#[test] +fn build_suggestions_discards_earlier_valid_communities_on_error() { + let vectors = [ + valid_vector(), + test_feature_vector("write_file", &[("domain:std::fs", 5)]), + ]; + let communities = [vec![0], vec![1]]; + + let result = build_suggestions(&context(), &communities, &vectors); + + assert!(matches!( + result, + Err(DecompositionError::MissingFeatureMetadata { vector_count: 2 }) + )); +} diff --git a/common/src/decomposition_advice/tests/adjacency.rs b/common/src/decomposition_advice/tests/adjacency.rs index c69a72c18..56d5315bc 100644 --- a/common/src/decomposition_advice/tests/adjacency.rs +++ b/common/src/decomposition_advice/tests/adjacency.rs @@ -5,16 +5,27 @@ //! adjacency lists, and sorts each per-node neighbour list by neighbour //! index. -use super::super::community::{SimilarityEdge, build_adjacency}; +use super::super::{ + community::{SimilarityEdge, build_adjacency}, + error::DecompositionError, +}; +use proptest::{prelude::*, test_runner::TestRunner}; +use std::collections::BTreeSet; -/// Convenience constructor for test `SimilarityEdge` values. +/// Create a test edge with the supplied endpoints and weight. fn edge(left: usize, right: usize, weight: u64) -> SimilarityEdge { SimilarityEdge::new(left, right, weight) } +/// Build a valid test graph and explain invalid fixture endpoints. +fn adjacency(node_count: usize, edges: &[SimilarityEdge]) -> Vec> { + build_adjacency(node_count, edges).expect("test edges should be within node bounds") +} + +/// Preserve the requested number of isolated adjacency rows. #[test] fn empty_edges_yield_empty_neighbour_lists() { - let adjacency = build_adjacency(3, &[]); + let adjacency = adjacency(3, &[]); assert_eq!(adjacency.len(), 3); for bucket in &adjacency { @@ -22,9 +33,10 @@ fn empty_edges_yield_empty_neighbour_lists() { } } +/// Insert each valid edge in both endpoint rows. #[test] fn single_edge_inserted_in_both_directions() { - let adjacency = build_adjacency(3, &[edge(0, 2, 10)]); + let adjacency = adjacency(3, &[edge(0, 2, 10)]); assert_eq!(adjacency.len(), 3); assert_eq!(adjacency.first(), Some(&vec![(2, 10)])); @@ -32,20 +44,78 @@ fn single_edge_inserted_in_both_directions() { assert_eq!(adjacency.get(2), Some(&vec![(0, 10)])); } +/// Sort each neighbour row by node index. #[test] fn multiple_edges_produce_sorted_neighbour_lists() { // Node 1 connects to 0, 2, and 3. let edges = vec![edge(0, 1, 5), edge(1, 2, 8), edge(1, 3, 3)]; - let adjacency = build_adjacency(4, &edges); + let adjacency = adjacency(4, &edges); // Node 1's neighbours should be sorted by neighbour index. assert_eq!(adjacency.get(1), Some(&vec![(0, 5), (2, 8), (3, 3)])); } +/// Check graph invariants for generated valid similarity edges. +#[test] +fn generated_valid_graphs_satisfy_adjacency_contract() { + let strategy = ( + 0usize..=8, + prop::collection::vec((any::(), any::(), any::()), 0..24), + ); + let mut runner = TestRunner::default(); + + runner + .run(&strategy, |(node_count, raw_edges)| { + let edges: Vec<_> = if node_count == 0 { + Vec::new() + } else { + let mut seen = BTreeSet::new(); + raw_edges + .into_iter() + .filter_map(|(left, right, weight)| { + let left = left % node_count; + let right = right % node_count; + let endpoints = (left.min(right), left.max(right)); + (left != right && seen.insert(endpoints)) + .then(|| edge(endpoints.0, endpoints.1, weight)) + }) + .collect() + }; + let adjacency = adjacency(node_count, &edges); + let emitted_entries: usize = adjacency.iter().map(Vec::len).sum(); + let expected_entries: BTreeSet<(usize, usize, u64)> = edges + .iter() + .flat_map(|edge| { + [ + (edge.left(), edge.right(), edge.weight()), + (edge.right(), edge.left(), edge.weight()), + ] + }) + .collect(); + let mut actual_entries = BTreeSet::new(); + + prop_assert_eq!(adjacency.len(), node_count); + prop_assert_eq!(emitted_entries, edges.len() * 2); + + for (node, bucket) in adjacency.iter().enumerate() { + prop_assert!(bucket.windows(2).all(|window| window[0].0 <= window[1].0)); + + for &(neighbour, weight) in bucket { + actual_entries.insert((node, neighbour, weight)); + } + } + + prop_assert_eq!(actual_entries, expected_entries); + Ok(()) + }) + .expect("generated valid graph cases should satisfy adjacency invariants"); +} + +/// Keep isolated nodes alongside connected components. #[test] fn sparse_graph_preserves_isolated_nodes() { // Nodes 1 and 3 are isolated; only 0-2 has an edge. - let adjacency = build_adjacency(4, &[edge(0, 2, 7)]); + let adjacency = adjacency(4, &[edge(0, 2, 7)]); assert_eq!(adjacency.len(), 4); assert_eq!(adjacency.first(), Some(&vec![(2, 7)])); @@ -54,10 +124,11 @@ fn sparse_graph_preserves_isolated_nodes() { assert_eq!(adjacency.get(3), Some(&Vec::new())); } +/// Preserve symmetry for every valid input edge. #[test] fn multi_edge_graph_is_symmetric() { let edges = vec![edge(0, 1, 4), edge(2, 3, 9)]; - let adjacency = build_adjacency(4, &edges); + let adjacency = adjacency(4, &edges); // Every (node -> neighbour, weight) pair has its mirror. for (node, bucket) in adjacency.iter().enumerate() { @@ -74,3 +145,17 @@ fn multi_edge_graph_is_symmetric() { } } } + +/// Reject an invalid endpoint before returning any adjacency output. +#[test] +fn invalid_endpoint_rejects_the_complete_adjacency() { + let result = build_adjacency(3, &[edge(0, 1, 5), edge(1, 3, 7)]); + + assert!(matches!( + result, + Err(DecompositionError::InvalidEdgeEndpoint { + node_count: 3, + edge_count: 2, + }) + )); +} diff --git a/common/src/decomposition_advice/tests/mod.rs b/common/src/decomposition_advice/tests/mod.rs index e7f1cd8cd..81a2c0577 100644 --- a/common/src/decomposition_advice/tests/mod.rs +++ b/common/src/decomposition_advice/tests/mod.rs @@ -19,12 +19,14 @@ use super::{ vector::{build_feature_vector, dot_product, identifier_keywords}, }; +/// Split camel-case identifiers and discard stop words. #[test] fn identifier_keywords_split_camel_case_and_remove_stop_words() { let keywords = identifier_keywords("buildRenderTree"); assert_eq!(keywords, ["tree"]); } +/// Accept both documented spellings of the sub-trait extraction kind. #[test] fn suggested_extraction_kind_accepts_both_sub_trait_spellings() { assert_eq!( @@ -38,6 +40,7 @@ fn suggested_extraction_kind_accepts_both_sub_trait_spellings() { assert_eq!(SuggestedExtractionKind::SubTrait.to_string(), "sub-trait"); } +/// Split acronym boundaries in mixed-case identifiers. #[test] fn identifier_keywords_handle_acronyms_and_mixed_case() { assert_eq!(identifier_keywords("HTTPRequest"), ["http", "request"]); @@ -47,18 +50,21 @@ fn identifier_keywords_handle_acronyms_and_mixed_case() { ); } +/// Split identifiers at non-alphanumeric separators. #[test] fn identifier_keywords_split_on_non_alphanumeric_separators() { assert_eq!(identifier_keywords("build_render_tree"), ["tree"]); assert_eq!(identifier_keywords("build-render-tree"), ["tree"]); } +/// Remove stop words and handle empty identifiers. #[test] fn identifier_keywords_only_stop_words_or_empty_input_yield_empty() { assert!(identifier_keywords("build_render").is_empty()); assert!(identifier_keywords("").is_empty()); } +/// Prefix feature keys with their category names. #[test] fn feature_vector_prefixes_categories() { let vector = build_feature_vector(&profile(MethodInput { @@ -73,6 +79,7 @@ fn feature_vector_prefixes_categories() { assert!(vector.weights().contains_key("keyword:state")); } +/// Return zero for feature vectors with disjoint keys. #[test] fn dot_product_is_zero_for_disjoint_profiles() { let left = build_feature_vector(&profile(MethodInput { @@ -93,6 +100,7 @@ fn dot_product_is_zero_for_disjoint_profiles() { assert_eq!(dot_product(left.weights(), right.weights()), 0); } +/// Connect only profiles that share enough feature weight. #[test] fn similarity_edges_include_related_methods_only() { let vectors = vec![ @@ -129,6 +137,7 @@ fn similarity_edges_include_related_methods_only() { assert!(edge.weight() > 0); } +/// Preserve community output when method input order changes. #[test] fn detect_communities_is_order_invariant() { let fixture = parser_serde_fs_fixture(); @@ -154,11 +163,12 @@ fn detect_communities_is_order_invariant() { reordered_vectors.sort(); assert_eq!( - detect_communities(&original_vectors), - detect_communities(&reordered_vectors) + detect_communities(&original_vectors).expect("original vectors form a valid graph"), + detect_communities(&reordered_vectors).expect("reordered vectors form a valid graph") ); } +/// Return no advice when methods form fewer than two communities. #[test] fn suggest_decomposition_returns_empty_for_single_community() { assert_type_decomposition_is_empty( @@ -196,6 +206,7 @@ fn suggest_decomposition_returns_empty_for_single_community() { ); } +/// Choose module and helper-struct advice for type communities. #[test] fn suggest_decomposition_for_type_prefers_domain_module_and_field_helper_struct() { let context = DecompositionContext::new("Foo", SubjectKind::Type); @@ -233,6 +244,7 @@ fn suggest_decomposition_for_type_prefers_domain_module_and_field_helper_struct( ); } +/// Suggest focused sub-traits for trait communities. #[test] fn suggest_decomposition_for_trait_returns_sub_trait_suggestions() { let context = DecompositionContext::new("Transport", SubjectKind::Trait); @@ -277,6 +289,7 @@ fn suggest_decomposition_for_trait_returns_sub_trait_suggestions() { ); } +/// Preserve suggestions when duplicate method names arrive in a new order. #[test] fn suggest_decomposition_is_order_invariant_for_duplicate_method_names() { let context = DecompositionContext::new("Importer", SubjectKind::Type); @@ -325,6 +338,7 @@ fn suggest_decomposition_is_order_invariant_for_duplicate_method_names() { ); } +/// Exclude singleton methods that do not belong to a useful group. #[test] fn suggestions_drop_singleton_noise_methods() { let context = DecompositionContext::new("Foo", SubjectKind::Type); @@ -347,6 +361,7 @@ fn suggestions_drop_singleton_noise_methods() { ); } +/// Drop groups that have no extractable feature label. #[test] fn suggestions_skip_degenerate_groups_without_features() { assert_type_decomposition_is_empty( diff --git a/common/src/decomposition_advice/tests/propagation.rs b/common/src/decomposition_advice/tests/propagation.rs index 4240402c2..3e4619ccc 100644 --- a/common/src/decomposition_advice/tests/propagation.rs +++ b/common/src/decomposition_advice/tests/propagation.rs @@ -5,10 +5,12 @@ use whitaker_test_macros::allow_fixture_expansion_lints; use crate::decomposition_advice::{ community::{SimilarityEdge, build_adjacency, detect_communities, propagate_labels_report}, + error::DecompositionError, minimal_feature_vector, vector::{MethodFeatureVector, test_feature_vector}, }; +/// Create minimal vectors in the supplied method-name order. fn vectors(method_names: &[&str]) -> Vec { method_names .iter() @@ -16,62 +18,78 @@ fn vectors(method_names: &[&str]) -> Vec { .collect() } +/// Create a weighted edge for propagation fixtures. fn edge(left: usize, right: usize, weight: u64) -> SimilarityEdge { SimilarityEdge::new(left, right, weight) } +/// Build a valid propagation fixture graph from checked edges. +fn adjacency(node_count: usize, edges: &[SimilarityEdge]) -> Vec> { + build_adjacency(node_count, edges).expect("test edges should be within node bounds") +} + +/// Return three named vectors for the connected-triplet cases. #[allow_fixture_expansion_lints] #[fixture] fn connected_triplet_vectors() -> Vec { vectors(&["alpha", "beta", "gamma"]) } +/// Return a three-node path adjacency graph. #[fixture] fn connected_triplet_adjacency() -> Vec> { - build_adjacency(3, &[edge(0, 1, 5), edge(1, 2, 5)]) + adjacency(3, &[edge(0, 1, 5), edge(1, 2, 5)]) } +/// Return four named vectors for the linear-quartet cases. #[fixture] fn linear_quartet_vectors() -> Vec { vectors(&["alpha", "beta", "gamma", "delta"]) } +/// Return a four-node path adjacency graph. #[fixture] fn linear_quartet_adjacency() -> Vec> { - build_adjacency(4, &[edge(0, 1, 5), edge(1, 2, 5), edge(2, 3, 5)]) + adjacency(4, &[edge(0, 1, 5), edge(1, 2, 5), edge(2, 3, 5)]) } +/// Return three named vectors for isolated-tail cases. #[allow_fixture_expansion_lints] #[fixture] fn isolated_tail_vectors() -> Vec { vectors(&["alpha", "beta", "gamma"]) } +/// Return an edge and one isolated node. #[allow_fixture_expansion_lints] #[fixture] fn isolated_tail_adjacency() -> Vec> { - build_adjacency(3, &[edge(0, 1, 5)]) + adjacency(3, &[edge(0, 1, 5)]) } +/// Return names whose lexical order breaks a propagation tie. #[allow_fixture_expansion_lints] #[fixture] fn lexical_tie_vectors() -> Vec { vectors(&["gamma", "alpha", "beta"]) } +/// Return a star graph for lexical tie-breaking cases. #[fixture] fn lexical_tie_adjacency() -> Vec> { - build_adjacency(3, &[edge(0, 1, 5), edge(0, 2, 5)]) + adjacency(3, &[edge(0, 1, 5), edge(0, 2, 5)]) } +/// Return names that make bounded propagation change across passes. #[fixture] fn non_converged_vectors() -> Vec { vectors(&["delta", "charlie", "beta", "alpha"]) } +/// Return a four-node path for bounded propagation cases. #[fixture] fn non_converged_adjacency() -> Vec> { - build_adjacency(4, &[edge(0, 1, 5), edge(1, 2, 5), edge(2, 3, 5)]) + adjacency(4, &[edge(0, 1, 5), edge(1, 2, 5), edge(2, 3, 5)]) } #[derive(Debug)] @@ -81,6 +99,7 @@ struct ConnectedTripletExpectation { expected_iteration_count: usize, } +/// Report one label per vector for both zero and positive iteration bounds. #[rstest] #[case(3)] #[case(0)] @@ -93,11 +112,13 @@ fn propagate_labels_reports_expected_length( &connected_triplet_vectors, &connected_triplet_adjacency, iteration_bound, - ); + ) + .expect("fixture adjacency should be valid"); assert_eq!(report.labels.len(), connected_triplet_vectors.len()); } +/// Keep every propagated label inside the vector index range. #[rstest] #[case(4)] #[case(1)] @@ -110,7 +131,8 @@ fn propagate_labels_keeps_labels_in_range( &linear_quartet_vectors, &linear_quartet_adjacency, iteration_bound, - ); + ) + .expect("fixture adjacency should be valid"); assert!( report @@ -120,16 +142,19 @@ fn propagate_labels_keeps_labels_in_range( ); } +/// Preserve the initial label for nodes without incident edges. #[rstest] fn propagate_labels_leaves_isolated_nodes_with_original_labels( isolated_tail_vectors: Vec, isolated_tail_adjacency: Vec>, ) { - let report = propagate_labels_report(&isolated_tail_vectors, &isolated_tail_adjacency, 3); + let report = propagate_labels_report(&isolated_tail_vectors, &isolated_tail_adjacency, 3) + .expect("fixture adjacency should be valid"); assert_eq!(report.labels.get(2), Some(&2)); } +/// Match deterministic labels and pass counts for connected triplets. #[rstest] #[case(ConnectedTripletExpectation { iteration_bound: 0, @@ -150,58 +175,68 @@ fn propagate_labels_matches_expected_report_for_connected_triplet( &connected_triplet_vectors, &connected_triplet_adjacency, expected.iteration_bound, - ); + ) + .expect("fixture adjacency should be valid"); assert_eq!(report.labels, expected.expected_labels); assert_eq!(report.iteration_count, expected.expected_iteration_count); } +/// Choose the lexically earlier method when candidate scores tie. #[rstest] fn propagate_labels_uses_lexical_tie_break_for_equal_scores( lexical_tie_vectors: Vec, lexical_tie_adjacency: Vec>, ) { - let report = propagate_labels_report(&lexical_tie_vectors, &lexical_tie_adjacency, 1); + let report = propagate_labels_report(&lexical_tie_vectors, &lexical_tie_adjacency, 1) + .expect("fixture adjacency should be valid"); assert_eq!(report.labels.first(), Some(&1)); } +/// Prefer a heavier neighbour when competing edges have unequal weights. #[rstest] fn propagate_labels_prefers_heavier_star_neighbour_when_counts_match() { let vectors = vectors(&["hub", "zeta", "alpha"]); - let adjacency = build_adjacency(3, &[edge(0, 1, 9), edge(0, 2, 1)]); + let adjacency = adjacency(3, &[edge(0, 1, 9), edge(0, 2, 1)]); - let report = propagate_labels_report(&vectors, &adjacency, 1); + let report = propagate_labels_report(&vectors, &adjacency, 1) + .expect("fixture adjacency should be valid"); assert_eq!(report.labels.first(), Some(&1)); assert_eq!(report.labels.first(), report.labels.get(1)); } +/// Prefer triangle weight totals when neighbour counts tie. #[rstest] fn propagate_labels_prefers_triangle_weight_over_count_tie() { let vectors = vectors(&["hub", "zeta", "alpha"]); - let adjacency = build_adjacency(3, &[edge(0, 1, 12), edge(0, 2, 1), edge(1, 2, 1)]); + let adjacency = adjacency(3, &[edge(0, 1, 12), edge(0, 2, 1), edge(1, 2, 1)]); - let report = propagate_labels_report(&vectors, &adjacency, 1); + let report = propagate_labels_report(&vectors, &adjacency, 1) + .expect("fixture adjacency should be valid"); assert_eq!(report.labels.first(), Some(&1)); } +/// Stop propagation at the requested bound before convergence. #[rstest] fn propagate_labels_returns_after_bound_even_when_not_converged( non_converged_vectors: Vec, non_converged_adjacency: Vec>, ) { - let single_pass = propagate_labels_report(&non_converged_vectors, &non_converged_adjacency, 1); - let two_passes = propagate_labels_report(&non_converged_vectors, &non_converged_adjacency, 2); + let single_pass = propagate_labels_report(&non_converged_vectors, &non_converged_adjacency, 1) + .expect("fixture adjacency should be valid"); + let two_passes = propagate_labels_report(&non_converged_vectors, &non_converged_adjacency, 2) + .expect("fixture adjacency should be valid"); assert_eq!(single_pass.iteration_count, 1); assert_eq!(single_pass.labels.len(), non_converged_vectors.len()); assert_ne!(single_pass.labels, two_passes.labels); } +/// Reject adjacency whose row count differs from the vector count. #[rstest] -#[should_panic(expected = "propagate_labels_report requires adjacency rows to match vectors")] fn propagate_labels_rejects_extra_adjacency_rows( connected_triplet_vectors: Vec, connected_triplet_adjacency: Vec>, @@ -209,9 +244,28 @@ fn propagate_labels_rejects_extra_adjacency_rows( let mut adjacency = connected_triplet_adjacency; adjacency.push(vec![(0, 5)]); - propagate_labels_report(&connected_triplet_vectors, &adjacency, 1); + assert!(matches!( + propagate_labels_report(&connected_triplet_vectors, &adjacency, 1), + Err(DecompositionError::AdjacencyRowCountMismatch { + expected_rows: 3, + actual_rows: 4, + }) + )); +} + +/// Reject a neighbour index outside the initial label vector. +#[test] +fn propagate_labels_rejects_out_of_range_neighbours() { + let vectors = vectors(&["alpha", "beta", "gamma"]); + let adjacency = vec![vec![(3, 5)], Vec::new(), Vec::new()]; + + assert!(matches!( + propagate_labels_report(&vectors, &adjacency, 1), + Err(DecompositionError::InvalidNeighbourLabel { node_count: 3 }) + )); } +/// Group connected vectors that share the same feature set. #[test] fn detect_communities_groups_connected_feature_vectors() { let vectors = vec![ @@ -220,16 +274,17 @@ fn detect_communities_groups_connected_feature_vectors() { test_feature_vector("gamma", &[("shared", 1)]), ]; - let communities = detect_communities(&vectors); + let communities = detect_communities(&vectors).expect("similarity graph should be valid"); assert_eq!(communities, vec![vec![0, 1, 2]]); } +/// Keep vectors without comparable features in separate communities. #[test] fn detect_communities_keeps_minimal_vectors_separate() { let vectors = vectors(&["alpha", "beta", "gamma"]); - let communities = detect_communities(&vectors); + let communities = detect_communities(&vectors).expect("similarity graph should be valid"); assert_eq!(communities, vec![vec![0], vec![1], vec![2]]); } diff --git a/common/src/decomposition_advice/vector.rs b/common/src/decomposition_advice/vector.rs index 4db3c7f03..3122b326d 100644 --- a/common/src/decomposition_advice/vector.rs +++ b/common/src/decomposition_advice/vector.rs @@ -27,7 +27,7 @@ pub(crate) enum FeatureCategory { } impl FeatureCategory { - fn prefix(self) -> &'static str { + const fn prefix(self) -> &'static str { match self { Self::Domain => "domain", Self::Field => "field", @@ -37,7 +37,7 @@ impl FeatureCategory { } } - fn weight(self) -> u64 { + const fn weight(self) -> u64 { match self { Self::Domain => DOMAIN_WEIGHT, Self::Field => FIELD_WEIGHT, @@ -47,7 +47,7 @@ impl FeatureCategory { } } - pub(crate) fn label_priority(self) -> usize { + pub(crate) const fn label_priority(self) -> usize { match self { Self::Domain => 0, Self::Field => 1, @@ -70,7 +70,7 @@ struct FeatureIdentity { } impl FeatureMetadata { - pub(crate) fn category(&self) -> FeatureCategory { + pub(crate) const fn category(&self) -> FeatureCategory { self.category } @@ -91,11 +91,11 @@ impl MethodFeatureVector { &self.method_name } - pub(crate) fn weights(&self) -> &BTreeMap { + pub(crate) const fn weights(&self) -> &BTreeMap { &self.weights } - pub(crate) fn metadata(&self) -> &BTreeMap { + pub(crate) const fn metadata(&self) -> &BTreeMap { &self.metadata } @@ -342,16 +342,20 @@ fn type_identity(type_name: &str) -> FeatureIdentity { } fn should_split_before(chars: &[char], index: usize) -> bool { - if index == 0 { + let Some(previous_index) = index.checked_sub(1) else { return false; - } + }; - let current = chars[index]; + let Some(¤t) = chars.get(index) else { + return false; + }; if !current.is_uppercase() { return false; } - let previous = chars[index - 1]; + let Some(&previous) = chars.get(previous_index) else { + return false; + }; previous.is_lowercase() || chars .get(index + 1) diff --git a/common/src/diagnostics.rs b/common/src/diagnostics.rs index 530cdd967..aeadc3aa7 100644 --- a/common/src/diagnostics.rs +++ b/common/src/diagnostics.rs @@ -1,5 +1,4 @@ //! Ergonomic builders for lint diagnostics and suggestions. -#![cfg_attr(test, allow(clippy::expect_used, clippy::unwrap_used))] use crate::span::SourceSpan; diff --git a/common/src/dylint_entry.rs b/common/src/dylint_entry.rs new file mode 100644 index 000000000..c3e18f02a --- /dev/null +++ b/common/src/dylint_entry.rs @@ -0,0 +1,47 @@ +//! Macro support for declaring Dylint library entry points. +//! +//! Dylint locates a library's lints by calling an exported `register_lints` +//! symbol, which requires `#[unsafe(no_mangle)]`. Libraries registering more +//! than one lint cannot use `dylint_linting`'s single-lint macros, and the +//! workspace forbids in-crate `unsafe` code, so this macro provides the same +//! externally-expanded escape hatch those macros rely on: the unsafe +//! attribute originates in this crate's macro definition, keeping consumer +//! crates free of unsafe tokens. +//! +//! Scope and reuse policy: intended solely for Whitaker Dylint driver crates +//! (currently `whitaker_suite`) that must export a combined `register_lints` +//! entry point. It must not be used outside Dylint entry-point wiring. + +/// Declares the `register_lints` entry point Dylint resolves from a lint +/// library. +/// +/// The single argument is a path to a function with the signature +/// `fn(&rustc_session::Session, &mut rustc_lint::LintStore)`; it is invoked +/// with the compiler session and lint store whenever Dylint loads the +/// library. The expansion resolves `rustc_session` and `rustc_lint` at the +/// call site, so the invoking crate must have those crates as dependencies. +/// +/// # Examples +/// +/// ```ignore +/// fn register_entry(sess: &rustc_session::Session, store: &mut rustc_lint::LintStore) { +/// dylint_linting::init_config(sess); +/// store.register_lints(MY_LINT_DECLS); +/// } +/// +/// whitaker_common::declare_dylint_register_entry!(register_entry); +/// ``` +#[macro_export] +macro_rules! declare_dylint_register_entry { + ($register:path) => { + /// Dylint entry point that forwards to the library's registration + /// function. + #[unsafe(no_mangle)] + pub fn register_lints( + sess: &rustc_session::Session, + lint_store: &mut rustc_lint::LintStore, + ) { + $register(sess, lint_store); + } + }; +} diff --git a/common/src/expr.rs b/common/src/expr.rs index 88c6aa115..23a3dd531 100644 --- a/common/src/expr.rs +++ b/common/src/expr.rs @@ -1,5 +1,4 @@ //! Lightweight expression helpers for lint analysis. -#![cfg_attr(test, allow(clippy::expect_used, clippy::unwrap_used))] use crate::path::SimplePath; @@ -7,7 +6,10 @@ use crate::path::SimplePath; #[derive(Clone, Debug, PartialEq, Eq)] pub enum Expr { /// A call expression with a resolved callee path. - Call { callee: SimplePath }, + Call { + /// The resolved path of the function being called. + callee: SimplePath, + }, /// A path expression. Path(SimplePath), /// Any other literal expression (placeholder for expansion). @@ -29,7 +31,7 @@ pub enum Expr { /// ); /// ``` #[must_use] -pub fn def_id_of_expr_callee(expr: &Expr) -> Option<&SimplePath> { +pub const fn def_id_of_expr_callee(expr: &Expr) -> Option<&SimplePath> { match expr { Expr::Call { callee } => Some(callee), _ => None, diff --git a/common/src/i18n/diagnostics.rs b/common/src/i18n/diagnostics.rs index c7eed48dd..219ecbc9f 100644 --- a/common/src/i18n/diagnostics.rs +++ b/common/src/i18n/diagnostics.rs @@ -33,7 +33,7 @@ impl<'a> MessageKey<'a> { } } -impl<'a> AsRef for MessageKey<'a> { +impl AsRef for MessageKey<'_> { fn as_ref(&self) -> &str { self.0 } @@ -57,7 +57,7 @@ impl<'a> AttrKey<'a> { } } -impl<'a> AsRef for AttrKey<'a> { +impl AsRef for AttrKey<'_> { fn as_ref(&self) -> &str { self.0 } @@ -72,9 +72,19 @@ impl fmt::Display for AttrKey<'_> { /// Lookup trait used by lint crates to resolve translated diagnostic strings. pub trait BundleLookup { /// Resolve the primary message for `key` using `args`. + /// + /// # Errors + /// + /// Returns [`I18nError::MissingMessage`] when `key` is not defined for the + /// resolved locale. fn message(&self, key: MessageKey<'_>, args: &Arguments<'_>) -> Result; /// Resolve an attribute message for `key.attribute` using `args`. + /// + /// # Errors + /// + /// Returns [`I18nError::MissingMessage`] when `key.attribute` is not + /// defined for the resolved locale. fn attribute( &self, key: MessageKey<'_>, @@ -121,7 +131,7 @@ impl DiagnosticMessageSet { /// ); /// assert_eq!(messages.primary(), "primary"); /// ``` - pub fn new(primary: String, note: String, help: String) -> Self { + pub const fn new(primary: String, note: String, help: String) -> Self { Self { primary, note, @@ -199,10 +209,15 @@ impl DiagnosticMessageSet { } } -/// Resolve the primary, note, and help messages for a lint diagnostic. const NOTE_ATTR: AttrKey<'static> = AttrKey::new("note"); const HELP_ATTR: AttrKey<'static> = AttrKey::new("help"); +/// Resolve the primary, note, and help messages for a lint diagnostic. +/// +/// # Errors +/// +/// Returns [`I18nError::MissingMessage`] when the primary message, its `note` +/// attribute, or its `help` attribute is not defined for the resolved locale. #[must_use = "Use the resolved localization messages when emitting diagnostics"] pub fn resolve_message_set( lookup: &impl BundleLookup, diff --git a/common/src/i18n/helpers.rs b/common/src/i18n/helpers.rs index 86f5401d2..88be8edd9 100644 --- a/common/src/i18n/helpers.rs +++ b/common/src/i18n/helpers.rs @@ -52,11 +52,7 @@ pub fn get_localizer_for_lint(lint_name: &str, configuration_locale: Option<&str /// ``` #[must_use] pub fn branch_phrase(locale: &str, branches: usize) -> String { - match locale - .split_once('-') - .map(|(lang, _)| lang) - .unwrap_or(locale) - { + match locale.split_once('-').map_or(locale, |(lang, _)| lang) { "cy" => welsh_branch_phrase(branches), "gd" => gaelic_branch_phrase(branches), _ => english_branch_phrase(branches), diff --git a/common/src/i18n/loader.rs b/common/src/i18n/loader.rs index 3b5cae030..af85a178e 100644 --- a/common/src/i18n/loader.rs +++ b/common/src/i18n/loader.rs @@ -14,7 +14,7 @@ use thiserror::Error; use super::locales::supports_locale; use super::{FALLBACK_LANGUAGE, FALLBACK_LITERAL, LOADER, LanguageIdentifier}; -/// HashMap wrapper used when passing Fluent arguments to lookups. +/// `HashMap` wrapper used when passing Fluent arguments to lookups. pub type Arguments<'a> = HashMap, FluentValue<'a>>; /// Error raised when localization data cannot satisfy a caller request. @@ -22,7 +22,12 @@ pub type Arguments<'a> = HashMap, FluentValue<'a>>; pub enum I18nError { /// Raised when the requested message slug is missing for the resolved locale. #[error("message `{key}` missing for locale `{locale}`")] - MissingMessage { key: String, locale: String }, + MissingMessage { + /// The Fluent message key (including any attribute suffix) that was requested. + key: String, + /// The locale tag the lookup was resolved against. + locale: String, + }, } /// Resolve localization messages for a specific locale. @@ -55,25 +60,26 @@ impl Localizer { #[must_use] pub fn new(locale: Option<&str>) -> Self { match locale { - Some(value) if supports_locale(value) => match LanguageIdentifier::from_str(value) { - Ok(identifier) => { - let language_tag = identifier.to_string(); - - Self { - language: identifier, - language_tag, - fallback_used: false, - } - } - Err(_) => Self::fallback(), - }, + Some(value) if supports_locale(value) => LanguageIdentifier::from_str(value) + .map_or_else( + |_| Self::fallback(), + |identifier| { + let language_tag = identifier.to_string(); + + Self { + language: identifier, + language_tag, + fallback_used: false, + } + }, + ), _ => Self::fallback(), } } /// Return the resolved locale identifier. #[must_use] - pub fn language(&self) -> &LanguageIdentifier { + pub const fn language(&self) -> &LanguageIdentifier { &self.language } @@ -85,26 +91,46 @@ impl Localizer { /// Whether the fallback locale was used. #[must_use] - pub fn used_fallback(&self) -> bool { + pub const fn used_fallback(&self) -> bool { self.fallback_used } /// Fetch the translated message for `key`. + /// + /// # Errors + /// + /// Returns [`I18nError::MissingMessage`] when `key` is not defined for the + /// resolved locale. pub fn message(&self, key: &str) -> Result { self.lookup(key, None, None) } /// Fetch the translated message with Fluent arguments. + /// + /// # Errors + /// + /// Returns [`I18nError::MissingMessage`] when `key` is not defined for the + /// resolved locale. pub fn message_with_args(&self, key: &str, args: &Arguments<'_>) -> Result { self.lookup(key, None, Some(args)) } /// Fetch a translated attribute, e.g. `function.primary`. + /// + /// # Errors + /// + /// Returns [`I18nError::MissingMessage`] when `key.attribute` is not + /// defined for the resolved locale. pub fn attribute(&self, key: &str, attribute: &str) -> Result { self.lookup(key, Some(attribute), None) } /// Fetch a translated attribute with Fluent arguments. + /// + /// # Errors + /// + /// Returns [`I18nError::MissingMessage`] when `key.attribute` is not + /// defined for the resolved locale. pub fn attribute_with_args( &self, key: &str, @@ -120,17 +146,15 @@ impl Localizer { attribute: Option<&str>, args: Option<&Arguments<'_>>, ) -> Result { - let lookup_key = attribute - .map(|attr| format!("{key}.{attr}")) - .unwrap_or_else(|| key.to_string()); + let lookup_key = attribute.map_or_else(|| key.to_owned(), |attr| format!("{key}.{attr}")); - let maybe_value = match args { - Some(arguments) => { + let maybe_value = args.map_or_else( + || LOADER.try_lookup(&self.language, lookup_key.as_str()), + |arguments| { let owned_arguments = promote_arguments(arguments); LOADER.try_lookup_with_args(&self.language, lookup_key.as_str(), &owned_arguments) - } - None => LOADER.try_lookup(&self.language, lookup_key.as_str()), - }; + }, + ); maybe_value.ok_or_else(|| I18nError::MissingMessage { key: lookup_key, @@ -141,7 +165,7 @@ impl Localizer { fn fallback() -> Self { Self { language: FALLBACK_LANGUAGE.clone(), - language_tag: FALLBACK_LITERAL.to_string(), + language_tag: FALLBACK_LITERAL.to_owned(), fallback_used: true, } } @@ -152,13 +176,13 @@ fn promote_arguments( ) -> HashMap, FluentValue<'static>> { arguments .iter() - .map(|(key, value)| (Cow::Owned(key.as_ref().to_string()), promote_value(value))) + .map(|(key, value)| (Cow::Owned(key.as_ref().to_owned()), promote_value(value))) .collect() } fn promote_value(value: &FluentValue<'_>) -> FluentValue<'static> { match value { - FluentValue::String(text) => FluentValue::String(Cow::Owned(text.as_ref().to_string())), + FluentValue::String(text) => FluentValue::String(Cow::Owned(text.as_ref().to_owned())), FluentValue::Number(number) => FluentValue::Number(number.clone()), FluentValue::Custom(custom) => FluentValue::Custom(custom.duplicate()), FluentValue::None => FluentValue::None, diff --git a/common/src/i18n/locales.rs b/common/src/i18n/locales.rs index f58b0feab..6a534888a 100644 --- a/common/src/i18n/locales.rs +++ b/common/src/i18n/locales.rs @@ -4,14 +4,15 @@ //! bundles and provides utilities for checking whether a given locale tag is //! supported. -use once_cell::sync::Lazy; - use fluent_templates::{Loader, loader::LanguageIdentifier}; use super::LOADER; -static ALL_LOCALES: Lazy> = Lazy::new(|| { - let mut locales: Vec = LOADER.locales().map(|id| id.to_string()).collect(); +static ALL_LOCALES: std::sync::LazyLock> = std::sync::LazyLock::new(|| { + let mut locales: Vec = LOADER + .locales() + .map(std::string::ToString::to_string) + .collect(); locales.sort_unstable(); locales }); @@ -25,13 +26,12 @@ pub fn available_locales() -> &'static [String] { /// Check whether a locale tag is supported by the embedded bundles. #[must_use] pub fn supports_locale(locale: &str) -> bool { - match locale.parse::() { - Ok(identifier) => { + locale + .parse::() + .is_ok_and(|identifier| { let canonical = identifier.to_string(); ALL_LOCALES .binary_search_by(|candidate| candidate.as_str().cmp(canonical.as_str())) .is_ok() - } - Err(_) => false, - } + }) } diff --git a/common/src/i18n/mod.rs b/common/src/i18n/mod.rs index 300db0588..d001f24dc 100644 --- a/common/src/i18n/mod.rs +++ b/common/src/i18n/mod.rs @@ -35,15 +35,18 @@ static_loader! { }; } +/// Locale tag used when no supported locale is requested (`en-GB`). pub const FALLBACK_LOCALE: &str = FALLBACK_LITERAL; pub(crate) const FALLBACK_LANGUAGE: LanguageIdentifier = langid!("en-GB"); /// Return the crate-local Fluent resource root used by packaging and tests. +#[must_use] pub fn locales_root() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(LOCALES_DIR_NAME) } /// Return the relative path inside the package tarball for a locale bundle. +#[must_use] pub fn packaged_locale_path(locale: &str, file: &str) -> PathBuf { PathBuf::from(format!( "{}-{}", @@ -56,6 +59,7 @@ pub fn packaged_locale_path(locale: &str, file: &str) -> PathBuf { } /// Return the default packaged locale path for Whitaker diagnostics. +#[must_use] pub fn packaged_fallback_locale_path() -> PathBuf { packaged_locale_path(FALLBACK_LOCALE, LOCALES_FTL_FILE) } diff --git a/common/src/i18n/selection.rs b/common/src/i18n/selection.rs index c6a58b3e7..8c759cca7 100644 --- a/common/src/i18n/selection.rs +++ b/common/src/i18n/selection.rs @@ -1,6 +1,7 @@ //! Locale resolver wiring explicit overrides, environment variables, and //! configuration before falling back to the bundled localizer. +use std::borrow::Cow; use std::fmt; use log::{debug, warn}; @@ -68,13 +69,13 @@ impl LocaleSelection { /// Whether the fallback locale was used. #[must_use] - pub fn used_fallback(&self) -> bool { + pub const fn used_fallback(&self) -> bool { self.localizer.used_fallback() } /// Returns the resolved [`Localizer`]. #[must_use] - pub fn localizer(&self) -> &Localizer { + pub const fn localizer(&self) -> &Localizer { &self.localizer } @@ -130,14 +131,20 @@ pub fn resolve_localizer( configuration: Option<&str>, ) -> LocaleSelection { let candidates = [ - (LocaleSource::ExplicitArgument, explicit), - (LocaleSource::EnvironmentVariable, environment.as_deref()), - (LocaleSource::Configuration, configuration), + (LocaleSource::ExplicitArgument, explicit.map(Cow::Borrowed)), + ( + LocaleSource::EnvironmentVariable, + environment.map(Cow::Owned), + ), + ( + LocaleSource::Configuration, + configuration.map(Cow::Borrowed), + ), ]; candidates .into_iter() - .find_map(|(source, raw)| try_resolve_candidate(source, raw)) + .find_map(|(source, raw)| try_resolve_candidate(source, raw.as_deref())) .unwrap_or_else(|| LocaleSelection::new(Localizer::new(None), LocaleSource::Fallback, None)) } diff --git a/common/src/i18n/tests.rs b/common/src/i18n/tests.rs index 27d98a4ea..c21611037 100644 --- a/common/src/i18n/tests.rs +++ b/common/src/i18n/tests.rs @@ -20,9 +20,9 @@ fn resolves_locales(#[case] input: Option<&str>, #[case] expected: &str, #[case] #[test] fn enumerates_available_locales() { let locales = available_locales(); - assert!(locales.contains(&"en-GB".to_string())); - assert!(locales.contains(&"cy".to_string())); - assert!(locales.contains(&"gd".to_string())); + assert!(locales.contains(&"en-GB".to_owned())); + assert!(locales.contains(&"cy".to_owned())); + assert!(locales.contains(&"gd".to_owned())); } #[test] diff --git a/common/src/lcom4/extract.rs b/common/src/lcom4/extract.rs index 5b58afbbd..8c13aa877 100644 --- a/common/src/lcom4/extract.rs +++ b/common/src/lcom4/extract.rs @@ -353,10 +353,13 @@ mod tests { let infos = collect_method_infos(vec![b1, b2, b3]); assert_eq!(infos.len(), 3); - assert_eq!(infos[0].name(), "alpha"); - assert_eq!(infos[1].name(), "beta"); - assert_eq!(infos[2].name(), "gamma"); - assert!(infos[0].accessed_fields().contains("x")); - assert!(infos[1].called_methods().contains("alpha")); + let alpha = infos.first().expect("collected infos should include alpha"); + let beta = infos.get(1).expect("collected infos should include beta"); + let gamma = infos.get(2).expect("collected infos should include gamma"); + assert_eq!(alpha.name(), "alpha"); + assert_eq!(beta.name(), "beta"); + assert_eq!(gamma.name(), "gamma"); + assert!(alpha.accessed_fields().contains("x")); + assert!(beta.called_methods().contains("alpha")); } } diff --git a/common/src/lcom4/mod.rs b/common/src/lcom4/mod.rs index 8e8d3adf0..ec3604cd2 100644 --- a/common/src/lcom4/mod.rs +++ b/common/src/lcom4/mod.rs @@ -109,7 +109,7 @@ impl MethodInfo { /// assert!(m.accessed_fields().contains("buf")); /// ``` #[must_use] - pub fn accessed_fields(&self) -> &BTreeSet { + pub const fn accessed_fields(&self) -> &BTreeSet { &self.accessed_fields } @@ -129,7 +129,7 @@ impl MethodInfo { /// assert!(m.called_methods().contains("validate")); /// ``` #[must_use] - pub fn called_methods(&self) -> &BTreeSet { + pub const fn called_methods(&self) -> &BTreeSet { &self.called_methods } } @@ -153,15 +153,29 @@ impl UnionFind { } fn find(&mut self, x: usize) -> usize { - if self.parent[x] != x { - self.parent[x] = self.find(self.parent[x]); + // Out-of-range nodes are treated as their own root; callers only pass + // indices from `0..n`, so the fallback never fires in practice. + let parent = self.parent.get(x).copied().unwrap_or(x); + if parent == x { + return x; } - self.parent[x] + let root = self.find(parent); + self.set_parent(x, root); + root } /// Returns `true` when the first root has strictly lower rank. fn lower_rank(&self, root_a: usize, root_b: usize) -> bool { - self.rank[root_a] < self.rank[root_b] + let rank_a = self.rank.get(root_a).copied().unwrap_or(0); + let rank_b = self.rank.get(root_b).copied().unwrap_or(0); + rank_a < rank_b + } + + /// Repoints `node` at `root`, ignoring out-of-range nodes. + fn set_parent(&mut self, node: usize, root: usize) { + if let Some(parent_slot) = self.parent.get_mut(node) { + *parent_slot = root; + } } fn union(&mut self, x: usize, y: usize) { @@ -171,22 +185,24 @@ impl UnionFind { return; } if self.lower_rank(root_x, root_y) { - self.parent[root_x] = root_y; + self.set_parent(root_x, root_y); } else if self.lower_rank(root_y, root_x) { - self.parent[root_y] = root_x; + self.set_parent(root_y, root_x); } else { - self.parent[root_y] = root_x; - self.rank[root_x] += 1; + self.set_parent(root_y, root_x); + if let Some(rank_slot) = self.rank.get_mut(root_x) { + *rank_slot += 1; + } } } fn component_count(&mut self) -> usize { - let n = self.parent.len(); + let node_count = self.parent.len(); // Flatten the forest so every node points directly to its root. - for i in 0..n { - self.find(i); + for node in 0..node_count { + self.find(node); } - let mut roots: Vec = self.parent[..n].to_vec(); + let mut roots = self.parent.clone(); roots.sort_unstable(); roots.dedup(); roots.len() diff --git a/common/src/lcom4/tests.rs b/common/src/lcom4/tests.rs index 83b57cd0f..311296807 100644 --- a/common/src/lcom4/tests.rs +++ b/common/src/lcom4/tests.rs @@ -11,7 +11,7 @@ fn method_with_fields() -> fn(&str, &[&str]) -> MethodInfo { |name: &str, fields: &[&str]| -> MethodInfo { MethodInfo::new( name, - fields.iter().map(|s| (*s).to_string()).collect(), + fields.iter().map(|s| (*s).to_owned()).collect(), BTreeSet::new(), ) } @@ -23,7 +23,7 @@ fn method_with_calls() -> fn(&str, &[&str]) -> MethodInfo { MethodInfo::new( name, BTreeSet::new(), - calls.iter().map(|s| (*s).to_string()).collect(), + calls.iter().map(|s| (*s).to_owned()).collect(), ) } } @@ -33,8 +33,8 @@ fn method_with_fields_and_calls() -> fn(&str, &[&str], &[&str]) -> MethodInfo { |name: &str, fields: &[&str], calls: &[&str]| -> MethodInfo { MethodInfo::new( name, - fields.iter().map(|s| (*s).to_string()).collect(), - calls.iter().map(|s| (*s).to_string()).collect(), + fields.iter().map(|s| (*s).to_owned()).collect(), + calls.iter().map(|s| (*s).to_owned()).collect(), ) } } diff --git a/common/src/lib.rs b/common/src/lib.rs index bf7007b63..1dc994ac6 100644 --- a/common/src/lib.rs +++ b/common/src/lib.rs @@ -9,6 +9,7 @@ pub mod complexity_signal; pub mod context; pub mod decomposition_advice; pub mod diagnostics; +pub mod dylint_entry; pub mod expr; pub mod i18n; pub mod lcom4; diff --git a/common/src/path.rs b/common/src/path.rs index 7bca6e56c..3d268abf3 100644 --- a/common/src/path.rs +++ b/common/src/path.rs @@ -70,9 +70,11 @@ impl SimplePath { let mut candidate_iter = candidate.into_iter(); for expected in &self.segments { - match candidate_iter.next() { - Some(candidate_segment) if expected == candidate_segment.as_ref() => continue, - _ => return false, + let Some(candidate_segment) = candidate_iter.next() else { + return false; + }; + if expected != candidate_segment.as_ref() { + return false; } } diff --git a/common/src/rstest/argument_fingerprint.rs b/common/src/rstest/argument_fingerprint.rs index b18cdc3d8..8742b2bc0 100644 --- a/common/src/rstest/argument_fingerprint.rs +++ b/common/src/rstest/argument_fingerprint.rs @@ -4,11 +4,20 @@ #[derive(Clone, Debug, PartialEq, Eq, Hash)] pub enum ArgAtom { /// An argument supplied by an `rstest` fixture-local parameter. - FixtureLocal { name: String }, + FixtureLocal { + /// The fixture-local parameter name as written in the test signature. + name: String, + }, /// A stable literal argument, stored as canonical source text. - ConstLit { text: String }, + ConstLit { + /// Canonical source text of the literal. + text: String, + }, /// A stable constant path, stored as a canonical definition path. - ConstPath { def_path: String }, + ConstPath { + /// Canonical definition path of the referenced constant. + def_path: String, + }, /// A present argument shape that later lowering does not support. Unsupported, } diff --git a/common/src/rstest/detection.rs b/common/src/rstest/detection.rs index b382307f5..c5c9ba059 100644 --- a/common/src/rstest/detection.rs +++ b/common/src/rstest/detection.rs @@ -76,7 +76,7 @@ impl RstestDetectionOptions { /// assert!(options.use_expansion_trace_fallback()); /// ``` #[must_use] - pub fn new( + pub const fn new( provider_param_attributes: Vec, use_expansion_trace_fallback: bool, ) -> Self { @@ -199,7 +199,7 @@ fn matches_direct_or_trace( ) -> bool { has_matching_attribute(attrs, candidates) || (options.use_expansion_trace_fallback() - && trace.is_some_and(|trace| has_matching_trace(trace, candidates))) + && trace.is_some_and(|expansion| has_matching_trace(expansion, candidates))) } fn has_matching_attribute(attrs: &[Attribute], candidates: &[&[&str]]) -> bool { diff --git a/common/src/rstest/paragraph_fingerprint.rs b/common/src/rstest/paragraph_fingerprint.rs index 787295f2e..8aa9a62d2 100644 --- a/common/src/rstest/paragraph_fingerprint.rs +++ b/common/src/rstest/paragraph_fingerprint.rs @@ -64,14 +64,14 @@ impl ParagraphNormalizer { /// reuse the original slot. #[must_use] pub fn local_slot(&mut self, local_name: impl Into) -> LocalSlot { - let local_name = local_name.into(); - if let Some(slot) = self.slots.get(&local_name) { + let owned_name = local_name.into(); + if let Some(slot) = self.slots.get(&owned_name) { return *slot; } let slot = LocalSlot::new(self.next_slot); self.next_slot += 1; - self.slots.insert(local_name, slot); + self.slots.insert(owned_name, slot); slot } } @@ -120,9 +120,19 @@ impl CalleeShape { #[derive(Clone, Debug, PartialEq, Eq, Hash)] pub enum ExprShape { /// A function call with known or unknown callee identity and arity. - Call { callee: CalleeShape, argc: usize }, + Call { + /// The normalized callee identity. + callee: CalleeShape, + /// The number of call arguments. + argc: usize, + }, /// A method call with method name and arity. - MethodCall { method: String, argc: usize }, + MethodCall { + /// The method name as written at the call site. + method: String, + /// The number of call arguments, excluding the receiver. + argc: usize, + }, /// A stable path expression. Path, /// A stable literal expression. @@ -185,10 +195,16 @@ impl ExprShape { #[derive(Clone, Debug, PartialEq, Eq, Hash)] pub enum StmtShape { /// A `let` statement represented by its initializer shape. - Let { init: ExprShape }, + Let { + /// The normalized shape of the initializer expression. + init: ExprShape, + }, /// A mutating call, optionally tied to a normalized local receiver slot. MutCall { + /// The normalized slot of the local receiver, when the receiver is a + /// known local binding. receiver: Option, + /// The normalized callee identity. callee: CalleeShape, }, } diff --git a/common/src/rstest/parameter.rs b/common/src/rstest/parameter.rs index fe4e0337b..a576948f8 100644 --- a/common/src/rstest/parameter.rs +++ b/common/src/rstest/parameter.rs @@ -37,7 +37,7 @@ impl RstestParameter { /// assert_eq!(parameter.attributes().len(), 1); /// ``` #[must_use] - pub fn new(binding: ParameterBinding, attributes: Vec) -> Self { + pub const fn new(binding: ParameterBinding, attributes: Vec) -> Self { Self { binding, attributes, @@ -70,7 +70,7 @@ impl RstestParameter { /// assert_eq!(parameter.binding_name(), None); /// ``` #[must_use] - pub fn unsupported() -> Self { + pub const fn unsupported() -> Self { Self::new(ParameterBinding::Unsupported, Vec::new()) } @@ -100,7 +100,10 @@ impl RstestParameter { #[derive(Clone, Debug, PartialEq, Eq)] pub enum RstestParameterKind { /// A fixture-local identifier binding. - FixtureLocal { name: String }, + FixtureLocal { + /// The identifier bound by the fixture parameter. + name: String, + }, /// A provider-driven input such as `#[case]` or `#[values]`. Provider, /// A binding shape that version one does not support. diff --git a/common/src/span.rs b/common/src/span.rs index 4b320976e..f53cb7e92 100644 --- a/common/src/span.rs +++ b/common/src/span.rs @@ -1,5 +1,4 @@ //! Utilities for working with source locations and spans. -#![cfg_attr(test, allow(clippy::expect_used, clippy::unwrap_used))] use std::ops::RangeInclusive; @@ -47,7 +46,7 @@ impl SourceLocation { /// Returns true when this location is positioned after other. #[must_use] - pub const fn is_after(self, other: SourceLocation) -> bool { + pub const fn is_after(self, other: Self) -> bool { self.line > other.line || (self.line == other.line && self.column > other.column) } } @@ -62,6 +61,10 @@ pub struct SourceSpan { impl SourceSpan { /// Constructs a new span from two locations. /// + /// # Errors + /// + /// Returns [`SpanError::StartAfterEnd`] when `start` is positioned after `end`. + /// /// # Examples /// /// ``` @@ -71,7 +74,7 @@ impl SourceSpan { /// assert_eq!(span.start().line(), 1); /// ``` #[must_use = "Inspect the span creation result to handle invalid ranges"] - pub fn new(start: SourceLocation, end: SourceLocation) -> Result { + pub const fn new(start: SourceLocation, end: SourceLocation) -> Result { if start.is_after(end) { Err(SpanError::StartAfterEnd) } else { @@ -103,7 +106,7 @@ impl SourceSpan { /// assert_eq!(span_to_lines(span), 4..=6); /// ``` #[must_use] -pub fn span_to_lines(span: SourceSpan) -> RangeInclusive { +pub const fn span_to_lines(span: SourceSpan) -> RangeInclusive { span.start.line()..=span.end.line() } @@ -118,7 +121,7 @@ pub fn span_to_lines(span: SourceSpan) -> RangeInclusive { /// assert_eq!(span_line_count(span), 4); /// ``` #[must_use] -pub fn span_line_count(span: SourceSpan) -> usize { +pub const fn span_line_count(span: SourceSpan) -> usize { span.end.line() - span.start.line() + 1 } diff --git a/common/src/test_support/decomposition_adjacency.rs b/common/src/test_support/decomposition_adjacency.rs index f9a64a32a..64ff624cb 100644 --- a/common/src/test_support/decomposition_adjacency.rs +++ b/common/src/test_support/decomposition_adjacency.rs @@ -57,6 +57,9 @@ pub enum AdjacencyError { /// Edge index within the declarative input slice. index: usize, }, + /// An internal graph invariant failed after declarative validation. + #[error("validated edge input did not produce a valid graph")] + InvalidInternalState, } /// Observable adjacency-construction results for a set of edges. @@ -81,7 +84,7 @@ pub struct AdjacencyReport { } impl AdjacencyReport { - /// Returns the number of nodes in the adjacency graph. + /// Return the number of nodes in the adjacency graph. /// /// ```rust /// use whitaker_common::test_support::decomposition::{EdgeInput, adjacency_report}; @@ -94,7 +97,7 @@ impl AdjacencyReport { self.node_count } - /// Returns the neighbour list for `node`, sorted by neighbour index. + /// Return the neighbour list for `node`, sorted by neighbour index. /// /// Returns `None` if `node` is out of range. /// @@ -118,7 +121,7 @@ impl AdjacencyReport { self.neighbours.get(node).map(Vec::as_slice) } - /// Returns `true` if all neighbour indices are within bounds. + /// Check whether every neighbour index lies within the graph bounds. /// /// ```rust /// use whitaker_common::test_support::decomposition::{EdgeInput, adjacency_report}; @@ -143,7 +146,7 @@ impl AdjacencyReport { }) } - /// Returns `true` if the adjacency lists are symmetric: for every entry + /// Check whether the adjacency lists are symmetric: for every entry /// `(node -> neighbour, weight)`, the mirrored entry exists. /// /// ```rust @@ -169,7 +172,7 @@ impl AdjacencyReport { }) } - /// Returns `true` if each per-node neighbour list is sorted by neighbour + /// Check whether each per-node neighbour list is sorted by neighbour /// index. /// /// ```rust @@ -203,6 +206,7 @@ impl AdjacencyReport { } } +/// Check whether the reverse edge appears in the neighbour list. #[expect( clippy::unnecessary_map_or, reason = "Keep the explicit non-panicking fallback requested in review." @@ -226,7 +230,7 @@ fn has_mirror( }) } -/// Builds an [`AdjacencyReport`] from declarative edge input. +/// Build an [`AdjacencyReport`] from declarative edge input. /// /// Returns `Err` if any edge endpoint is out of range for the given /// `node_count` or if `left >= right` (violating the production @@ -252,13 +256,14 @@ fn has_mirror( /// # Errors /// /// Returns a typed validation error when an edge violates the production -/// input contract. +/// input contract or validated data violates an internal graph invariant. pub fn adjacency_report( node_count: usize, edges: &[EdgeInput], ) -> Result { let similarity_edges = validate_edges(node_count, edges)?; - let neighbours = build_adjacency(node_count, &similarity_edges); + let neighbours = build_adjacency(node_count, &similarity_edges) + .map_err(|_| AdjacencyError::InvalidInternalState)?; Ok(AdjacencyReport { node_count, @@ -266,7 +271,7 @@ pub fn adjacency_report( }) } -/// Validates declarative [`EdgeInput`] entries against adjacency invariants and +/// Validate declarative [`EdgeInput`] entries against adjacency invariants and /// converts valid edges into [`SimilarityEdge`] values. /// /// # Errors @@ -309,6 +314,7 @@ mod tests { use super::{AdjacencyError, EdgeInput, adjacency_report}; + /// Reject an edge whose endpoints are not in canonical order. #[test] fn adjacency_report_rejects_non_canonical_edges_with_structured_error() { let result = adjacency_report( @@ -330,6 +336,7 @@ mod tests { ); } + /// Reject an edge endpoint outside the declared graph size. #[test] fn adjacency_report_rejects_out_of_range_endpoints_with_structured_error() { let result = adjacency_report( @@ -351,6 +358,7 @@ mod tests { ); } + /// Reject an edge with zero weight. #[test] fn adjacency_report_rejects_zero_weight_with_structured_error() { let result = adjacency_report( diff --git a/common/src/test_support/decomposition_label_propagation.rs b/common/src/test_support/decomposition_label_propagation.rs index cc37f947e..f2f1ff501 100644 --- a/common/src/test_support/decomposition_label_propagation.rs +++ b/common/src/test_support/decomposition_label_propagation.rs @@ -36,32 +36,32 @@ pub struct LabelPropagationReport { } impl LabelPropagationReport { - /// Returns the final label vector. + /// Return the final label vector. #[must_use] pub fn labels(&self) -> &[usize] { &self.runtime.labels } - /// Returns the propagated label for `node`, or `None` if it is out of + /// Return the propagated label for `node`, or `None` if it is out of /// range. #[must_use] pub fn label_of(&self, node: usize) -> Option { self.labels().get(node).copied() } - /// Returns the number of propagation passes performed. + /// Return the number of propagation passes performed. #[must_use] pub const fn iteration_count(&self) -> usize { self.runtime.iteration_count } - /// Returns `true` when the graph contains at least one active node. + /// Check whether the graph contains at least one active node. #[must_use] pub const fn has_active_nodes(&self) -> bool { self.has_active_nodes } - /// Returns `true` when every label is a valid node index. + /// Check whether every label is a valid node index. #[must_use] pub fn all_labels_in_bounds(&self) -> bool { self.runtime @@ -75,8 +75,8 @@ impl LabelPropagationReport { /// /// # Errors /// -/// Returns a typed validation error when any edge violates the production -/// adjacency contract. +/// Returns a typed validation error when an edge violates the production +/// adjacency contract or validated data violates an internal graph invariant. pub fn label_propagation_report( method_names: &[&str], edges: &[EdgeInput], @@ -86,13 +86,15 @@ pub fn label_propagation_report( let adjacency = crate::decomposition_advice::community::build_adjacency( method_names.len(), &similarity_edges, - ); + ) + .map_err(|_| AdjacencyError::InvalidInternalState)?; let vectors = method_names .iter() .map(|method_name| build_feature_vector(&MethodProfileBuilder::new(*method_name).build())) .collect::>(); let has_active_nodes = adjacency.iter().any(|neighbours| !neighbours.is_empty()); - let runtime = runtime_propagate_labels_report(&vectors, &adjacency, max_iterations); + let runtime = runtime_propagate_labels_report(&vectors, &adjacency, max_iterations) + .map_err(|_| AdjacencyError::InvalidInternalState)?; Ok(LabelPropagationReport { runtime, diff --git a/common/src/test_support/env_tests.rs b/common/src/test_support/env_tests.rs index 0f119805b..78e4189c9 100644 --- a/common/src/test_support/env_tests.rs +++ b/common/src/test_support/env_tests.rs @@ -8,7 +8,7 @@ use std::{ time::Duration, }; -use super::{EnvVarGuard, env_test_guard, with_env_var, with_env_var_removed, with_locale}; +use super::{env_test_guard, with_env_var, with_env_var_removed, with_locale}; use proptest::prelude::*; const ABSENT_SET_KEY: &str = "WHITAKER_TEST_SUPPORT_ABSENT_SET"; @@ -16,341 +16,324 @@ const REMOVED_KEY: &str = "WHITAKER_TEST_SUPPORT_REMOVED"; const SET_PANIC_KEY: &str = "WHITAKER_TEST_SUPPORT_SET_PANIC"; const REMOVE_PANIC_KEY: &str = "WHITAKER_TEST_SUPPORT_REMOVE_PANIC"; const SCOPED_KEY: &str = "WHITAKER_TEST_SUPPORT_SCOPED"; -const OVERLAPPING_GUARDS_KEY: &str = "WHITAKER_TEST_SUPPORT_OVERLAPPING_GUARDS"; +const OVERLAPPING_KEY: &str = "WHITAKER_TEST_SUPPORT_OVERLAPPING"; const PROPERTY_KEY: &str = "WHITAKER_TEST_SUPPORT_PROPERTY"; -struct GuardThread { - release_sender: mpsc::Sender<()>, - created_receiver: mpsc::Receiver<()>, - dropped_receiver: mpsc::Receiver<()>, - thread: JoinHandle<()>, +enum NestedMutation { + Set, + Remove, +} + +struct ReleaseOnDrop(Option>); + +impl ReleaseOnDrop { + /// Release a worker blocked inside a scoped mutation. + fn release(mut self) { + if let Some(sender) = self.0.take() { + let _ = sender.send(()); + } + } } -impl GuardThread { - fn release(&self, description: &str) { - self.release_sender.send(()).expect(description); +impl Drop for ReleaseOnDrop { + /// Unblock a worker if an earlier assertion unwinds the test. + fn drop(&mut self) { + if let Some(sender) = self.0.take() { + let _ = sender.send(()); + } } +} + +struct ScopedMutationThread { + release_sender: mpsc::Sender<()>, + entered_receiver: mpsc::Receiver<()>, + restored_receiver: mpsc::Receiver<()>, + thread: JoinHandle<()>, +} - fn wait_for_creation(&self, description: &str) { - self.created_receiver +impl ScopedMutationThread { + /// Waits until the scoped environment callback has entered. + fn wait_for_entry(&self, description: &str) { + self.entered_receiver .recv_timeout(Duration::from_secs(1)) .expect(description); } - fn wait_for_drop(&self, description: &str) { - self.dropped_receiver + /// Waits until the scoped environment value has been restored. + fn wait_for_restoration(&self, description: &str) { + self.restored_receiver .recv_timeout(Duration::from_secs(1)) .expect(description); } - fn join(self, description: &str) { - self.thread.join().expect(description); + /// Joins the worker after its environment callback has completed. + fn join(self) { + self.thread + .join() + .expect("scoped environment thread should complete"); } } -fn spawn_first_guard() -> GuardThread { - let (created_sender, created_receiver) = mpsc::channel(); +/// Starts a thread that holds a scoped value until the test releases it. +fn spawn_scoped_mutation(value: &'static str) -> ScopedMutationThread { + let (entered_sender, entered_receiver) = mpsc::channel(); let (release_sender, release_receiver) = mpsc::channel(); - let (dropped_sender, dropped_receiver) = mpsc::channel(); + let (restored_sender, restored_receiver) = mpsc::channel(); let thread = thread::spawn(move || { - let guard = EnvVarGuard::set(OVERLAPPING_GUARDS_KEY, "first"); - created_sender - .send(()) - .expect("first guard creation must be reported"); - release_receiver - .recv() - .expect("first guard release must be received"); - drop(guard); - dropped_sender + with_env_var(OVERLAPPING_KEY, value, || { + entered_sender + .send(()) + .expect("scoped callback entry must be reported"); + release_receiver + .recv() + .expect("scoped callback release must be received"); + }); + restored_sender .send(()) - .expect("first guard restoration must be reported"); + .expect("environment restoration must be reported"); }); - GuardThread { + ScopedMutationThread { release_sender, - created_receiver, - dropped_receiver, + entered_receiver, + restored_receiver, thread, } } -fn spawn_second_guard() -> (GuardThread, mpsc::Receiver<()>) { - let (attempted_sender, attempted_receiver) = mpsc::channel(); - let (created_sender, created_receiver) = mpsc::channel(); - let (release_sender, release_receiver) = mpsc::channel(); - let (dropped_sender, dropped_receiver) = mpsc::channel(); - let thread = thread::spawn(move || { - attempted_sender - .send(()) - .expect("second guard attempt must be reported"); - let guard = EnvVarGuard::set(OVERLAPPING_GUARDS_KEY, "second"); - created_sender - .send(()) - .expect("second guard mutation must be reported"); - release_receiver - .recv() - .expect("second guard release must be received"); - drop(guard); - dropped_sender - .send(()) - .expect("second guard restoration must be reported"); - }); - ( - GuardThread { - release_sender, - created_receiver, - dropped_receiver, - thread, - }, - attempted_receiver, - ) -} - +/// Check that a scoped set exposes its value and restores an absent variable. #[test] fn with_env_var_exposes_and_restores_an_absent_value() { - let baseline = EnvVarGuard::remove(ABSENT_SET_KEY); + with_env_var_removed(ABSENT_SET_KEY, || { + with_env_var(ABSENT_SET_KEY, "temporary", || { + assert_eq!( + std::env::var_os(ABSENT_SET_KEY).as_deref(), + Some(OsStr::new("temporary")) + ); + }); - with_env_var(ABSENT_SET_KEY, "temporary", || { - assert_eq!( - std::env::var_os(ABSENT_SET_KEY).as_deref(), - Some(OsStr::new("temporary")) - ); + assert!(std::env::var_os(ABSENT_SET_KEY).is_none()); }); - - assert!(std::env::var_os(ABSENT_SET_KEY).is_none()); - drop(baseline); } +/// Check that a scoped removal restores the exact previous value. #[test] -fn with_env_var_removed_removes_and_restores_the_exact_value() { - let baseline = EnvVarGuard::set(REMOVED_KEY, "original value"); +fn with_env_var_removed_restores_the_previous_value() { + with_env_var(REMOVED_KEY, "original value", || { + with_env_var_removed(REMOVED_KEY, || { + assert!(std::env::var_os(REMOVED_KEY).is_none()); + }); - with_env_var_removed(REMOVED_KEY, || { - assert!(std::env::var_os(REMOVED_KEY).is_none()); + assert_eq!( + std::env::var_os(REMOVED_KEY).as_deref(), + Some(OsStr::new("original value")) + ); }); - - assert_eq!( - std::env::var_os(REMOVED_KEY).as_deref(), - Some(OsStr::new("original value")) - ); - drop(baseline); } +/// Check that scoped set restoration still runs when the callback panics. #[test] fn with_env_var_restores_an_absent_value_after_a_panic() { - let baseline = EnvVarGuard::remove(SET_PANIC_KEY); - - let result = catch_unwind(AssertUnwindSafe(|| { - with_env_var(SET_PANIC_KEY, "temporary", || { - panic!("intentional test panic") - }); - })); - - assert!(result.is_err()); - assert!(std::env::var_os(SET_PANIC_KEY).is_none()); - drop(baseline); -} - -#[test] -fn with_env_var_removed_restores_the_exact_value_after_a_panic() { - let baseline = EnvVarGuard::set(REMOVE_PANIC_KEY, "original value"); - - let result = catch_unwind(AssertUnwindSafe(|| { - with_env_var_removed(REMOVE_PANIC_KEY, || panic!("intentional test panic")); - })); - - assert!(result.is_err()); - assert_eq!( - std::env::var_os(REMOVE_PANIC_KEY).as_deref(), - Some(OsStr::new("original value")) - ); - drop(baseline); -} - -#[test] -fn scoped_mutation_blocks_env_var_guard_until_callback_finishes() { - let (scoped_entered_sender, scoped_entered_receiver) = mpsc::channel(); - let (release_scoped_sender, release_scoped_receiver) = mpsc::channel(); - let scoped_thread = thread::spawn(move || { - with_env_var(SCOPED_KEY, "scoped", || { - scoped_entered_sender - .send(()) - .expect("scoped callback entry must be reported"); - release_scoped_receiver - .recv() - .expect("scoped callback release must be received"); - }); + with_env_var_removed(SET_PANIC_KEY, || { + let result = catch_unwind(AssertUnwindSafe(|| { + with_env_var(SET_PANIC_KEY, "temporary", || { + panic!("intentional test panic") + }); + })); + + assert!(result.is_err()); + assert!(std::env::var_os(SET_PANIC_KEY).is_none()); }); - scoped_entered_receiver - .recv() - .expect("scoped callback must enter before competing mutation"); - - let (guard_started_sender, guard_started_receiver) = mpsc::channel(); - let (guard_created_sender, guard_created_receiver) = mpsc::channel(); - let guard_thread = thread::spawn(move || { - guard_started_sender - .send(()) - .expect("competing guard attempt must be reported"); - let _guard = EnvVarGuard::set(SCOPED_KEY, "guarded"); - guard_created_sender - .send(()) - .expect("competing guard creation must be reported"); - }); - guard_started_receiver - .recv() - .expect("competing guard attempt must start"); - - assert!( - guard_created_receiver - .recv_timeout(Duration::from_millis(100)) - .is_err(), - "shared guard mutation must wait for the scoped callback" - ); - - release_scoped_sender - .send(()) - .expect("scoped callback must be released"); - scoped_thread - .join() - .expect("scoped callback thread must complete"); - guard_created_receiver - .recv_timeout(Duration::from_secs(1)) - .expect("competing guard must proceed after the scoped callback"); - guard_thread - .join() - .expect("competing guard thread must complete"); } +/// Check that scoped removal restoration still runs when the callback panics. #[test] -fn scoped_mutation_allows_nested_shared_environment_setup() { - with_env_var(SCOPED_KEY, "scoped", || { - let _nested_guard = env_test_guard(); +fn with_env_var_removed_restores_a_value_after_a_panic() { + with_env_var(REMOVE_PANIC_KEY, "original value", || { + let result = catch_unwind(AssertUnwindSafe(|| { + with_env_var_removed(REMOVE_PANIC_KEY, || panic!("intentional test panic")); + })); + + assert!(result.is_err()); assert_eq!( - std::env::var(SCOPED_KEY).expect("scoped environment variable must remain available"), - "scoped" + std::env::var_os(REMOVE_PANIC_KEY).as_deref(), + Some(OsStr::new("original value")) ); }); } +/// Check that concurrent scoped mutations wait for the current callback. #[test] -fn overlapping_env_var_guards_wait_for_mutation_and_restoration() { - { - let _guard = env_test_guard(); - assert!( - std::env::var_os(OVERLAPPING_GUARDS_KEY).is_none(), - "the unique regression key must start absent" - ); - } - let first_guard = spawn_first_guard(); - first_guard.wait_for_creation("first guard must be created before the second starts"); - let (second_guard, second_attempted_receiver) = spawn_second_guard(); - second_attempted_receiver +fn scoped_mutations_wait_for_callback_restoration() { + let original_value = std::env::var_os(OVERLAPPING_KEY); + let first = spawn_scoped_mutation("first"); + let release_first = ReleaseOnDrop(Some(first.release_sender.clone())); + first.wait_for_entry("first callback should enter before the second starts"); + + let (attempted_sender, attempted_receiver) = mpsc::channel(); + let second = { + let (entered_sender, entered_receiver) = mpsc::channel(); + let (release_sender, release_receiver) = mpsc::channel(); + let (restored_sender, restored_receiver) = mpsc::channel(); + let thread = thread::spawn(move || { + attempted_sender + .send(()) + .expect("second mutation attempt must be reported"); + with_env_var(OVERLAPPING_KEY, "second", || { + entered_sender + .send(()) + .expect("second callback entry must be reported"); + release_receiver + .recv() + .expect("second callback release must be received"); + }); + restored_sender + .send(()) + .expect("second environment restoration must be reported"); + }); + ScopedMutationThread { + release_sender, + entered_receiver, + restored_receiver, + thread, + } + }; + let release_second = ReleaseOnDrop(Some(second.release_sender.clone())); + attempted_receiver .recv() - .expect("second guard must attempt construction"); + .expect("second mutation attempt should start"); assert_eq!( - std::env::var_os(OVERLAPPING_GUARDS_KEY).as_deref(), + std::env::var_os(OVERLAPPING_KEY).as_deref(), Some(OsStr::new("first")) ); assert!( - second_guard - .created_receiver - .recv_timeout(Duration::from_millis(100)) - .is_err(), - "the second guard must not mutate before the first restores" - ); - assert!( - second_guard - .dropped_receiver + second + .entered_receiver .recv_timeout(Duration::from_millis(100)) .is_err(), - "the second guard cannot restore before it acquires the shared guard" + "the second callback must wait for the first scoped mutation" ); - first_guard.release("first guard must be released"); - first_guard.wait_for_drop("first guard must restore before the second can proceed"); - second_guard.wait_for_creation("second guard must mutate after the first restores"); + release_first.release(); + first.wait_for_restoration("the first value should restore before the second enters"); + second.wait_for_entry("the second callback should enter after restoration"); assert_eq!( - std::env::var_os(OVERLAPPING_GUARDS_KEY).as_deref(), + std::env::var_os(OVERLAPPING_KEY).as_deref(), Some(OsStr::new("second")) ); - second_guard.release("second guard must be released"); - second_guard.wait_for_drop("second guard must restore after release"); - first_guard.join("first guard thread must complete"); - second_guard.join("second guard thread must complete"); - let _guard = env_test_guard(); - assert!(std::env::var_os(OVERLAPPING_GUARDS_KEY).is_none()); + release_second.release(); + second.wait_for_restoration("the second value should restore after release"); + first.join(); + second.join(); + assert_eq!(std::env::var_os(OVERLAPPING_KEY), original_value); } +/// Check that nested scoped overrides restore the outer value. #[test] -fn locale_scopes_restore_set_and_removed_values() { - let baseline = EnvVarGuard::set("DYLINT_LOCALE", "en-GB"); +fn scoped_mutations_allow_nested_environment_setup() { + with_env_var(SCOPED_KEY, "outer", || { + with_env_var(SCOPED_KEY, "inner", || { + assert_eq!( + std::env::var(SCOPED_KEY).expect("inner scoped value should be available"), + "inner" + ); + }); - with_locale(Some("cy"), || { assert_eq!( - std::env::var_os("DYLINT_LOCALE").as_deref(), - Some(OsStr::new("cy")) + std::env::var(SCOPED_KEY).expect("outer scoped value should be restored"), + "outer" ); + let _guard = env_test_guard(); }); - assert_eq!( - std::env::var_os("DYLINT_LOCALE").as_deref(), - Some(OsStr::new("en-GB")) - ); - drop(baseline); +} - let cleared = EnvVarGuard::remove("DYLINT_LOCALE"); - with_locale(None, || { - assert!(std::env::var_os("DYLINT_LOCALE").is_none()); +/// Check that locale overrides restore both set and absent values. +#[test] +fn locale_scopes_restore_set_and_removed_values() { + with_env_var("DYLINT_LOCALE", "en-GB", || { + with_locale(Some("cy"), || { + assert_eq!( + std::env::var_os("DYLINT_LOCALE").as_deref(), + Some(OsStr::new("cy")) + ); + }); + assert_eq!( + std::env::var_os("DYLINT_LOCALE").as_deref(), + Some(OsStr::new("en-GB")) + ); + + with_locale(None, || { + assert!(std::env::var_os("DYLINT_LOCALE").is_none()) + }); + assert_eq!( + std::env::var_os("DYLINT_LOCALE").as_deref(), + Some(OsStr::new("en-GB")) + ); }); - assert!(std::env::var_os("DYLINT_LOCALE").is_none()); - drop(cleared); } -fn transition_exposes_temporary_value() -> Result<(), TestCaseError> { - let temporary_visible = with_env_var(PROPERTY_KEY, "temporary", || { +/// Exercise scoped environment transitions from several starting values. +fn transition_exposes_temporary_value() -> bool { + with_env_var(PROPERTY_KEY, "temporary", || { std::env::var_os(PROPERTY_KEY).as_deref() == Some(OsStr::new("temporary")) - }); - prop_assert!(temporary_visible); - Ok(()) + }) } -fn transition_removes_temporary_value() -> Result<(), TestCaseError> { - let removed = with_env_var_removed(PROPERTY_KEY, || std::env::var_os(PROPERTY_KEY).is_none()); - prop_assert!(removed); - Ok(()) +/// Exercise scoped removal while the generated baseline is active. +fn transition_removes_temporary_value() -> bool { + with_env_var_removed(PROPERTY_KEY, || std::env::var_os(PROPERTY_KEY).is_none()) } -fn transition_restores_outer_value_after_nested_removal() -> Result<(), TestCaseError> { - let (inner_removed, outer_restored) = with_env_var(PROPERTY_KEY, "outer", || { - let inner_removed = - with_env_var_removed(PROPERTY_KEY, || std::env::var_os(PROPERTY_KEY).is_none()); +/// Exercise nested mutation and restoration of the outer value. +fn transition_restores_outer_value_after_nested_mutation(nested_mutation: NestedMutation) -> bool { + with_env_var(PROPERTY_KEY, "outer", || { + let inner_mutation_applied = match nested_mutation { + NestedMutation::Set => with_env_var(PROPERTY_KEY, "inner", || { + std::env::var_os(PROPERTY_KEY).as_deref() == Some(OsStr::new("inner")) + }), + NestedMutation::Remove => { + with_env_var_removed(PROPERTY_KEY, || std::env::var_os(PROPERTY_KEY).is_none()) + } + }; let outer_restored = std::env::var_os(PROPERTY_KEY).as_deref() == Some(OsStr::new("outer")); - (inner_removed, outer_restored) - }); - prop_assert!(inner_removed); - prop_assert!(outer_restored); - Ok(()) + inner_mutation_applied && outer_restored + }) } -fn transition_restores_value_after_set_callback_panic() -> Result<(), TestCaseError> { +/// Exercise restoration after a set callback panics. +fn transition_restores_value_after_set_callback_panic() -> bool { let panic = catch_unwind(AssertUnwindSafe(|| { with_env_var(PROPERTY_KEY, "temporary", || panic!("test panic")); })); - prop_assert!(panic.is_err()); - Ok(()) -} - -fn transition_drops_competing_guard() -> Result<(), TestCaseError> { - let competing_guard = EnvVarGuard::set(PROPERTY_KEY, "guarded"); - drop(competing_guard); - Ok(()) + panic.is_err() } -fn transition_restores_value_after_remove_callback_panic() -> Result<(), TestCaseError> { +/// Exercise restoration after a removal callback panics. +fn transition_restores_value_after_remove_callback_panic() -> bool { let panic = catch_unwind(AssertUnwindSafe(|| { with_env_var_removed(PROPERTY_KEY, || panic!("test panic")); })); - prop_assert!(panic.is_err()); + panic.is_err() +} + +/// Run a generated sequence and confirm every scoped transition restores its baseline. +fn run_transitions(initial: Option<&str>, transitions: &[u8]) -> Result<(), TestCaseError> { + for transition in transitions { + let transition_succeeded = match transition { + 0 => transition_exposes_temporary_value(), + 1 => transition_removes_temporary_value(), + 2 => transition_restores_outer_value_after_nested_mutation(NestedMutation::Remove), + 3 => transition_restores_outer_value_after_nested_mutation(NestedMutation::Set), + 4 => transition_restores_value_after_set_callback_panic(), + 5 => transition_restores_value_after_remove_callback_panic(), + _ => unreachable!("the transition strategy only yields values below six"), + }; + prop_assert!(transition_succeeded); + + let restored = std::env::var_os(PROPERTY_KEY); + prop_assert_eq!(restored.as_deref(), initial.map(OsStr::new)); + } Ok(()) } @@ -360,28 +343,10 @@ proptest! { initial in prop_oneof![Just(None), Just(Some("first")), Just(Some("second"))], transitions in prop::collection::vec(0_u8..6, 1..8), ) { - let _protocol = env_test_guard(); - let baseline = match initial { - Some(value) => EnvVarGuard::set(PROPERTY_KEY, value), - None => EnvVarGuard::remove(PROPERTY_KEY), + let result = match initial { + Some(value) => with_env_var(PROPERTY_KEY, value, || run_transitions(Some(value), &transitions)), + None => with_env_var_removed(PROPERTY_KEY, || run_transitions(None, &transitions)), }; - - for transition in transitions { - match transition { - 0 => transition_exposes_temporary_value()?, - 1 => transition_removes_temporary_value()?, - 2 => transition_restores_outer_value_after_nested_removal()?, - 3 => transition_restores_value_after_set_callback_panic()?, - 4 => transition_drops_competing_guard()?, - 5 => transition_restores_value_after_remove_callback_panic()?, - _ => unreachable!("the transition strategy only yields values below six"), - } - - let restored = std::env::var_os(PROPERTY_KEY); - let expected = initial.map(OsStr::new); - prop_assert_eq!(restored.as_deref(), expected); - } - - drop(baseline); + result?; } } diff --git a/common/src/test_support/mod.rs b/common/src/test_support/mod.rs index a12e0203d..8feb9f910 100644 --- a/common/src/test_support/mod.rs +++ b/common/src/test_support/mod.rs @@ -13,8 +13,6 @@ //! variables. //! - [`ui`]: Discovers fixtures, prepares isolated workspaces, and runs dylint UI tests with //! consistent panic handling. -//! - [`EnvVarGuard`]: Sets or removes one environment variable and restores -//! its prior state on drop. //! - [`with_locale`], [`with_env_var`], and [`with_env_var_removed`]: Scope temporary environment //! mutations (such as `DYLINT_LOCALE` overrides) to a callback so tests cannot leak global state //! between cases. @@ -23,10 +21,7 @@ pub mod decomposition; pub mod fixtures; pub mod ui; -use std::{ - ffi::{OsStr, OsString}, - sync::OnceLock, -}; +use std::{ffi::OsStr, sync::OnceLock}; use parking_lot::{ReentrantMutex, ReentrantMutexGuard}; @@ -37,138 +32,40 @@ pub use ui::{ run_test_runner_with_env_guard, }; -/// Held while serializing process-wide environment mutations in tests. +/// Serializes test protocols that explicitly coordinate environment access. +/// +/// The scoped `with_env_var`, `with_env_var_removed`, and `with_locale` helpers +/// use `temp_env`'s own re-entrant lock. This mutex is for multi-step test +/// protocols whose participants explicitly acquire the same guard. /// -/// This alias keeps callers coupled to the shared test-support contract rather -/// than its lock implementation. +/// The re-entrant lock guards `()` and is used only for serialization, so a +/// protocol can safely call another helper that acquires the same guard. pub type EnvTestGuard = ReentrantMutexGuard<'static, ()>; -/// Serializes tests that mutate process-wide environment variables. +/// Acquire the shared guard for environment-sensitive test protocols. /// -/// Use this guard around environment mutations when the test would otherwise -/// race with other cases changing the same global process state. +/// Hold the guard across a multi-step protocol that must not interleave with +/// other tests mutating the process environment. Re-entrancy permits that +/// protocol to call another helper that acquires the same guard. +/// +/// # Examples /// -/// The lock is reentrant so a callback that already holds the shared -/// serialization token can safely call another shared environment helper. -/// This permits scoped overrides to wrap UI runners, which guard their own -/// setup and restoration. The guarded value is `()`, so no mutable state can -/// be accessed through the guard. +/// ``` +/// use whitaker_common::test_support::env_test_guard; +/// +/// let _guard = env_test_guard(); +/// ``` pub fn env_test_guard() -> EnvTestGuard { static LOCK: OnceLock> = OnceLock::new(); LOCK.get_or_init(|| ReentrantMutex::new(())).lock() } -/// Guard that sets one environment variable and restores its prior state. -/// -/// The guard retains [`env_test_guard`] from construction until after it -/// restores the prior value in [`Drop`]. This prevents another thread from -/// observing or restoring an interleaved environment value. The shared lock is -/// reentrant, so callers can still nest helpers that acquire it on the same -/// thread. -pub struct EnvVarGuard { - _env_guard: EnvTestGuard, - key: String, - previous: Option, -} - -enum EnvVarMutation<'a> { - Set(&'a OsStr), - Remove, -} - -impl EnvVarGuard { - /// Sets `key` to `value`, returning a guard that restores the previous - /// value or removes the variable when dropped. - /// - /// # Examples - /// - /// ```rust - /// use whitaker_common::test_support::EnvVarGuard; - /// - /// let _guard = EnvVarGuard::set("WHITAKER_TEST_ENV_VAR", "enabled"); - /// assert_eq!( - /// std::env::var("WHITAKER_TEST_ENV_VAR").expect("test env var should be set"), - /// "enabled", - /// ); - /// ``` - #[must_use] - pub fn set(key: &'static str, value: impl AsRef) -> Self { - Self::set_scoped(key, value) - } - - /// Sets a callback-supplied key while retaining the shared guard. - fn set_scoped(key: &str, value: impl AsRef) -> Self { - Self::mutate_scoped(key, EnvVarMutation::Set(value.as_ref())) - } - - /// Removes a callback-supplied key while retaining the shared guard. - fn remove_scoped(key: &str) -> Self { - Self::mutate_scoped(key, EnvVarMutation::Remove) - } - - /// Applies `mutation` while retaining the shared guard until restoration. - /// - /// Keeping this guard in the returned value serializes construction, use, - /// and [`Drop`] as one process-environment transaction. - fn mutate_scoped(key: &str, mutation: EnvVarMutation<'_>) -> Self { - let env_guard = env_test_guard(); - let previous = std::env::var_os(key); - // SAFETY: `env_test_guard` serializes this environment mutation. - unsafe { - match mutation { - EnvVarMutation::Set(value) => std::env::set_var(key, value), - EnvVarMutation::Remove => std::env::remove_var(key), - } - } - Self { - _env_guard: env_guard, - key: key.to_owned(), - previous, - } - } - - /// Removes `key`, returning a guard that restores the previous value when - /// dropped. - /// - /// # Examples - /// - /// ```rust - /// use whitaker_common::test_support::EnvVarGuard; - /// - /// let _guard = EnvVarGuard::remove("WHITAKER_REMOVED_TEST_ENV_VAR"); - /// assert!(std::env::var_os("WHITAKER_REMOVED_TEST_ENV_VAR").is_none()); - /// ``` - #[must_use] - pub fn remove(key: &'static str) -> Self { - Self::remove_scoped(key) - } -} - -impl Drop for EnvVarGuard { - fn drop(&mut self) { - match &self.previous { - Some(previous) => { - // SAFETY: the retained `env_test_guard` serializes this restoration. - unsafe { - std::env::set_var(&self.key, previous); - } - } - None => { - // SAFETY: the retained `env_test_guard` serializes this restoration. - unsafe { - std::env::remove_var(&self.key); - } - } - } - } -} - /// Runs `callback` with one environment variable temporarily set. /// /// The mutation is scoped to the callback and the prior value is restored -/// afterwards, even on panic. The callback holds [`env_test_guard`], so its -/// mutation and restoration cannot interleave with [`EnvVarGuard`] or manually -/// guarded environment mutations. +/// afterwards, even on panic. Serialization is provided by `temp_env`'s +/// re-entrant global lock, so nested scoped mutations from the same thread +/// do not deadlock. /// /// # Examples /// @@ -183,16 +80,13 @@ impl Drop for EnvVarGuard { /// }); /// ``` pub fn with_env_var(key: &str, value: impl AsRef, callback: impl FnOnce() -> R) -> R { - let _env_guard = env_test_guard(); - let _variable_guard = EnvVarGuard::set_scoped(key, value); - callback() + temp_env::with_var(key, Some(value.as_ref()), callback) } /// Runs `callback` with one environment variable temporarily removed. /// /// The prior value (if any) is restored after the callback completes or -/// panics. The callback holds [`env_test_guard`] so its mutation and -/// restoration cannot interleave with other shared environment helpers. +/// panics. /// /// # Examples /// @@ -204,17 +98,14 @@ pub fn with_env_var(key: &str, value: impl AsRef, callback: impl FnOnc /// }); /// ``` pub fn with_env_var_removed(key: &str, callback: impl FnOnce() -> R) -> R { - let _env_guard = env_test_guard(); - let _variable_guard = EnvVarGuard::remove_scoped(key); - callback() + temp_env::with_var_unset(key, callback) } /// Runs `callback` with `DYLINT_LOCALE` overridden. /// /// `Some(locale)` sets the variable for the duration of the callback; /// `None` removes it. Any prior value is restored afterwards, so -/// locale-sensitive tests cannot leak global state between cases. The callback -/// remains within the shared reentrant environment-mutation protocol. +/// locale-sensitive tests cannot leak global state between cases. /// /// # Examples /// diff --git a/common/tests/localizer_helpers.rs b/common/tests/localizer_helpers.rs index 37160e702..b5e19caef 100644 --- a/common/tests/localizer_helpers.rs +++ b/common/tests/localizer_helpers.rs @@ -13,7 +13,7 @@ use whitaker_common::{ Arguments, DiagnosticMessageSet, FluentValue, Localizer, MessageKey, MessageResolution, get_localizer_for_lint, noop_reporter, safe_resolve_message_set, testing::RecordingEmitter, }, - test_support::{EnvTestGuard, env_test_guard, with_locale}, + test_support::with_locale, }; use whitaker_test_macros::allow_fixture_expansion_lints; @@ -33,16 +33,11 @@ struct HelperWorld { result: RefCell>, emitter: RecordingEmitter, environment_locale: RefCell>, - _guard: EnvTestGuard, } impl HelperWorld { /// Creates a scenario world that holds the shared environment protocol. fn new() -> Self { - // Keep the shared guard for the scenario lifetime so locale changes - // cannot interleave with other process-environment test helpers. - let guard = env_test_guard(); - Self { configuration: RefCell::new(None), localizer: RefCell::new(None), @@ -52,7 +47,6 @@ impl HelperWorld { result: RefCell::new(None), emitter: RecordingEmitter::default(), environment_locale: RefCell::new(None), - _guard: guard, } } diff --git a/common/tests/public_api_contract.rs b/common/tests/public_api_contract.rs index 9e5ce872f..4b4015eae 100644 --- a/common/tests/public_api_contract.rs +++ b/common/tests/public_api_contract.rs @@ -4,6 +4,7 @@ #[test] fn renamed_common_apis_are_public_and_legacy_names_stay_removed() { let cases = trybuild::TestCases::new(); + cases.pass("tests/ui/pass_const_apis.rs"); cases.pass("tests/ui/pass_renamed_common_apis.rs"); cases.compile_fail("tests/ui/legacy_common_apis.rs"); } diff --git a/common/tests/ui/pass_const_apis.rs b/common/tests/ui/pass_const_apis.rs new file mode 100644 index 000000000..fc165a471 --- /dev/null +++ b/common/tests/ui/pass_const_apis.rs @@ -0,0 +1,17 @@ +//! Compile-pass checks for const public common APIs in an external crate. + +use whitaker_common::span::{SourceLocation, SourceSpan}; + +const START: SourceLocation = SourceLocation::new(2, 3); +const END: SourceLocation = SourceLocation::new(4, 5); +const SPAN: Result = + SourceSpan::new(START, END); +const SPAN_START_LINE: usize = match SPAN { + Ok(span) => span.start().line(), + Err(_) => 0, +}; + +/// Assert that the public const APIs work in an external crate context. +fn main() { + assert_eq!(SPAN_START_LINE, 2); +} diff --git a/crates/bumpy_road_function/src/analysis.rs b/crates/bumpy_road_function/src/analysis.rs index d7aaabd44..0105a4310 100644 --- a/crates/bumpy_road_function/src/analysis.rs +++ b/crates/bumpy_road_function/src/analysis.rs @@ -98,7 +98,7 @@ pub fn normalize_settings(settings: Settings) -> Settings { } } - fn is_valid_window(window: usize) -> bool { + const fn is_valid_window(window: usize) -> bool { window != 0 && (window & 1) == 1 } @@ -239,6 +239,11 @@ pub fn detect_bumps(smoothed: &[f64], threshold: f64, min_bump_lines: usize) -> context.into_intervals() } +#[expect( + clippy::float_arithmetic, + reason = "bump areas accumulate smoothed complexity samples, which are inherently \ + floating-point" +)] fn process_sample_value(value: f64, index: usize, context: &mut BumpDetectionContext) { if value >= context.threshold { if context.current_start.is_none() { @@ -260,7 +265,7 @@ fn process_sample_value(value: f64, index: usize, context: &mut BumpDetectionCon context.area = 0.0; } -fn finalize_bump( +const fn finalize_bump( start: usize, end: usize, area: f64, diff --git a/crates/bumpy_road_function/src/driver/config.rs b/crates/bumpy_road_function/src/driver/config.rs index 4388791bb..b69785f93 100644 --- a/crates/bumpy_road_function/src/driver/config.rs +++ b/crates/bumpy_road_function/src/driver/config.rs @@ -52,7 +52,7 @@ impl Default for Config { } impl Config { - pub(super) fn into_settings(self) -> Settings { + pub(super) const fn into_settings(self) -> Settings { Settings { threshold: self.threshold, window: self.window, diff --git a/crates/bumpy_road_function/src/driver/diagnostic.rs b/crates/bumpy_road_function/src/driver/diagnostic.rs index b954c5c07..023bc462d 100644 --- a/crates/bumpy_road_function/src/driver/diagnostic.rs +++ b/crates/bumpy_road_function/src/driver/diagnostic.rs @@ -55,8 +55,8 @@ pub(super) fn emit_diagnostic( BUMPY_ROAD_FUNCTION, input.primary_span, rustc_lint::errors::DiagDecorator(|lint| { - lint.primary_message(messages.primary().to_string()); - lint.span_note(input.primary_span, messages.note().to_string()); + lint.primary_message(messages.primary().to_owned()); + lint.span_note(input.primary_span, messages.note().to_owned()); for (ordinal, interval) in highlighted.iter().enumerate() { let Some(span) = bump_spans.get(ordinal).copied().flatten() else { @@ -70,7 +70,7 @@ pub(super) fn emit_diagnostic( lint.span_label(span, label); } - lint.help(messages.help().to_string()); + lint.help(messages.help().to_owned()); }), ); } @@ -136,7 +136,12 @@ struct LineSpanMapper { } impl LineSpanMapper { - fn new(base_span: Span, snippet_len: usize, base_line: usize, line_starts: Vec) -> Self { + const fn new( + base_span: Span, + snippet_len: usize, + base_line: usize, + line_starts: Vec, + ) -> Self { Self { base_span, snippet_len, diff --git a/crates/bumpy_road_function/src/driver/mod.rs b/crates/bumpy_road_function/src/driver/mod.rs index e5b1851f5..4ee8fe362 100644 --- a/crates/bumpy_road_function/src/driver/mod.rs +++ b/crates/bumpy_road_function/src/driver/mod.rs @@ -29,13 +29,32 @@ use self::config::load_configuration; use self::diagnostic::{DiagnosticInput, emit_diagnostic}; use self::segment_builder::{SegmentBuilder, span_line_range}; -dylint_linting::impl_late_lint! { - pub BUMPY_ROAD_FUNCTION, - Warn, - "functions should avoid multiple separated clusters of complex conditional logic", - BumpyRoadFunction::default() +/// Dylint lint declaration and registration glue. +/// +/// `impl_late_lint!` expands to the Dylint ABI entry point and the +/// `impl_lint_pass!` accessor, neither of which has a source location that +/// could carry documentation. Isolating the invocation keeps the expectation +/// scoped to exactly those generated items. +mod declaration { + #![expect( + missing_docs, + reason = "dylint_linting macro expansion emits items with no documentable source location" + )] + + use super::BumpyRoadFunction; + + dylint_linting::impl_late_lint! { + /// Lint flagging functions with multiple separated clusters of complex + /// conditional logic ("bumpy road" complexity profiles). + pub BUMPY_ROAD_FUNCTION, + Warn, + "functions should avoid multiple separated clusters of complex conditional logic", + BumpyRoadFunction::default() + } } +pub use declaration::BUMPY_ROAD_FUNCTION; + /// Lint pass that caches configuration and localization for a crate. pub struct BumpyRoadFunction { settings: Settings, @@ -67,7 +86,7 @@ impl<'tcx> LateLintPass<'tcx> for BumpyRoadFunction { return; }; - self.analyse_if_not_expanded(cx, item.span, target); + self.analyse_if_not_expanded(cx, item.span, &target); } fn check_impl_item(&mut self, cx: &LateContext<'tcx>, item: &'tcx hir::ImplItem<'tcx>) { @@ -75,7 +94,7 @@ impl<'tcx> LateLintPass<'tcx> for BumpyRoadFunction { return; }; - self.analyse_if_not_expanded(cx, item.span, target); + self.analyse_if_not_expanded(cx, item.span, &target); } fn check_trait_item(&mut self, cx: &LateContext<'tcx>, item: &'tcx hir::TraitItem<'tcx>) { @@ -83,7 +102,7 @@ impl<'tcx> LateLintPass<'tcx> for BumpyRoadFunction { return; }; - self.analyse_if_not_expanded(cx, item.span, target); + self.analyse_if_not_expanded(cx, item.span, &target); } fn check_expr(&mut self, cx: &LateContext<'tcx>, expr: &'tcx hir::Expr<'tcx>) { @@ -95,12 +114,12 @@ impl<'tcx> LateLintPass<'tcx> for BumpyRoadFunction { return; }; - self.analyse_if_not_expanded(cx, expr.span, target); + self.analyse_if_not_expanded(cx, expr.span, &target); } } impl BumpyRoadFunction { - fn analyse_if_not_expanded(&self, cx: &LateContext<'_>, span: Span, target: AnalysisTarget) { + fn analyse_if_not_expanded(&self, cx: &LateContext<'_>, span: Span, target: &AnalysisTarget) { if span.from_expansion() { return; } @@ -109,7 +128,7 @@ impl BumpyRoadFunction { } } -fn extract_item_target(item: &hir::Item<'_>) -> Option { +const fn extract_item_target(item: &hir::Item<'_>) -> Option { let hir::ItemKind::Fn { ident, body, .. } = item.kind else { return None; }; @@ -117,7 +136,7 @@ fn extract_item_target(item: &hir::Item<'_>) -> Option { Some(make_ident_target(ident, body)) } -fn extract_impl_item_target(item: &hir::ImplItem<'_>) -> Option { +const fn extract_impl_item_target(item: &hir::ImplItem<'_>) -> Option { if let hir::ImplItemKind::Fn(_, body_id) = item.kind { return Some(make_analysis_target( item.ident.name, @@ -129,7 +148,7 @@ fn extract_impl_item_target(item: &hir::ImplItem<'_>) -> Option None } -fn extract_trait_item_target(item: &hir::TraitItem<'_>) -> Option { +const fn extract_trait_item_target(item: &hir::TraitItem<'_>) -> Option { let hir::TraitItemKind::Fn(_, trait_fn) = item.kind else { return None; }; @@ -157,7 +176,11 @@ fn extract_expr_target(expr: &hir::Expr<'_>) -> Option { )) } -fn make_analysis_target(name: Symbol, primary_span: Span, body_id: hir::BodyId) -> AnalysisTarget { +const fn make_analysis_target( + name: Symbol, + primary_span: Span, + body_id: hir::BodyId, +) -> AnalysisTarget { AnalysisTarget { name, primary_span, @@ -165,7 +188,7 @@ fn make_analysis_target(name: Symbol, primary_span: Span, body_id: hir::BodyId) } } -fn make_ident_target(ident: Ident, body_id: hir::BodyId) -> AnalysisTarget { +const fn make_ident_target(ident: Ident, body_id: hir::BodyId) -> AnalysisTarget { make_analysis_target(ident.name, ident.span, body_id) } @@ -182,7 +205,7 @@ struct AnalysisTarget { /// smoothing failures are reported as delayed compiler bugs and stop analysis. fn analyse_body( cx: &LateContext<'_>, - target: AnalysisTarget, + target: &AnalysisTarget, settings: &Settings, localizer: &Localizer, ) { @@ -213,7 +236,7 @@ fn analyse_body( }; let smoothed = match smooth_moving_average(&signal, settings.window) { - Ok(signal) => signal, + Ok(smoothed) => smoothed, Err(error) => { cx.tcx.sess.dcx().span_delayed_bug( body_span, diff --git a/crates/bumpy_road_function/src/driver/numeric.rs b/crates/bumpy_road_function/src/driver/numeric.rs index 37ae8c9e5..a06fa1614 100644 --- a/crates/bumpy_road_function/src/driver/numeric.rs +++ b/crates/bumpy_road_function/src/driver/numeric.rs @@ -24,6 +24,10 @@ pub(super) fn diagnostic_integer_argument(value: usize) -> i64 { } /// Compute the production predicate weight with a bounded branch count. +#[expect( + clippy::float_arithmetic, + reason = "predicate weights are floating-point tuning parameters scaled by branch counts" +)] pub(super) fn predicate_segment_weight(branches: usize, weight: f64) -> f64 { f64::from(saturating_branch_count( u64::try_from(branches).unwrap_or(u64::MAX), diff --git a/crates/bumpy_road_function/src/driver/segment_builder.rs b/crates/bumpy_road_function/src/driver/segment_builder.rs index 324e17913..0852fde2c 100644 --- a/crates/bumpy_road_function/src/driver/segment_builder.rs +++ b/crates/bumpy_road_function/src/driver/segment_builder.rs @@ -57,7 +57,7 @@ pub(super) struct SegmentBuilder<'a, 'tcx> { } impl<'a, 'tcx> SegmentBuilder<'a, 'tcx> { - pub(super) fn new( + pub(super) const fn new( cx: &'a LateContext<'tcx>, settings: &'a Settings, function_lines: RangeInclusive, @@ -235,7 +235,7 @@ impl<'a, 'tcx> SegmentBuilder<'a, 'tcx> { } } -impl<'a, 'tcx> rustc_hir::intravisit::Visitor<'tcx> for SegmentBuilder<'a, 'tcx> { +impl<'tcx> rustc_hir::intravisit::Visitor<'tcx> for SegmentBuilder<'_, 'tcx> { fn visit_expr(&mut self, expr: &'tcx hir::Expr<'tcx>) { Self::visit_expr(self, expr); } diff --git a/crates/bumpy_road_function/src/lib.rs b/crates/bumpy_road_function/src/lib.rs index b3f3ced3b..8eac58230 100644 --- a/crates/bumpy_road_function/src/lib.rs +++ b/crates/bumpy_road_function/src/lib.rs @@ -11,8 +11,13 @@ pub mod analysis; #[cfg(feature = "dylint-driver")] mod driver; +// Re-export only the documented lint surface. `impl_late_lint!` also expands +// to the Dylint ABI entry point and lint-pass glue, which have no source +// location that could carry documentation; keeping them out of the public +// path satisfies `missing_docs` without suppressing it. The `no_mangle` +// symbol is still exported from the cdylib for standalone Dylint loading. #[cfg(feature = "dylint-driver")] -pub use driver::*; +pub use driver::{BUMPY_ROAD_FUNCTION, BumpyRoadFunction}; #[cfg(not(feature = "dylint-driver"))] mod stub { diff --git a/crates/clippy_utils/src/lib.rs b/crates/clippy_utils/src/lib.rs index 34d4c9290..200948252 100644 --- a/crates/clippy_utils/src/lib.rs +++ b/crates/clippy_utils/src/lib.rs @@ -7,6 +7,8 @@ #![feature(rustc_private)] +/// Macro-related helpers mirroring the subset of Clippy's `macros` module +/// used by Whitaker lints. pub mod macros { use rustc_hir as hir; use rustc_lint::LateContext; @@ -37,7 +39,7 @@ pub mod macros { return false; }; - let def_id = cx + let resolved = cx .typeck_results() .type_dependent_def_id(callee.hir_id) .or_else(|| match callee.kind { @@ -45,7 +47,7 @@ pub mod macros { _ => None, }); - let Some(def_id) = def_id else { + let Some(def_id) = resolved else { return false; }; diff --git a/crates/conditional_max_n_branches/src/driver.rs b/crates/conditional_max_n_branches/src/driver.rs index 410e842fe..1308a0fa4 100644 --- a/crates/conditional_max_n_branches/src/driver.rs +++ b/crates/conditional_max_n_branches/src/driver.rs @@ -69,6 +69,10 @@ mod registration { //! 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. + #![expect( + missing_docs, + reason = "dylint_linting macro expansion emits items with no documentable source location" + )] use super::ConditionalMaxNBranches; @@ -300,7 +304,7 @@ fn normalize_isolation_marks(text: &str) -> String { }) .collect() } else { - text.to_string() + text.to_owned() } } diff --git a/crates/conditional_max_n_branches/src/lib.rs b/crates/conditional_max_n_branches/src/lib.rs index 6f94e6016..6abe4970f 100644 --- a/crates/conditional_max_n_branches/src/lib.rs +++ b/crates/conditional_max_n_branches/src/lib.rs @@ -4,8 +4,13 @@ #[cfg(feature = "dylint-driver")] mod driver; +// Re-export only the documented lint surface. `impl_late_lint!` also expands +// to the Dylint ABI entry point and lint-pass glue, which have no source +// location that could carry documentation; keeping them out of the public +// path satisfies `missing_docs` without suppressing it. The `no_mangle` +// symbol is still exported from the cdylib for standalone Dylint loading. #[cfg(feature = "dylint-driver")] -pub use driver::*; +pub use driver::{CONDITIONAL_MAX_N_BRANCHES, ConditionalMaxNBranches}; #[cfg(not(feature = "dylint-driver"))] mod stub { diff --git a/crates/function_attrs_follow_docs/src/driver.rs b/crates/function_attrs_follow_docs/src/driver.rs index bd2b81722..6b10eb21b 100644 --- a/crates/function_attrs_follow_docs/src/driver.rs +++ b/crates/function_attrs_follow_docs/src/driver.rs @@ -32,13 +32,31 @@ impl Default for FunctionAttrsFollowDocs { } } -dylint_linting::impl_late_lint! { - pub FUNCTION_ATTRS_FOLLOW_DOCS, - Warn, - "doc comments on functions must precede other outer attributes", - FunctionAttrsFollowDocs::default() +/// Dylint lint declaration and registration glue. +/// +/// `impl_late_lint!` expands to the Dylint ABI entry point and the +/// `impl_lint_pass!` accessor, neither of which has a source location that +/// could carry documentation. Isolating the invocation keeps the expectation +/// scoped to exactly those generated items. +mod declaration { + #![expect( + missing_docs, + reason = "dylint_linting macro expansion emits items with no documentable source location" + )] + + use super::FunctionAttrsFollowDocs; + + dylint_linting::impl_late_lint! { + /// Warns when outer attributes on a function precede its doc comments. + pub FUNCTION_ATTRS_FOLLOW_DOCS, + Warn, + "doc comments on functions must precede other outer attributes", + FunctionAttrsFollowDocs::default() + } } +pub use declaration::FUNCTION_ATTRS_FOLLOW_DOCS; + impl<'tcx> LateLintPass<'tcx> for FunctionAttrsFollowDocs { fn check_crate(&mut self, _cx: &LateContext<'tcx>) { let shared_config = SharedConfig::load(); @@ -50,7 +68,7 @@ impl<'tcx> LateLintPass<'tcx> for FunctionAttrsFollowDocs { if let hir::ItemKind::Fn { .. } = item.kind { self.check_item_attributes( cx, - ItemInfo::new(item.hir_id(), item.span, FunctionKind::Function), + &ItemInfo::new(item.hir_id(), item.span, FunctionKind::Function), ); } } @@ -59,7 +77,7 @@ impl<'tcx> LateLintPass<'tcx> for FunctionAttrsFollowDocs { if let hir::ImplItemKind::Fn(..) = item.kind { self.check_item_attributes( cx, - ItemInfo::new(item.hir_id(), item.span, FunctionKind::Method), + &ItemInfo::new(item.hir_id(), item.span, FunctionKind::Method), ); } } @@ -68,7 +86,7 @@ impl<'tcx> LateLintPass<'tcx> for FunctionAttrsFollowDocs { if let hir::TraitItemKind::Fn(..) = item.kind { self.check_item_attributes( cx, - ItemInfo::new(item.hir_id(), item.span, FunctionKind::TraitMethod), + &ItemInfo::new(item.hir_id(), item.span, FunctionKind::TraitMethod), ); } } @@ -82,15 +100,15 @@ struct ItemInfo { } impl ItemInfo { - fn new(hir_id: hir::HirId, span: Span, kind: FunctionKind) -> Self { + const fn new(hir_id: hir::HirId, span: Span, kind: FunctionKind) -> Self { Self { hir_id, span, kind } } } impl<'tcx> FunctionAttrsFollowDocs { - fn check_item_attributes(&self, cx: &LateContext<'tcx>, item: ItemInfo) { + fn check_item_attributes(&self, cx: &LateContext<'tcx>, item: &ItemInfo) { let attrs = cx.tcx.hir_attrs(item.hir_id); - check_function_attributes(FunctionAttributeCheck { + check_function_attributes(&FunctionAttributeCheck { cx, attrs, item_span: item.span, @@ -195,7 +213,7 @@ impl AttrInfo { (span.lo(), span.hi()) } - fn user_editable_span(&self) -> Option { + const fn user_editable_span(&self) -> Option { self.user_editable_span } } @@ -223,7 +241,7 @@ struct FunctionAttributeCheck<'tcx, 'a> { localizer: &'a Localizer, } -fn check_function_attributes(check: FunctionAttributeCheck<'_, '_>) { +fn check_function_attributes(check: &FunctionAttributeCheck<'_, '_>) { let item_user_editable_span = recover_user_editable_hir_span(check.item_span); let mut infos: Vec = check .attrs @@ -238,14 +256,15 @@ fn check_function_attributes(check: FunctionAttributeCheck<'_, '_>) { ) }); // Attribute macros can reorder attributes in HIR; rely on source order instead. - infos.sort_by_key(|info| info.source_order_key()); + infos.sort_by_key(AttrInfo::source_order_key); let Some((doc_index, offending_index)) = detect_misordered_doc(infos.as_slice()) else { return; }; - let doc = &infos[doc_index]; - let offending = &infos[offending_index]; + let (Some(doc), Some(offending)) = (infos.get(doc_index), infos.get(offending_index)) else { + return; + }; let diagnostic_context = DiagnosticContext { doc_span: doc.span(), offending_span: offending.span(), @@ -265,7 +284,7 @@ fn attribute_within_item( item_span: Option, raw_item_span: Span, ) -> bool { - let Some(attribute_span) = attribute_span else { + let Some(editable_attribute_span) = attribute_span else { return false; }; @@ -273,14 +292,15 @@ fn attribute_within_item( return true; } - let item_span = item_span.unwrap_or(raw_item_span); + let bounding_item_span = item_span.unwrap_or(raw_item_span); // Modern nightlies exclude attributes from the item span, so outer // attributes sit immediately before it. Accept spans contained in the // item (older behaviour and inner attributes) or preceding it (outer // attributes on current nightlies). - let contained = attribute_span.lo() >= item_span.lo() && attribute_span.hi() <= item_span.hi(); - let precedes = attribute_span.hi() <= item_span.lo(); + let contained = editable_attribute_span.lo() >= bounding_item_span.lo() + && editable_attribute_span.hi() <= bounding_item_span.hi(); + let precedes = editable_attribute_span.hi() <= bounding_item_span.lo(); contained || precedes } @@ -312,9 +332,9 @@ fn emit_diagnostic(cx: &LateContext<'_>, context: DiagnosticContext, localizer: let kind = context.kind; move || fallback_messages(kind, attribute.as_str()) }); - let primary = messages.primary().to_string(); - let note = messages.note().to_string(); - let help = messages.help().to_string(); + let primary = messages.primary().to_owned(); + let note = messages.note().to_owned(); + let help = messages.help().to_owned(); cx.emit_span_lint( FUNCTION_ATTRS_FOLLOW_DOCS, @@ -342,7 +362,7 @@ fn localized_messages( args.insert(Cow::Borrowed("subject"), FluentValue::from(kind.subject())); args.insert( Cow::Borrowed("attribute"), - FluentValue::from(attribute.to_string()), + FluentValue::from(attribute.to_owned()), ); resolve_message_set(lookup, MESSAGE_KEY, &args) @@ -353,17 +373,17 @@ fn fallback_messages(kind: FunctionKind, attribute: &str) -> FunctionAttrsMessag "Doc comments on {} must precede other outer attributes.", kind.subject() ); - let note = format!("The outer attribute {attribute} appears before the doc comment.",); - let help = format!("Move the doc comment so it appears before {attribute} on the item.",); + let note = format!("The outer attribute {attribute} appears before the doc comment."); + let help = format!("Move the doc comment so it appears before {attribute} on the item."); FunctionAttrsMessages::new(primary, note, help) } fn attribute_label(cx: &LateContext<'_>, span: Span, localizer: &Localizer) -> String { - match cx.sess().source_map().span_to_snippet(span) { - Ok(snippet) => snippet.trim().to_string(), - Err(_) => attribute_fallback(localizer), - } + cx.sess().source_map().span_to_snippet(span).map_or_else( + |_| attribute_fallback(localizer), + |snippet| snippet.trim().to_owned(), + ) } fn attribute_fallback(lookup: &impl BundleLookup) -> String { @@ -371,7 +391,7 @@ fn attribute_fallback(lookup: &impl BundleLookup) -> String { lookup .message(MessageKey::new("common-attribute-fallback"), &args) - .unwrap_or_else(|_| "the preceding attribute".to_string()) + .unwrap_or_else(|_| "the preceding attribute".to_owned()) } /// Recover the source span of a parsed attribute kind. @@ -386,7 +406,7 @@ fn attribute_fallback(lookup: &impl BundleLookup) -> String { /// deliberately not recovered until the ordering check needs them. Only /// variants whose shape is identical on the currently supported nightlies /// are matched; further kinds can be added as the pin advances. -fn parsed_attribute_span(kind: &AttributeKind) -> Option { +const fn parsed_attribute_span(kind: &AttributeKind) -> Option { match kind { AttributeKind::DocComment { span, .. } | AttributeKind::Ignore { span, .. } @@ -402,6 +422,7 @@ fn parsed_attribute_span(kind: &AttributeKind) -> Option { _ => None, } } + fn detect_misordered_doc(attrs: &[A]) -> Option<(usize, usize)> where A: OrderedAttribute, diff --git a/crates/function_attrs_follow_docs/src/lib.rs b/crates/function_attrs_follow_docs/src/lib.rs index 23f486797..c1535c6be 100644 --- a/crates/function_attrs_follow_docs/src/lib.rs +++ b/crates/function_attrs_follow_docs/src/lib.rs @@ -15,8 +15,13 @@ #[cfg(feature = "dylint-driver")] mod driver; +// Re-export only the documented lint surface. `impl_late_lint!` also expands +// to the Dylint ABI entry point and lint-pass glue, which have no source +// location that could carry documentation; keeping them out of the public +// path satisfies `missing_docs` without suppressing it. The `no_mangle` +// symbol is still exported from the cdylib for standalone Dylint loading. #[cfg(feature = "dylint-driver")] -pub use driver::*; +pub use driver::{FUNCTION_ATTRS_FOLLOW_DOCS, FunctionAttrsFollowDocs}; #[cfg(not(feature = "dylint-driver"))] mod stub { diff --git a/crates/module_max_lines/src/driver.rs b/crates/module_max_lines/src/driver.rs index 797ba851c..27591960c 100644 --- a/crates/module_max_lines/src/driver.rs +++ b/crates/module_max_lines/src/driver.rs @@ -17,6 +17,7 @@ use whitaker_common::i18n::{ }; const LINT_NAME: &str = "module_max_lines"; + const MESSAGE_KEY: MessageKey<'static> = MessageKey::new("module_max_lines"); #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -26,13 +27,31 @@ enum ModuleDisposition { ExceedsLimit, } -dylint_linting::impl_late_lint! { - pub MODULE_MAX_LINES, - Warn, - "modules should stay within the configured maximum line count", - ModuleMaxLines::default() +/// Dylint lint declaration and registration glue. +/// +/// `impl_late_lint!` expands to the Dylint ABI entry point and the +/// `impl_lint_pass!` accessor, neither of which has a source location that +/// could carry documentation. Isolating the invocation keeps the expectation +/// scoped to exactly those generated items. +mod declaration { + #![expect( + missing_docs, + reason = "dylint_linting macro expansion emits items with no documentable source location" + )] + + use super::ModuleMaxLines; + + dylint_linting::impl_late_lint! { + /// Warns when a module exceeds the configured maximum line count. + pub MODULE_MAX_LINES, + Warn, + "modules should stay within the configured maximum line count", + ModuleMaxLines::default() + } } +pub use declaration::MODULE_MAX_LINES; + /// Lint pass that tracks configuration and localization state while checking modules. pub struct ModuleMaxLines { max_lines: usize, @@ -56,9 +75,8 @@ impl<'tcx> LateLintPass<'tcx> for ModuleMaxLines { } fn check_item(&mut self, cx: &LateContext<'tcx>, item: &'tcx hir::Item<'tcx>) { - let (ident, module) = match item.kind { - hir::ItemKind::Mod(ident, module) => (ident, module), - _ => return, + let hir::ItemKind::Mod(ident, module) = item.kind else { + return; }; let span = module_body_span(cx, item, module); @@ -93,7 +111,7 @@ impl<'tcx> LateLintPass<'tcx> for ModuleMaxLines { } } -fn evaluate_module(lines: usize, limit: usize, from_macro: bool) -> ModuleDisposition { +const fn evaluate_module(lines: usize, limit: usize, from_macro: bool) -> ModuleDisposition { if from_macro { ModuleDisposition::Ignore } else if lines > limit { @@ -110,8 +128,7 @@ fn load_configuration() -> usize { Err(error) => { debug!( target: LINT_NAME, - "failed to parse `{}` configuration: {error}; using defaults", - LINT_NAME + "failed to parse `{LINT_NAME}` configuration: {error}; using defaults" ); ModuleMaxLinesConfig::default().max_lines } @@ -126,7 +143,7 @@ fn count_lines(source_map: &SourceMap, span: Span) -> Option { let contiguous = info .lines .windows(2) - .all(|pair| pair[1].line_index == pair[0].line_index + 1); + .all(|pair| matches!(pair, [previous, next] if next.line_index == previous.line_index + 1)); if !contiguous { debug!( target: LINT_NAME, @@ -153,8 +170,12 @@ fn emit_diagnostic(cx: &LateContext<'_>, info: &ModuleDiagnosticInfo, localizer: let mut args: Arguments<'_> = Arguments::default(); let module_name = info.ident.name.as_str(); args.insert(Cow::Borrowed("module"), FluentValue::from(module_name)); - args.insert(Cow::Borrowed("lines"), FluentValue::from(info.lines as i64)); - args.insert(Cow::Borrowed("limit"), FluentValue::from(info.limit as i64)); + // Fluent arguments are `i64`; module sizes never approach the bound, so + // saturating keeps the diagnostic honest without a fallible path. + let line_count = i64::try_from(info.lines).unwrap_or(i64::MAX); + let line_limit = i64::try_from(info.limit).unwrap_or(i64::MAX); + args.insert(Cow::Borrowed("lines"), FluentValue::from(line_count)); + args.insert(Cow::Borrowed("limit"), FluentValue::from(line_limit)); let resolution = MessageResolution { lint_name: LINT_NAME, @@ -169,12 +190,12 @@ fn emit_diagnostic(cx: &LateContext<'_>, info: &ModuleDiagnosticInfo, localizer: MODULE_MAX_LINES, info.ident.span, rustc_lint::errors::DiagDecorator(|lint| { - lint.primary_message(messages.primary().to_string()); + lint.primary_message(messages.primary().to_owned()); lint.span_note( module_header_span(info.item_span, info.ident.span), - messages.note().to_string(), + messages.note().to_owned(), ); - lint.help(messages.help().to_string()); + lint.help(messages.help().to_owned()); }), ); } diff --git a/crates/module_max_lines/src/lib.rs b/crates/module_max_lines/src/lib.rs index 840470ad6..c83e2af7d 100644 --- a/crates/module_max_lines/src/lib.rs +++ b/crates/module_max_lines/src/lib.rs @@ -8,8 +8,13 @@ #[cfg(feature = "dylint-driver")] mod driver; +// Re-export only the documented lint surface. `impl_late_lint!` also expands +// to the Dylint ABI entry point and lint-pass glue, which have no source +// location that could carry documentation; keeping them out of the public +// path satisfies `missing_docs` without suppressing it. The `no_mangle` +// symbol is still exported from the cdylib for standalone Dylint loading. #[cfg(feature = "dylint-driver")] -pub use driver::*; +pub use driver::{MODULE_MAX_LINES, ModuleMaxLines}; #[cfg(not(feature = "dylint-driver"))] mod stub { diff --git a/crates/module_must_have_inner_docs/Cargo.toml b/crates/module_must_have_inner_docs/Cargo.toml index 8c99e4b98..135ced409 100644 --- a/crates/module_must_have_inner_docs/Cargo.toml +++ b/crates/module_must_have_inner_docs/Cargo.toml @@ -36,6 +36,7 @@ newt-hype = "0.2" [dev-dependencies] whitaker_test_macros = { workspace = true } rstest = { workspace = true } +proptest = { workspace = true } rstest-bdd = { workspace = true } rstest-bdd-macros = { workspace = true } dylint_testing = { workspace = true } diff --git a/crates/module_must_have_inner_docs/src/driver/inner_attr.rs b/crates/module_must_have_inner_docs/src/driver/inner_attr.rs index d73b62d6f..ec44a5c25 100644 --- a/crates/module_must_have_inner_docs/src/driver/inner_attr.rs +++ b/crates/module_must_have_inner_docs/src/driver/inner_attr.rs @@ -24,74 +24,8 @@ fn segment_has_case_incorrect_doc(segment: &str) -> bool { false } -struct ParserState { - depth: usize, - start: usize, -} - -fn has_case_incorrect_doc_in_meta_list(list: &str) -> bool { - let mut state = ParserState { depth: 0, start: 0 }; - - for (idx, ch) in list.char_indices() { - if handle_character(ch, &mut state, list, idx) { - return true; - } - } - - segment_has_case_incorrect_doc(&list[state.start..]) -} - -// Extracted to reduce nested complexity in `has_case_incorrect_doc_in_meta_list`. -fn handle_character(ch: char, state: &mut ParserState, list: &str, idx: usize) -> bool { - match ch { - '(' => state.depth += 1, - ')' => state.depth = state.depth.saturating_sub(1), - ',' if state.depth == 0 => { - if segment_has_case_incorrect_doc(&list[state.start..idx]) { - return true; - } - state.start = idx + 1; - } - _ => {} - } - false -} - fn cfg_attr_has_case_incorrect_doc(rest: ParseInput<'_>) -> bool { - let (_, trimmed) = parser::skip_leading_whitespace(rest); - let Some(content) = trimmed.strip_prefix('(') else { - return false; - }; - - let mut depth: usize = 1; - let mut first_comma = None; - let mut closing_paren = None; - - for (idx, ch) in content.char_indices() { - match ch { - '(' => depth += 1, - ')' => { - depth = depth.saturating_sub(1); - if depth == 0 { - closing_paren = Some(idx); - break; - } - } - ',' if depth == 1 && first_comma.is_none() => first_comma = Some(idx), - _ => {} - } - } - - let Some(attr_section_start) = first_comma else { - return false; - }; - let Some(close_idx) = closing_paren else { - return false; - }; - - let args = &content[..close_idx]; - let attr_section = &args[attr_section_start + 1..]; - has_case_incorrect_doc_in_meta_list(attr_section) + parser::cfg_attr_has_meta_item(rest, segment_has_case_incorrect_doc) } fn inner_attribute_body(rest: ParseInput<'_>) -> Option> { @@ -103,7 +37,7 @@ fn inner_attribute_body(rest: ParseInput<'_>) -> Option> { // Missing `]` is tolerated here: downstream identifier parsing will // gracefully reject malformed content by failing to match expected patterns. let attr_end = body.find(']').unwrap_or(body.len()); - Some(AttributeBody::from(&body[..attr_end])) + body.get(..attr_end).map(AttributeBody::from) } /// Detects inner attributes like `#![DOC = ...]` or `#![cfg_attr(..., Doc = ...)]` @@ -160,3 +94,41 @@ pub(super) fn is_cfg_attr_without_doc(rest: ParseInput<'_>) -> bool { // the condition holds, so treat it the same as having no inner attributes. *ident == "cfg_attr" && !parser::cfg_attr_has_doc(tail) } + +#[cfg(test)] +mod tests { + //! Tests for inner attribute parsing. + + use super::is_case_incorrect_doc_inner_attr; + use crate::ParseInput; + use rstest::rstest; + + #[rstest] + #[case( + "#![cfg_attr(all(feature = \"x\", unix), Doc = \"example\", allow(dead_code))]", + true + )] + #[case( + "#![cfg_attr(all(feature = \"x\", unix), doc = \"example\", allow(dead_code))]", + false + )] + #[case( + "#![cfg_attr(feature = \"x\", cfg_attr(unix, DOC = \"example\"))]", + true + )] + #[case( + r#"#![cfg_attr(feature = "x", deprecated(note = ")"), Doc = "example")]"#, + true + )] + #[case("#![cfg_attr(feature = \"x\", allow(dead_code))]", false)] + fn detects_case_mismatched_doc_attributes_inside_cfg_attr( + #[case] attribute: &str, + #[case] expected: bool, + ) { + assert_eq!( + is_case_incorrect_doc_inner_attr(ParseInput::from(attribute)), + expected, + "unexpected result for {attribute}" + ); + } +} diff --git a/crates/module_must_have_inner_docs/src/driver/literal.rs b/crates/module_must_have_inner_docs/src/driver/literal.rs new file mode 100644 index 000000000..02e1de5b7 --- /dev/null +++ b/crates/module_must_have_inner_docs/src/driver/literal.rs @@ -0,0 +1,170 @@ +//! Track strings and comments while scanning Rust attribute text. +//! +//! This private scanner only protects meta-list delimiters from lexical +//! contents. Keep it local to `module_must_have_inner_docs`; it is not a Rust +//! parser or a general-purpose lexer. + +#[derive(Default)] +pub(super) struct LexicalState { + state: State, + skipped_delimiter_byte: Option, +} + +#[derive(Default)] +enum State { + #[default] + Outside, + Quoted { + is_escaped: bool, + }, + Raw(RawStringState), + LineComment, + BlockComment { + depth: usize, + }, +} + +struct RawStringState { + opening_quote: usize, + hash_count: usize, +} + +impl LexicalState { + /// Track lexical contents and report whether they consume this character. + pub(super) fn consumes(&mut self, input: &str, idx: usize, ch: char) -> bool { + if self.skipped_delimiter_byte == Some(idx) { + self.skipped_delimiter_byte = None; + return true; + } + + match std::mem::take(&mut self.state) { + State::Outside => match state_at(input, idx, ch) { + State::Outside => false, + next_state => { + if matches!(next_state, State::BlockComment { .. }) { + self.skipped_delimiter_byte = Some(idx.saturating_add(1)); + } + self.state = next_state; + true + } + }, + State::Quoted { is_escaped } => { + self.state = next_quoted_state(is_escaped, ch); + true + } + State::Raw(raw_string) => { + self.state = raw_string.next(input, idx, ch); + true + } + State::LineComment => { + self.state = if ch == '\n' { + State::Outside + } else { + State::LineComment + }; + true + } + State::BlockComment { depth } => { + self.state = + next_block_comment_state(input, idx, depth, &mut self.skipped_delimiter_byte); + true + } + } + } +} + +/// Choose the lexical state that starts at the current character. +fn state_at(input: &str, idx: usize, ch: char) -> State { + match (raw_string_start(input, idx), ch) { + (Some((opening_quote, hash_count)), _) => State::Raw(RawStringState { + opening_quote, + hash_count, + }), + (None, '"') => State::Quoted { is_escaped: false }, + (None, '/') if input.get(idx..).is_some_and(|tail| tail.starts_with("//")) => { + State::LineComment + } + (None, '/') if input.get(idx..).is_some_and(|tail| tail.starts_with("/*")) => { + State::BlockComment { depth: 1 } + } + (None, _) => State::Outside, + } +} + +/// Advance a nested block-comment state and consume its delimiter pair. +fn next_block_comment_state( + input: &str, + idx: usize, + depth: usize, + skipped_delimiter_byte: &mut Option, +) -> State { + let Some(suffix) = input.get(idx..) else { + return State::BlockComment { depth }; + }; + if suffix.starts_with("/*") { + *skipped_delimiter_byte = Some(idx.saturating_add(1)); + State::BlockComment { + depth: depth.saturating_add(1), + } + } else if suffix.starts_with("*/") { + *skipped_delimiter_byte = Some(idx.saturating_add(1)); + if depth == 1 { + State::Outside + } else { + State::BlockComment { + depth: depth.saturating_sub(1), + } + } + } else { + State::BlockComment { depth } + } +} + +/// Advance an escaped quoted string by one character. +fn next_quoted_state(is_escaped: bool, ch: char) -> State { + match (is_escaped, ch) { + (true, _) => State::Quoted { is_escaped: false }, + (false, '\\') => State::Quoted { is_escaped: true }, + (false, '"') => State::Outside, + (false, _) => State::Quoted { is_escaped: false }, + } +} + +impl RawStringState { + /// Advance a raw string and exit only at its matching hash-delimited end. + fn next(self, input: &str, idx: usize, ch: char) -> State { + match ( + idx == self.opening_quote, + ch == '"', + raw_string_ends(input, idx, self.hash_count), + ) { + (false, true, true) => State::Outside, + _ => State::Raw(self), + } + } +} + +/// Find the opening quote and hash count for a raw string literal. +fn raw_string_start(input: &str, idx: usize) -> Option<(usize, usize)> { + let suffix = input.get(idx..)?; + let prefix_length = suffix + .strip_prefix("br") + .map(|_| 2) + .or_else(|| suffix.strip_prefix('r').map(|_| 1))?; + let raw_content = suffix.get(prefix_length..)?; + let hash_count = raw_content.bytes().take_while(|byte| *byte == b'#').count(); + let opening_quote = idx.saturating_add(prefix_length).saturating_add(hash_count); + + raw_content + .get(hash_count..) + .filter(|tail| tail.starts_with('"')) + .map(|_| (opening_quote, hash_count)) +} + +/// Check whether a quote is followed by the raw string's closing hashes. +fn raw_string_ends(input: &str, idx: usize, hash_count: usize) -> bool { + input + .get(idx.saturating_add(1)..) + .and_then(|tail| tail.as_bytes().get(..hash_count)) + .is_some_and(|hashes| hashes.iter().all(|byte| *byte == b'#')) +} diff --git a/crates/module_must_have_inner_docs/src/driver/mod.rs b/crates/module_must_have_inner_docs/src/driver/mod.rs index ef19d43ba..a0288261c 100644 --- a/crates/module_must_have_inner_docs/src/driver/mod.rs +++ b/crates/module_must_have_inner_docs/src/driver/mod.rs @@ -9,7 +9,6 @@ use std::borrow::Cow; use log::debug; -use newt_hype::base_newtype; use rustc_hir as hir; use rustc_lint::{LateContext, LateLintPass, LintContext}; #[cfg(test)] @@ -24,9 +23,27 @@ use whitaker_common::i18n::{ }; mod inner_attr; +mod literal; mod parser; -base_newtype!(StrWrapper); +/// Shared string newtype backing the parser's snippet aliases. +/// +/// `newt_hype::base_newtype!` emits both `Copy` and an explicit `Clone` impl. +/// The impl is generated inside the external macro, so it has no source +/// location that could be changed to a derive; isolating the invocation keeps +/// the expectation scoped to exactly that generated impl. +mod str_wrapper { + #![expect( + clippy::expl_impl_clone_on_copy, + reason = "newt_hype::base_newtype! emits an explicit Clone impl on a Copy type" + )] + + use newt_hype::base_newtype; + + base_newtype!(StrWrapper); +} + +pub use str_wrapper::StrWrapper; pub type SourceSnippet<'a> = StrWrapper<&'a str>; pub type AttributeBody<'a> = StrWrapper<&'a str>; @@ -43,6 +60,7 @@ impl<'a> ParseInput<'a> { /// let input = ParseInput::from("example"); /// assert_eq!(input.as_str(), "example"); /// ``` + #[must_use] pub fn as_str(&self) -> &'a str { **self } @@ -51,13 +69,31 @@ impl<'a> ParseInput<'a> { const LINT_NAME: &str = "module_must_have_inner_docs"; const MESSAGE_KEY: MessageKey<'static> = MessageKey::new(LINT_NAME); -dylint_linting::impl_late_lint! { - pub MODULE_MUST_HAVE_INNER_DOCS, - Warn, - "modules must begin with an inner doc comment", - ModuleMustHaveInnerDocs::default() +/// Dylint lint declaration and registration glue. +/// +/// `impl_late_lint!` expands to the Dylint ABI entry point and the +/// `impl_lint_pass!` accessor, neither of which has a source location that +/// could carry documentation. Isolating the invocation keeps the expectation +/// scoped to exactly those generated items. +mod declaration { + #![expect( + missing_docs, + reason = "dylint_linting macro expansion emits items with no documentable source location" + )] + + use super::ModuleMustHaveInnerDocs; + + dylint_linting::impl_late_lint! { + /// Warns when a module body does not open with an inner doc comment. + pub MODULE_MUST_HAVE_INNER_DOCS, + Warn, + "modules must begin with an inner doc comment", + ModuleMustHaveInnerDocs::default() + } } +pub use declaration::MODULE_MUST_HAVE_INNER_DOCS; + /// Lint pass enforcing leading inner doc comments on modules. pub struct ModuleMustHaveInnerDocs { localizer: Localizer, @@ -78,9 +114,8 @@ impl<'tcx> LateLintPass<'tcx> for ModuleMustHaveInnerDocs { } fn check_item(&mut self, cx: &LateContext<'tcx>, item: &'tcx hir::Item<'tcx>) { - let (ident, module) = match item.kind { - hir::ItemKind::Mod(ident, module) => (ident, module), - _ => return, + let hir::ItemKind::Mod(ident, module) = item.kind else { + return; }; if item.span.from_expansion() { @@ -191,17 +226,17 @@ fn has_inner_doc(rest: ParseInput<'_>) -> bool { let snippet = rest.as_str(); let mut line_start = 0; - while line_start < snippet.len() { - let line_end = snippet[line_start..] - .find('\n') - .map(|idx| line_start + idx) - .unwrap_or(snippet.len()); - let line = &snippet[line_start..line_end]; + // `split_inclusive` keeps each terminator attached, so accumulating the + // yielded lengths reproduces the byte offset of every line start. + for line_with_terminator in snippet.split_inclusive('\n') { + let line = line_with_terminator + .strip_suffix('\n') + .unwrap_or(line_with_terminator); if check_line_for_inner_doc(snippet, line, line_start) { return true; } - line_start = line_end.saturating_add(1); + line_start = line_start.saturating_add(line_with_terminator.len()); } false @@ -223,10 +258,13 @@ fn check_line_for_inner_doc(snippet: &str, line: &str, line_start: usize) -> boo search_start = offset.saturating_add(2); } - while let Some(local_idx) = line[search_start..].find("#!") { + while let Some(local_idx) = line.get(search_start..).and_then(|tail| tail.find("#!")) { let absolute_idx = search_start + local_idx; - let offset = line_start + absolute_idx; - if parser::is_doc_comment(ParseInput::from(&snippet[offset..])) { + let snippet_offset = line_start + absolute_idx; + let Some(tail) = snippet.get(snippet_offset..) else { + break; + }; + if parser::is_doc_comment(ParseInput::from(tail)) { return true; } search_start = absolute_idx + 2; @@ -282,8 +320,13 @@ fn primary_span_for_disposition( fn first_token_span(module_body: Span, offset: usize, len: usize) -> Span { let base = module_body.shrink_to_lo(); - let start = base.lo() + BytePos(offset as u32); - let hi = start + BytePos(len.max(1) as u32); + // Source files never exceed `u32::MAX` bytes, so a failed conversion means + // the caller supplied a nonsensical offset; fall back to the module start. + let (Ok(byte_offset), Ok(byte_len)) = (u32::try_from(offset), u32::try_from(len.max(1))) else { + return base; + }; + let start = base.lo() + BytePos(byte_offset); + let hi = start + BytePos(byte_len); base.with_lo(start).with_hi(hi) } @@ -305,9 +348,9 @@ fn emit_diagnostic(cx: &LateContext<'_>, context: &ModuleDiagnosticContext, loca MODULE_MUST_HAVE_INNER_DOCS, context.primary_span, rustc_lint::errors::DiagDecorator(|lint| { - lint.primary_message(messages.primary().to_string()); - lint.span_note(context.header_span, messages.note().to_string()); - lint.help(messages.help().to_string()); + lint.primary_message(messages.primary().to_owned()); + lint.span_note(context.header_span, messages.note().to_owned()); + lint.help(messages.help().to_owned()); }), ); } diff --git a/crates/module_must_have_inner_docs/src/driver/parser.rs b/crates/module_must_have_inner_docs/src/driver/parser.rs index 1f36914c2..f254945bc 100644 --- a/crates/module_must_have_inner_docs/src/driver/parser.rs +++ b/crates/module_must_have_inner_docs/src/driver/parser.rs @@ -4,7 +4,9 @@ //! `cfg_attr` wrappers to inspect their contained meta items, and skips leading //! whitespace before parsing. It looks for doc tokens (`//!` line comments and //! `#![doc = \"...\"]` style attributes) while ignoring commas inside nested -//! parentheses when dissecting meta lists. Key helpers: +//! parentheses and string literals when dissecting meta lists. One crate-private +//! meta-list walker serves the documentation and case-mismatch checks, while +//! each caller owns its identifier policy. Key helpers: //! - `skip_leading_whitespace`: advances a text cursor past Unicode whitespace. //! - `is_doc_comment`: recognizes leading doc comments or doc attributes, //! including those wrapped in `cfg_attr`. @@ -12,9 +14,10 @@ //! These utilities underpin the lint that determines whether a module has the //! required leading inner docs. +use super::literal::LexicalState; use crate::{AttributeBody, MetaList, ParseInput}; -/// Skips leading Unicode whitespace in the input. +/// Skip leading Unicode whitespace in the input. /// /// Returns the byte offset of the first non-whitespace character and the /// remaining input after whitespace. If the input is entirely whitespace or @@ -44,7 +47,7 @@ use crate::{AttributeBody, MetaList, ParseInput}; /// assert_eq!(offset, 5); // 2 bytes + 3 bytes. /// assert_eq!(rest.as_str(), "hello"); /// ``` -pub(super) fn skip_leading_whitespace<'a>(snippet: ParseInput<'a>) -> (usize, ParseInput<'a>) { +pub(super) fn skip_leading_whitespace(snippet: ParseInput<'_>) -> (usize, ParseInput<'_>) { let snippet_str = snippet.as_str(); let trimmed = snippet_str.trim_start_matches(char::is_whitespace); let byte_offset = snippet_str.len().saturating_sub(trimmed.len()); @@ -52,7 +55,7 @@ pub(super) fn skip_leading_whitespace<'a>(snippet: ParseInput<'a>) -> (usize, Pa (byte_offset, ParseInput::from(trimmed)) } -/// Determines whether the input starts with a module-level doc comment. +/// Determine whether the input starts with a module-level doc comment. /// /// Recognizes two forms: /// - Line doc comments: `//! ...` @@ -78,20 +81,20 @@ pub(super) fn is_doc_comment(rest: ParseInput<'_>) -> bool { let (_, tail) = skip_leading_whitespace(ParseInput::from(after_bang)); if let Some(body) = tail.strip_prefix('[') { let attr_end = body.find(']').unwrap_or(body.len()); - let attr_body = AttributeBody::from(&body[..attr_end]); - return is_doc_attr(attr_body); + return body + .get(..attr_end) + .is_some_and(|attr_body| is_doc_attr(AttributeBody::from(attr_body))); } } false } -// Returns true for direct `doc` attributes and for `cfg_attr` wrappers that -// contain a `doc` entry. +/// Recognize direct `doc` attributes and wrappers that contain a `doc` entry. fn is_doc_attr(attr_body: AttributeBody<'_>) -> bool { is_doc_ident(ParseInput::from(*attr_body)) } -/// Extracts the leading identifier from the input, skipping any leading +/// Extract the leading identifier from the input, skipping any leading /// whitespace. /// /// An identifier starts with `_` or an ASCII letter and continues with `_` or @@ -110,16 +113,16 @@ fn is_doc_attr(attr_body: AttributeBody<'_>) -> bool { /// /// assert!(take_ident(ParseInput::from(" 123")).is_none()); /// ``` -pub(super) fn take_ident<'a>(input: ParseInput<'a>) -> Option<(ParseInput<'a>, ParseInput<'a>)> { +pub(super) fn take_ident(input: ParseInput<'_>) -> Option<(ParseInput<'_>, ParseInput<'_>)> { let (_, trimmed) = skip_leading_whitespace(input); let trimmed_str = trimmed.as_str(); let mut iter = trimmed_str.char_indices(); - let (start, ch) = iter.next()?; - if !is_ident_start(ch) { + let (start, first_ch) = iter.next()?; + if !is_ident_start(first_ch) { return None; } - let mut end = start + ch.len_utf8(); + let mut end = start + first_ch.len_utf8(); for (idx, ch) in iter { if is_ident_continue(ch) { end = idx + ch.len_utf8(); @@ -128,11 +131,11 @@ pub(super) fn take_ident<'a>(input: ParseInput<'a>) -> Option<(ParseInput<'a>, P } } - let ident = ParseInput::from(&trimmed_str[..end]); - Some((ident, ParseInput::from(&trimmed_str[end..]))) + let (ident, remainder) = trimmed_str.split_at_checked(end)?; + Some((ParseInput::from(ident), ParseInput::from(remainder))) } -// Detects documentation by matching a `doc` ident directly or inside `cfg_attr`. +/// Find a direct `doc` identifier or a `cfg_attr` wrapper containing one. fn is_doc_ident(input: ParseInput<'_>) -> bool { let Some((ident, tail)) = take_ident(input) else { return false; @@ -149,112 +152,187 @@ fn is_doc_ident(input: ParseInput<'_>) -> bool { false } -fn is_ident_start(ch: char) -> bool { +/// Recognize the first character allowed in an attribute identifier. +const fn is_ident_start(ch: char) -> bool { // Ident parsing is intentionally ASCII-only; we only need to recognize // built-in attribute names such as `doc` and `cfg_attr`. ch == '_' || ch.is_ascii_alphabetic() } -fn is_ident_continue(ch: char) -> bool { +/// Recognize subsequent characters allowed in an attribute identifier. +const fn is_ident_continue(ch: char) -> bool { ch == '_' || ch.is_ascii_alphanumeric() } +/// Detect a `doc` item in a `cfg_attr` meta list. pub(super) fn cfg_attr_has_doc(tail: ParseInput<'_>) -> bool { - let (_, tail) = skip_leading_whitespace(tail); - let Some(args) = tail.strip_prefix('(') else { - return false; - }; - let Some(close_idx) = args.rfind(')') else { + cfg_attr_has_meta_item(tail, segment_is_doc) +} + +/// Check `cfg_attr` items with a caller-supplied name predicate. +pub(super) fn cfg_attr_has_meta_item( + tail: ParseInput<'_>, + mut matches_attribute: impl FnMut(&str) -> bool, +) -> bool { + let Some(meta_list) = cfg_attr_meta_list(tail) else { return false; }; - let meta_list = &args[..close_idx]; - has_doc_in_meta_list_after_first(MetaList::from(meta_list)) + meta_list_contains(meta_list, &mut matches_attribute) } -struct ParserStateAfterFirst { +/// Extract the attribute portion after the condition in a `cfg_attr` list. +/// +/// Keep the condition separate so callers only inspect attributes activated by +/// the wrapper. +fn cfg_attr_meta_list(tail: ParseInput<'_>) -> Option> { + let (_, trimmed) = skip_leading_whitespace(tail); + let content = trimmed.strip_prefix('(')?; + let (condition_comma, close_idx) = cfg_attr_delimiters(content)?; + let attributes_start = condition_comma.checked_add(1)?; + let attributes = content.get(attributes_start..close_idx)?; + + Some(MetaList::from(attributes)) +} + +/// Find the condition comma and outer closing parenthesis in `cfg_attr` text. +/// +/// Ignore delimiters inside nested meta items, strings, and comments. +fn cfg_attr_delimiters(content: &str) -> Option<(usize, usize)> { + let mut scan_state = CfgAttrScanState::new(); + + for (idx, ch) in content.char_indices() { + if scan_state.consume(content, idx, ch) { + return scan_state.condition_comma.map(|comma| (comma, idx)); + } + } + + None +} + +struct CfgAttrScanState { depth: usize, - start: usize, - seen_comma: bool, + condition_comma: Option, + lexical_state: LexicalState, } -impl ParserStateAfterFirst { +impl CfgAttrScanState { + /// Create a scanner positioned inside the outer `cfg_attr` parentheses. fn new() -> Self { Self { - depth: 0, - start: 0, - seen_comma: false, + depth: 1, + condition_comma: None, + lexical_state: LexicalState::default(), } } + + /// Track nested parentheses and return true at the outer closing one. + fn consume(&mut self, content: &str, idx: usize, ch: char) -> bool { + if self.lexical_state.consumes(content, idx, ch) { + return false; + } + + match (ch, self.depth, self.condition_comma.is_none()) { + ('(', _, _) => self.depth += 1, + (')', 1, _) => return true, + (')', _, _) => self.depth = self.depth.saturating_sub(1), + (',', 1, true) => self.condition_comma = Some(idx), + _ => {} + } + false + } } -fn has_doc_in_meta_list_after_first(list: MetaList<'_>) -> bool { - let list_str = *list; - let mut state = ParserStateAfterFirst::new(); +/// Check the attributes in a meta list with a caller-supplied predicate. +pub(super) fn meta_list_contains( + list: MetaList<'_>, + matches_attribute: impl FnMut(&str) -> bool, +) -> bool { + let mut scanner = MetaListScanner::new(*list, matches_attribute); - for (idx, ch) in list_str.char_indices() { - if process_char_for_doc_after_first(list_str, ch, &mut state, idx) { + for (idx, ch) in scanner.list.char_indices() { + if scanner.matches_at_separator(idx, ch) { return true; } } - state.seen_comma && segment_is_doc(&list_str[state.start..]) + scanner.matches_remaining() } -fn process_char_for_doc_after_first( - list_str: &str, - ch: char, - state: &mut ParserStateAfterFirst, - idx: usize, -) -> bool { - match ch { - '(' => { - state.depth += 1; - false +struct MetaListScanner<'list, MatchesAttribute> { + list: &'list str, + depth: usize, + start: usize, + lexical_state: LexicalState, + matches_attribute: MatchesAttribute, +} + +impl<'list, MatchesAttribute> MetaListScanner<'list, MatchesAttribute> +where + MatchesAttribute: FnMut(&str) -> bool, +{ + /// Create a scanner for a comma-separated meta list. + fn new(list: &'list str, matches_attribute: MatchesAttribute) -> Self { + Self { + list, + depth: 0, + start: 0, + lexical_state: LexicalState::default(), + matches_attribute, } - ')' => { - state.depth = state.depth.saturating_sub(1); - false + } + + /// Check a top-level separator and test the preceding meta item. + fn matches_at_separator(&mut self, idx: usize, ch: char) -> bool { + if self.lexical_state.consumes(self.list, idx, ch) { + return false; } - ',' if state.depth == 0 => { - if state.seen_comma && segment_is_doc(&list_str[state.start..idx]) { - return true; - } - state.seen_comma = true; - state.start = idx + 1; - false + + if is_top_level_meta_separator(ch, &mut self.depth) { + let matches = + meta_item_matches(self.list, self.start, idx, &mut self.matches_attribute); + self.start = idx.saturating_add(1); + return matches; } - _ => false, + false } + + /// Test the final meta item after the last top-level separator. + fn matches_remaining(&mut self) -> bool { + meta_item_matches( + self.list, + self.start, + self.list.len(), + &mut self.matches_attribute, + ) + } +} + +/// Update parenthesis depth and identify top-level commas. +fn is_top_level_meta_separator(ch: char, depth: &mut usize) -> bool { + match ch { + '(' => *depth += 1, + ')' => *depth = depth.saturating_sub(1), + ',' => return *depth == 0, + _ => {} + } + false } +/// Apply the attribute predicate to one checked meta-list slice. +fn meta_item_matches( + list: &str, + start: usize, + end: usize, + mut matches_attribute: impl FnMut(&str) -> bool, +) -> bool { + list.get(start..end).is_some_and(&mut matches_attribute) +} + +/// Match a meta-list segment against the module `doc` identifier policy. fn segment_is_doc(segment: &str) -> bool { is_doc_ident(ParseInput::from(segment)) } #[cfg(test)] -mod tests { - //! Unit tests for parsing helpers. - - use super::skip_leading_whitespace; - use crate::ParseInput; - use rstest::rstest; - - #[rstest] - #[case("", 0, "")] - #[case(" \t\n", " \t\n".len(), "")] - #[case("//! docs", 0, "//! docs")] - #[case("\u{2003}x", "\u{2003}".len(), "x")] - #[case("\u{00A0}\u{2003}//! docs", "\u{00A0}\u{2003}".len(), "//! docs")] - #[case("\u{00A0}\u{2003}\t\n", "\u{00A0}\u{2003}\t\n".len(), "")] - fn skip_leading_whitespace_handles_unicode( - #[case] input: &str, - #[case] expected_offset: usize, - #[case] expected_rest: &str, - ) { - let (offset, rest) = skip_leading_whitespace(ParseInput::from(input)); - - assert_eq!(offset, expected_offset); - assert_eq!(rest.as_str(), expected_rest); - } -} +mod tests; diff --git a/crates/module_must_have_inner_docs/src/driver/parser/tests.rs b/crates/module_must_have_inner_docs/src/driver/parser/tests.rs new file mode 100644 index 000000000..2fe36c71c --- /dev/null +++ b/crates/module_must_have_inner_docs/src/driver/parser/tests.rs @@ -0,0 +1,132 @@ +//! Unit tests for the module-documentation parser. + +use super::{cfg_attr_has_meta_item, is_doc_comment, skip_leading_whitespace, take_ident}; +use crate::ParseInput; +use proptest::{prelude::*, test_runner::TestRunner}; +use rstest::rstest; + +/// Verify that the byte offset and remainder account for Unicode whitespace. +#[rstest] +#[case("", 0, "")] +#[case(" \t\n", " \t\n".len(), "")] +#[case("//! docs", 0, "//! docs")] +#[case("\u{2003}x", "\u{2003}".len(), "x")] +#[case("\u{00A0}\u{2003}//! docs", "\u{00A0}\u{2003}".len(), "//! docs")] +#[case("\u{00A0}\u{2003}\t\n", "\u{00A0}\u{2003}\t\n".len(), "")] +fn skip_leading_whitespace_handles_unicode( + #[case] input: &str, + #[case] expected_offset: usize, + #[case] expected_rest: &str, +) { + let (offset, rest) = skip_leading_whitespace(ParseInput::from(input)); + + assert_eq!(offset, expected_offset); + assert_eq!(rest.as_str(), expected_rest); +} + +/// Verify that string contents cannot terminate or split a meta list. +#[rstest] +#[case( + r#"#![cfg_attr(feature = "x", deprecated(note = ")"), doc = "module") ]"#, + true +)] +#[case( + r#"#![cfg_attr(feature = "x", deprecated(note = "escaped \" ), doc = \"fake\""), doc = "module") ]"#, + true +)] +#[case( + r##"#![cfg_attr(feature = "x", deprecated(note = r#"), doc = "fake"#), doc = "module") ]"##, + true +)] +#[case( + r#"#![cfg_attr(feature = "x", deprecated(note = "no, doc = \"fake\""))]"#, + false +)] +fn doc_detection_ignores_delimiters_inside_string_literals( + #[case] attribute: &str, + #[case] expected: bool, +) { + assert_eq!( + is_doc_comment(ParseInput::from(attribute)), + expected, + "unexpected result for {attribute}" + ); +} + +/// Verify that comments containing delimiters do not split a meta list. +#[rstest] +#[case(r#"#![cfg_attr(/* " */ all(), doc = "module") ]"#, true)] +#[case( + r#"#![cfg_attr(/* outer " , ) /* nested " */ still ) , */ all(), doc = "module") ]"#, + true +)] +#[case( + r#"#![cfg_attr(// ") + all(), doc = "module") ]"#, + true +)] +fn doc_detection_ignores_delimiters_inside_comments( + #[case] attribute: &str, + #[case] expected: bool, +) { + assert_eq!( + is_doc_comment(ParseInput::from(attribute)), + expected, + "unexpected result for {attribute}" + ); +} + +/// Verify that generated nested meta lists expose only real top-level targets. +#[test] +fn cfg_attr_scanners_match_top_level_targets_across_lexical_states() { + let strategy = (0usize..8, 0usize..5, any::(), any::()); + let mut runner = TestRunner::default(); + + runner + .run( + &strategy, + |(nested_depth, raw_hash_count, has_target, wrong_case)| { + let quoted_literal = r#""(, ) doc = \"fake\"""#; + let hashes = "#".repeat(raw_hash_count); + let near_closing_quote = if raw_hash_count == 0 { + String::new() + } else { + format!("\"{} ", "#".repeat(raw_hash_count - 1)) + }; + let raw_literal = + format!("r{hashes}\"(, ) {near_closing_quote}doc = fake\"{hashes}"); + let condition_item = format!("feature = {quoted_literal}, feature = {raw_literal}"); + let nesting = nested_depth + 1; + let condition = format!( + "{}all({condition_item}){}", + "all(".repeat(nesting), + ")".repeat(nesting) + ); + let nested_meta = format!("nested(value = {quoted_literal}, raw = {raw_literal})"); + let target = if has_target { + let name = if wrong_case { "Doc" } else { "doc" }; + format!(", {name} = \"module\"") + } else { + String::new() + }; + let tail = format!("({condition}, {nested_meta}{target})"); + let input = format!("#![cfg_attr{tail}]"); + let expected_doc = has_target && !wrong_case; + let expected_wrong_case = has_target && wrong_case; + + prop_assert_eq!( + is_doc_comment(ParseInput::from(input.as_str())), + expected_doc + ); + prop_assert_eq!( + cfg_attr_has_meta_item(ParseInput::from(tail.as_str()), |segment| { + take_ident(ParseInput::from(segment)) + .is_some_and(|(ident, _)| *ident == "Doc") + }), + expected_wrong_case + ); + Ok(()) + }, + ) + .expect("generated nested meta lists must preserve top-level detection"); +} diff --git a/crates/module_must_have_inner_docs/src/lib.rs b/crates/module_must_have_inner_docs/src/lib.rs index 1f79354b2..7821c2de2 100644 --- a/crates/module_must_have_inner_docs/src/lib.rs +++ b/crates/module_must_have_inner_docs/src/lib.rs @@ -26,5 +26,16 @@ #[cfg(feature = "dylint-driver")] mod driver; +// The snippet newtypes are parser plumbing shared between the driver's +// submodules through the crate root. They are generated by `base_newtype!`, +// so the underlying struct has no documentable source location either; +// keeping the aliases crate-visible leaves them outwith the public surface. #[cfg(feature = "dylint-driver")] -pub use driver::*; +pub(crate) use driver::{AttributeBody, MetaList, ParseInput}; +// Re-export only the documented lint surface. `impl_late_lint!` also expands +// to the Dylint ABI entry point and lint-pass glue, which have no source +// location that could carry documentation; keeping them out of the public +// path satisfies `missing_docs` without suppressing it. The `no_mangle` +// symbol is still exported from the cdylib for standalone Dylint loading. +#[cfg(feature = "dylint-driver")] +pub use driver::{MODULE_MUST_HAVE_INNER_DOCS, ModuleMustHaveInnerDocs}; diff --git a/crates/no_expect_outside_tests/examples/fail_expect_in_rstest_non_test_module.rs b/crates/no_expect_outside_tests/examples/fail_expect_in_rstest_non_test_module.rs index 57280542c..1ef4e8166 100644 --- a/crates/no_expect_outside_tests/examples/fail_expect_in_rstest_non_test_module.rs +++ b/crates/no_expect_outside_tests/examples/fail_expect_in_rstest_non_test_module.rs @@ -8,13 +8,13 @@ use rstest::rstest; -#[allow(dead_code)] +#[expect(dead_code)] fn parse() { let parsed = std::iter::once("value").next(); let _ = parsed.expect("ordinary code must not inherit rstest harness status"); } -#[allow(dead_code)] +#[expect(dead_code)] mod parse { pub const VERSION: &str = "1"; } diff --git a/crates/no_expect_outside_tests/src/context/mod.rs b/crates/no_expect_outside_tests/src/context/mod.rs index 4b9af71e9..298bf40d3 100644 --- a/crates/no_expect_outside_tests/src/context/mod.rs +++ b/crates/no_expect_outside_tests/src/context/mod.rs @@ -48,8 +48,8 @@ pub(crate) struct ContextSummary { /// collect_context(cx, expr.hir_id, additional_test_attributes); /// assert!(!entries.is_empty() || !has_test_context_ancestry); /// ``` -pub(crate) fn collect_context<'tcx>( - cx: &LateContext<'tcx>, +pub(crate) fn collect_context( + cx: &LateContext<'_>, hir_id: hir::HirId, additional_test_attributes: &[AttributePath], ) -> (Vec, bool) { @@ -116,7 +116,7 @@ pub(crate) fn summarize_context( entry .kind() .matches_function() - .then(|| entry.name().to_string()) + .then(|| entry.name().to_owned()) }); ContextSummary { @@ -135,7 +135,7 @@ fn context_entry_for(node: Node<'_>, attrs: &[hir::Attribute]) -> Option Some(ContextEntry::new( - "impl".to_string(), + "impl".to_owned(), ContextKind::Impl, convert_attributes(attrs), )), @@ -156,7 +156,7 @@ fn context_entry_for(node: Node<'_>, attrs: &[hir::Attribute]) -> Option None, }, Node::Block(_) => Some(ContextEntry::new( - "block".to_string(), + "block".to_owned(), ContextKind::Block, convert_attributes(attrs), )), @@ -182,16 +182,16 @@ fn convert_attribute(attr: &hir::Attribute) -> Attribute { return Attribute::new(AttributePath::from(PARSED_ATTRIBUTE_PLACEHOLDER), kind); }; let mut names = attr.path().into_iter().map(|symbol| symbol.to_string()); - match names.next() { - Some(first) => AttributePath::new(std::iter::once(first).chain(names)), - None => AttributePath::from("unknown"), - } + names.next().map_or_else( + || AttributePath::from("unknown"), + |first| AttributePath::new(std::iter::once(first).chain(names)), + ) }; Attribute::new(path, kind) } -/// Check if a cfg_attr has a test condition and contains nested cfg(test). +/// Check if a `cfg_attr` has a test condition and contains nested cfg(test). fn check_cfg_attr_for_test(items: I) -> bool where I: IntoIterator, @@ -249,24 +249,21 @@ pub(crate) fn is_cfg_test_attribute(attr: &hir::Attribute) -> bool { }; let path = attr.path(); - if path.len() != 1 { + let [name] = path.as_slice() else { return false; - } + }; - if path[0] == sym::cfg { + if *name == sym::cfg { return attr .meta_item_list() - .map(|items| items.iter().cloned().any(meta_item_inner_contains_test)) - .unwrap_or(false); + .is_some_and(|items| items.iter().cloned().any(meta_item_inner_contains_test)); } - if path[0] != sym::cfg_attr { + if *name != sym::cfg_attr { return false; } - attr.meta_item_list() - .map(check_cfg_attr_for_test) - .unwrap_or(false) + attr.meta_item_list().is_some_and(check_cfg_attr_for_test) } /// Report whether an evaluated `cfg` predicate can hold only under `cfg(test)`. @@ -307,33 +304,27 @@ fn meta_contains_test_with_polarity(meta: &MetaItem, is_positive: bool) -> bool } if path_is_ident(&meta.path, sym::not) { - return meta - .meta_item_list() - .map(|items| { - items - .iter() - .cloned() - .any(|item| meta_item_inner_contains_test_with_polarity(item, !is_positive)) - }) - .unwrap_or(false); - } - - meta.meta_item_list() - .map(|items| { + return meta.meta_item_list().is_some_and(|items| { items .iter() .cloned() - .any(|item| meta_item_inner_contains_test_with_polarity(item, is_positive)) - }) - .unwrap_or(false) + .any(|item| meta_item_inner_contains_test_with_polarity(item, !is_positive)) + }); + } + + meta.meta_item_list().is_some_and(|items| { + items + .iter() + .cloned() + .any(|item| meta_item_inner_contains_test_with_polarity(item, is_positive)) + }) } fn meta_contains_test_cfg(meta: &MetaItem) -> bool { if path_is_ident(&meta.path, sym::cfg) { return meta .meta_item_list() - .map(|items| items.iter().cloned().any(meta_item_inner_contains_test)) - .unwrap_or(false); + .is_some_and(|items| items.iter().cloned().any(meta_item_inner_contains_test)); } if !path_is_ident(&meta.path, sym::cfg_attr) { @@ -341,8 +332,7 @@ fn meta_contains_test_cfg(meta: &MetaItem) -> bool { } meta.meta_item_list() - .map(|items| check_cfg_attr_for_test(items.iter().cloned())) - .unwrap_or(false) + .is_some_and(|items| check_cfg_attr_for_test(items.iter().cloned())) } fn item_name(item: &hir::Item<'_>) -> Option { @@ -353,12 +343,12 @@ fn attribute_style(attr: &hir::Attribute) -> AttrStyle { match attr { hir::Attribute::Unparsed(item) => item.style, hir::Attribute::Parsed(HirAttributeKind::DocComment { style, .. }) => *style, - _ => AttrStyle::Outer, + hir::Attribute::Parsed(_) => AttrStyle::Outer, } } fn path_is_ident(path: &rustc_ast::Path, symbol: rustc_span::Symbol) -> bool { - path.segments.len() == 1 && path.segments[0].ident.name == symbol + matches!(&*path.segments, [segment] if segment.ident.name == symbol) } #[cfg(test)] diff --git a/crates/no_expect_outside_tests/src/context/tests.rs b/crates/no_expect_outside_tests/src/context/tests.rs index de6d6e448..a84edd516 100644 --- a/crates/no_expect_outside_tests/src/context/tests.rs +++ b/crates/no_expect_outside_tests/src/context/tests.rs @@ -56,6 +56,25 @@ enum AttributeFixture { CfgAndBuiltInTest, } +/// Outcome expected from `has_test_ancestry` for a table-driven case. +#[cfg(feature = "dylint-driver")] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum AncestryOutcome { + TestContext, + NotTestContext, +} + +#[cfg(feature = "dylint-driver")] +impl AncestryOutcome { + const fn from_detection(is_test_context: bool) -> Self { + if is_test_context { + Self::TestContext + } else { + Self::NotTestContext + } + } +} + #[cfg(feature = "dylint-driver")] #[derive(Clone, Copy, Debug)] struct HasTestAncestryCase { @@ -63,28 +82,37 @@ struct HasTestAncestryCase { attr_fixture: AttributeFixture, is_function_item: bool, include_custom_attribute: bool, - expected: bool, + expected: AncestryOutcome, } // Common path constants #[cfg(feature = "dylint-driver")] const PATH_CFG: PathSegments = PathSegments::new(&["cfg"]); + #[cfg(feature = "dylint-driver")] const PATH_TEST: PathSegments = PathSegments::new(&["test"]); + #[cfg(feature = "dylint-driver")] const PATH_ANY: PathSegments = PathSegments::new(&["any"]); + #[cfg(feature = "dylint-driver")] const PATH_ALL: PathSegments = PathSegments::new(&["all"]); + #[cfg(feature = "dylint-driver")] const PATH_NOT: PathSegments = PathSegments::new(&["not"]); + #[cfg(feature = "dylint-driver")] const PATH_CFG_ATTR: PathSegments = PathSegments::new(&["cfg_attr"]); + #[cfg(feature = "dylint-driver")] const PATH_ALLOW: PathSegments = PathSegments::new(&["allow"]); + #[cfg(feature = "dylint-driver")] const PATH_DOCTEST: PathSegments = PathSegments::new(&["doctest"]); + #[cfg(feature = "dylint-driver")] const PATH_UNIX: PathSegments = PathSegments::new(&["unix"]); + #[cfg(feature = "dylint-driver")] const PATH_DEAD_CODE: PathSegments = PathSegments::new(&["dead_code"]); @@ -281,8 +309,12 @@ fn build_cfg_attr_test_allow() -> MetaItem { /// Helper function to test `meta_contains_test_cfg` behaviour. /// Must be called within `create_default_session_globals_then`. #[cfg(feature = "dylint-driver")] -fn assert_meta_test_cfg(meta: MetaItem, expected: bool) { - assert_eq!(meta_contains_test_cfg(&meta), expected); +fn assert_meta_test_cfg(meta: &MetaItem, expected: bool) { + assert_eq!( + meta_contains_test_cfg(meta), + expected, + "meta item should report test-cfg detection as {expected}" + ); } /// Asserts that `convert_attribute` preserves path segments for the given @@ -342,42 +374,42 @@ fn build_additional_test_attributes(include_custom: bool) -> Vec attr_fixture: AttributeFixture::None, is_function_item: false, include_custom_attribute: false, - expected: true, + expected: AncestryOutcome::TestContext, })] #[case::cfg_test_attribute_detected(HasTestAncestryCase { has_test_context_ancestry: false, attr_fixture: AttributeFixture::CfgTest, is_function_item: false, include_custom_attribute: false, - expected: true, + expected: AncestryOutcome::TestContext, })] #[case::built_in_test_attribute_on_function_item(HasTestAncestryCase { has_test_context_ancestry: false, attr_fixture: AttributeFixture::BuiltInTest, is_function_item: true, include_custom_attribute: false, - expected: true, + expected: AncestryOutcome::TestContext, })] #[case::configured_test_attribute_on_function_item(HasTestAncestryCase { has_test_context_ancestry: false, attr_fixture: AttributeFixture::CustomTest, is_function_item: true, include_custom_attribute: true, - expected: true, + expected: AncestryOutcome::TestContext, })] #[case::all_detection_paths_together(HasTestAncestryCase { has_test_context_ancestry: true, attr_fixture: AttributeFixture::CfgAndBuiltInTest, is_function_item: true, include_custom_attribute: true, - expected: true, + expected: AncestryOutcome::TestContext, })] #[case::negative_case(HasTestAncestryCase { has_test_context_ancestry: false, attr_fixture: AttributeFixture::Allow, is_function_item: false, include_custom_attribute: false, - expected: false, + expected: AncestryOutcome::NotTestContext, })] fn has_test_ancestry_detects_test_context(#[case] case: HasTestAncestryCase) { create_default_session_globals_then(|| { @@ -385,14 +417,16 @@ fn has_test_ancestry_detects_test_context(#[case] case: HasTestAncestryCase) { let additional_test_attributes = build_additional_test_attributes(case.include_custom_attribute); + let outcome = AncestryOutcome::from_detection(has_test_ancestry( + case.has_test_context_ancestry, + &attrs, + case.is_function_item, + &additional_test_attributes, + )); + assert_eq!( - has_test_ancestry( - case.has_test_context_ancestry, - &attrs, - case.is_function_item, - &additional_test_attributes, - ), - case.expected, + outcome, case.expected, + "test-ancestry detection should match the expected outcome for {case:?}" ); }); } @@ -402,7 +436,7 @@ fn has_test_ancestry_detects_test_context(#[case] case: HasTestAncestryCase) { #[test] fn meta_contains_test_cfg_any_test_doctest() { create_default_session_globals_then(|| { - assert_meta_test_cfg(build_cfg_any_test_doctest(), true); + assert_meta_test_cfg(&build_cfg_any_test_doctest(), true); }); } @@ -411,7 +445,7 @@ fn meta_contains_test_cfg_any_test_doctest() { #[test] fn meta_contains_test_cfg_all_test_unix() { create_default_session_globals_then(|| { - assert_meta_test_cfg(build_cfg_all_test_unix(), true); + assert_meta_test_cfg(&build_cfg_all_test_unix(), true); }); } @@ -420,7 +454,7 @@ fn meta_contains_test_cfg_all_test_unix() { #[test] fn meta_contains_test_cfg_not_test() { create_default_session_globals_then(|| { - assert_meta_test_cfg(build_cfg_not_test(), false); + assert_meta_test_cfg(&build_cfg_not_test(), false); }); } @@ -429,7 +463,7 @@ fn meta_contains_test_cfg_not_test() { #[test] fn meta_contains_test_cfg_attr_test_cfg_test() { create_default_session_globals_then(|| { - assert_meta_test_cfg(build_cfg_attr_test_cfg_test(), true); + assert_meta_test_cfg(&build_cfg_attr_test_cfg_test(), true); }); } @@ -438,7 +472,7 @@ fn meta_contains_test_cfg_attr_test_cfg_test() { #[test] fn meta_contains_test_cfg_attr_test_allow() { create_default_session_globals_then(|| { - assert_meta_test_cfg(build_cfg_attr_test_allow(), false); + assert_meta_test_cfg(&build_cfg_attr_test_allow(), false); }); } @@ -459,7 +493,7 @@ fn convert_attribute_handles_parsed_must_use() { // Should return a placeholder "parsed" path instead of panicking. assert_eq!( attribute.path().segments(), - &[PARSED_ATTRIBUTE_PLACEHOLDER.to_string()] + &[PARSED_ATTRIBUTE_PLACEHOLDER.to_owned()] ); assert_eq!(attribute.kind(), AttributeKind::Outer); } diff --git a/crates/no_expect_outside_tests/src/diagnostics.rs b/crates/no_expect_outside_tests/src/diagnostics.rs index 0e285c970..d06dbfec5 100644 --- a/crates/no_expect_outside_tests/src/diagnostics.rs +++ b/crates/no_expect_outside_tests/src/diagnostics.rs @@ -81,7 +81,7 @@ impl ReceiverCategory { } } - fn as_key(self) -> &'static str { + const fn as_key(self) -> &'static str { match self { Self::Option => "option", Self::Result => "result", @@ -132,7 +132,7 @@ pub(crate) struct DiagnosticContext<'a> { } impl<'a> DiagnosticContext<'a> { - pub(crate) fn new(summary: &'a ContextSummary, localizer: &'a Localizer) -> Self { + pub(crate) const fn new(summary: &'a ContextSummary, localizer: &'a Localizer) -> Self { Self { summary, localizer } } } @@ -144,7 +144,7 @@ pub(crate) fn emit_diagnostic( context: &DiagnosticContext<'_>, ) { let receiver_ty = cx.typeck_results().expr_ty(receiver).peel_refs(); - let receiver_label = ReceiverLabel::new(format!("`{}`", receiver_ty)); + let receiver_label = ReceiverLabel::new(format!("`{receiver_ty}`")); let call_context = context_label(context.summary); let category = ReceiverCategory::classify_ty(cx, receiver_ty); @@ -152,15 +152,15 @@ pub(crate) fn emit_diagnostic( let mut args: Arguments<'static> = Arguments::default(); args.insert( Cow::Borrowed("receiver"), - FluentValue::from(receiver_label.as_ref().to_string()), + FluentValue::from(receiver_label.as_ref().to_owned()), ); args.insert( Cow::Borrowed("context"), - FluentValue::from(call_context.as_ref().to_string()), + FluentValue::from(call_context.as_ref().to_owned()), ); args.insert( Cow::Borrowed("handling"), - FluentValue::from(category.as_key().to_string()), + FluentValue::from(category.as_key().to_owned()), ); let fallback_receiver = receiver_label.clone(); @@ -175,9 +175,9 @@ pub(crate) fn emit_diagnostic( fallback_messages(&fallback_receiver, &fallback_context, category) }); - let primary = messages.primary().to_string(); - let note = messages.note().to_string(); - let help = messages.help().to_string(); + let primary = messages.primary().to_owned(); + let note = messages.note().to_owned(); + let help = messages.help().to_owned(); cx.emit_span_lint( NO_EXPECT_OUTSIDE_TESTS, @@ -205,15 +205,15 @@ fn localized_messages( let mut args: Arguments<'static> = Arguments::default(); args.insert( Cow::Borrowed("receiver"), - FluentValue::from(receiver.as_ref().to_string()), + FluentValue::from(receiver.as_ref().to_owned()), ); args.insert( Cow::Borrowed("context"), - FluentValue::from(context.as_ref().to_string()), + FluentValue::from(context.as_ref().to_owned()), ); args.insert( Cow::Borrowed("handling"), - FluentValue::from(category.as_key().to_string()), + FluentValue::from(category.as_key().to_owned()), ); resolve_message_set(lookup, MESSAGE_KEY, &args) @@ -229,18 +229,17 @@ fn fallback_messages( 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 recognized 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) } fn context_label(summary: &ContextSummary) -> ContextLabel { - let label = summary - .function_name - .as_ref() - .map(|name| format!("function `{name}`")) - .unwrap_or_else(|| "the surrounding scope".to_string()); + let label = summary.function_name.as_ref().map_or_else( + || "the surrounding scope".to_owned(), + |name| format!("function `{name}`"), + ); ContextLabel::new(label) } diff --git a/crates/no_expect_outside_tests/src/driver/mod.rs b/crates/no_expect_outside_tests/src/driver/mod.rs index 225d8d8fc..6f0f19118 100644 --- a/crates/no_expect_outside_tests/src/driver/mod.rs +++ b/crates/no_expect_outside_tests/src/driver/mod.rs @@ -26,13 +26,31 @@ use whitaker_common::{AttributePath, Localizer, get_localizer_for_lint}; use crate::context::{collect_context, is_cfg_test_attribute, summarize_context}; use crate::diagnostics::{DiagnosticContext, emit_diagnostic}; -dylint_linting::impl_late_lint! { - pub NO_EXPECT_OUTSIDE_TESTS, - Deny, - "`.expect(..)` must not be used outside of test or doctest contexts", - NoExpectOutsideTests::default() +/// Dylint lint declaration and registration glue. +/// +/// `impl_late_lint!` expands to the Dylint ABI entry point and the +/// `impl_lint_pass!` accessor, neither of which has a source location that +/// could carry documentation. Isolating the invocation keeps the expectation +/// scoped to exactly those generated items. +mod declaration { + #![expect( + missing_docs, + reason = "dylint_linting macro expansion emits items with no documentable source location" + )] + + use super::NoExpectOutsideTests; + + dylint_linting::impl_late_lint! { + /// Denies `.expect(..)` calls outside test and doctest contexts. + pub NO_EXPECT_OUTSIDE_TESTS, + Deny, + "`.expect(..)` must not be used outside of test or doctest contexts", + NoExpectOutsideTests::default() + } } +pub use declaration::NO_EXPECT_OUTSIDE_TESTS; + #[derive(Default, Deserialize)] struct Config { #[serde(default)] @@ -158,12 +176,12 @@ fn receiver_is_option_or_result<'tcx>( } fn ty_is_option_or_result<'tcx>(cx: &LateContext<'tcx>, ty: Ty<'tcx>) -> bool { - let ty = cx + let normalized = cx .tcx .normalize_erasing_regions(cx.typing_env(), ty::Unnormalized::new_wip(ty)) .peel_refs(); - let Some(adt) = ty.ty_adt_def() else { + let Some(adt) = normalized.ty_adt_def() else { return false; }; @@ -198,7 +216,7 @@ fn ancestor_function_is_test<'tcx>( }) } -fn is_in_tests_directory<'tcx>(cx: &LateContext<'tcx>) -> bool { +fn is_in_tests_directory(cx: &LateContext<'_>) -> bool { cx.tcx.sess.local_crate_source_file().is_some_and(|source| { is_integration_test_crate_root(source.path(RemapPathScopeComponents::DIAGNOSTICS)) }) @@ -218,6 +236,7 @@ fn is_integration_test_crate_root(crate_root: &Path) -> bool { is_direct_test || is_multi_file_test } + fn is_likely_test_function<'tcx>( cx: &LateContext<'tcx>, expr: &hir::Expr<'tcx>, @@ -238,7 +257,7 @@ fn is_likely_test_function<'tcx>( || is_in_tests_directory(cx) } -fn is_in_cfg_test_module<'tcx>(cx: &LateContext<'tcx>, hir_id: hir::HirId) -> bool { +fn is_in_cfg_test_module(cx: &LateContext<'_>, hir_id: hir::HirId) -> bool { cx.tcx.hir_parent_iter(hir_id).any(|(ancestor_id, node)| { let hir::Node::Item(item) = node else { return false; diff --git a/crates/no_expect_outside_tests/src/driver/tests.rs b/crates/no_expect_outside_tests/src/driver/tests.rs index 0659e1a3f..0c01dbbca 100644 --- a/crates/no_expect_outside_tests/src/driver/tests.rs +++ b/crates/no_expect_outside_tests/src/driver/tests.rs @@ -33,7 +33,7 @@ fn hir_attribute_from_segments(segments: &[&str]) -> hir::Attribute { hir::Attribute::Unparsed(Box::new(attr_item)) } -fn parsed_must_use_attribute() -> hir::Attribute { +const fn parsed_must_use_attribute() -> hir::Attribute { hir::Attribute::Parsed(HirAttributeKind::MustUse { span: DUMMY_SP, reason: None, @@ -113,6 +113,7 @@ fn assert_has_test_like_attributes( assert_eq!( has_test_like_hir_attributes(&attrs, additional_test_attributes), expected, + "attribute segments {attr_segments:?} should report test-like as {expected}", ); }); } @@ -129,7 +130,10 @@ fn has_test_like_hir_attributes_detects_test_attributes(#[case] case_index: usiz &[&["tokio", "test"]], &[&["core", "prelude", "v1", "test"]], ]; - assert_has_test_like_attributes(test_cases[case_index], &[], true); + let segments = test_cases + .get(case_index) + .expect("case index should address a declared test case"); + assert_has_test_like_attributes(segments, &[], true); } #[test] diff --git a/crates/no_expect_outside_tests/src/lib.rs b/crates/no_expect_outside_tests/src/lib.rs index a8154f96c..a528d36b8 100644 --- a/crates/no_expect_outside_tests/src/lib.rs +++ b/crates/no_expect_outside_tests/src/lib.rs @@ -28,8 +28,13 @@ mod ui { whitaker::declare_ui_tests!("ui"); } +// Re-export only the documented lint surface. `impl_late_lint!` also expands +// to the Dylint ABI entry point and lint-pass glue, which have no source +// location that could carry documentation; keeping them out of the public +// path satisfies `missing_docs` without suppressing it. The `no_mangle` +// symbol is still exported from the cdylib for standalone Dylint loading. #[cfg(feature = "dylint-driver")] -pub use driver::*; +pub use driver::{NO_EXPECT_OUTSIDE_TESTS, NoExpectOutsideTests}; #[cfg(not(feature = "dylint-driver"))] mod stub { diff --git a/crates/no_expect_outside_tests/src/lib_ui_tests.rs b/crates/no_expect_outside_tests/src/lib_ui_tests.rs index 12025af99..ddbb07b6b 100644 --- a/crates/no_expect_outside_tests/src/lib_ui_tests.rs +++ b/crates/no_expect_outside_tests/src/lib_ui_tests.rs @@ -9,8 +9,7 @@ use std::{ use camino::Utf8Path; use dylint_testing::ui::Test; use rstest::rstest; -use temp_env::with_vars_unset; -use whitaker_common::test_support::{prepare_fixture, run_test_runner_with_env_guard}; +use whitaker_common::test_support::{env_test_guard, prepare_fixture, run_test_runner}; /// Describes a single example-based regression to run under the test harness. /// @@ -84,16 +83,12 @@ struct DependencyRlib { fn run_example_under_test_harness(spec: &ExampleHarnessRun<'_>) { let crate_name = env!("CARGO_PKG_NAME"); let directory = "examples"; - whitaker::testing::ui::run_with_runner(crate_name, directory, |crate_name, _| { - run_test_runner_with_env_guard(spec.name, || { - with_vars_unset( - ["SCCACHE_GHA_ENABLED", "SCCACHE_PATH", "SCCACHE_ERROR_LOG"], - || { - let mut test = Test::example(crate_name, spec.name); - test.rustc_flags(spec.rustc_flags); - test.run(); - }, - ); + whitaker::testing::ui::run_with_runner(crate_name, directory, |_, _| { + run_test_runner(spec.name, || { + let _guard = env_test_guard(); + let mut test = Test::example(crate_name, spec.name); + test.rustc_flags(spec.rustc_flags); + test.run(); }) }) .unwrap_or_else(|error| { @@ -121,7 +116,8 @@ fn run_fixture_under_test_harness( let harness_flags = test_harness_flags(spec.rustc_flags, spec.extern_crates)?; let harness_flag_refs: Vec<_> = harness_flags.iter().map(String::as_str).collect(); - run_test_runner_with_env_guard(spec.fixture_name, || { + run_test_runner(spec.fixture_name, || { + let _guard = env_test_guard(); let mut test = Test::src_base(spec.crate_name, env.workdir()); if let Some(config) = env.take_config() { test.dylint_toml(config); diff --git a/crates/no_std_fs_operations/src/behaviour.rs b/crates/no_std_fs_operations/src/behaviour.rs index 8061548d2..bea35702e 100644 --- a/crates/no_std_fs_operations/src/behaviour.rs +++ b/crates/no_std_fs_operations/src/behaviour.rs @@ -21,10 +21,10 @@ impl LocalizationWorld { } fn set_operation(&mut self, operation: &str) { - self.operation = operation.to_owned(); + operation.clone_into(&mut self.operation); } - fn mark_failure(&mut self) { + const fn mark_failure(&mut self) { self.failing = true; } @@ -34,26 +34,26 @@ impl LocalizationWorld { let result = if self.failing { localized_messages(&FailingLookup::new("no_std_fs_operations"), &op) } else { - let localizer = self.localizer.as_ref().expect("a locale must be selected"); + let Some(localizer) = self.localizer.as_ref() else { + panic!("a locale must be selected before resolving messages") + }; localized_messages(localizer, &op) }; self.result = Some(result); } fn messages(&self) -> &StdFsMessages { - self.result - .as_ref() - .expect("localization result should be recorded") - .as_ref() - .expect("localization should succeed") + let Some(Ok(messages)) = self.result.as_ref().map(Result::as_ref) else { + panic!("localization should have been resolved successfully") + }; + messages } fn error(&self) -> &I18nError { - self.result - .as_ref() - .expect("localization result should be recorded") - .as_ref() - .expect_err("localization should fail") + let Some(Err(error)) = self.result.as_ref().map(Result::as_ref) else { + panic!("localization should have been resolved to a failure") + }; + error } } @@ -94,21 +94,30 @@ fn when_localize(world: &WorldCell) { fn then_primary(world: &WorldCell, snippet: String) { let needle = snippet.trim_matches('"'); let borrow = world.borrow(); - assert!(borrow.messages().primary().contains(needle)); + assert!( + borrow.messages().primary().contains(needle), + "primary message should mention `{needle}`" + ); } #[then("the note references {snippet}")] fn then_note(world: &WorldCell, snippet: String) { let needle = snippet.trim_matches('"'); let borrow = world.borrow(); - assert!(borrow.messages().note().contains(needle)); + assert!( + borrow.messages().note().contains(needle), + "note message should mention `{needle}`" + ); } #[then("the help references {snippet}")] fn then_help(world: &WorldCell, snippet: String) { let needle = snippet.trim_matches('"'); let borrow = world.borrow(); - assert!(borrow.messages().help().contains(needle)); + assert!( + borrow.messages().help().contains(needle), + "help message should mention `{needle}`" + ); } #[then("localization fails for {key}")] @@ -116,7 +125,11 @@ fn then_failure(world: &WorldCell, key: String) { let borrow = world.borrow(); match borrow.error() { I18nError::MissingMessage { key: missing, .. } => { - assert_eq!(missing, &key.trim_matches('"')) + assert_eq!( + missing, + &key.trim_matches('"'), + "localization should fail for the requested key" + ); } } } diff --git a/crates/no_std_fs_operations/src/diagnostics.rs b/crates/no_std_fs_operations/src/diagnostics.rs index ebb6c981a..dd6519efd 100644 --- a/crates/no_std_fs_operations/src/diagnostics.rs +++ b/crates/no_std_fs_operations/src/diagnostics.rs @@ -16,16 +16,16 @@ use whitaker_common::i18n::{BundleLookup, I18nError, resolve_message_set}; pub(crate) fn emit_diagnostic( cx: &LateContext<'_>, span: Span, - usage: StdFsUsage, + usage: &StdFsUsage, localizer: &Localizer, ) { let mut args: Arguments<'static> = Arguments::default(); args.insert( Cow::Borrowed("operation"), - FluentValue::from(usage.operation().to_string()), + FluentValue::from(usage.operation().to_owned()), ); - let fallback_operation = usage.operation().to_string(); + let fallback_operation = usage.operation().to_owned(); let resolution = MessageResolution { lint_name: "no_std_fs_operations", key: MESSAGE_KEY, @@ -40,9 +40,9 @@ pub(crate) fn emit_diagnostic( NO_STD_FS_OPERATIONS, span, rustc_lint::errors::DiagDecorator(move |lint| { - lint.primary_message(sanitize_message(messages.primary().to_string())); - lint.note(sanitize_message(messages.note().to_string())); - lint.help(sanitize_message(messages.help().to_string())); + lint.primary_message(sanitize_message(messages.primary())); + lint.note(sanitize_message(messages.note())); + lint.help(sanitize_message(messages.help())); }), ); } @@ -59,16 +59,15 @@ fn fallback_messages(operation: &str) -> StdFsMessages { "std::fs reads the ambient working directory, ", "so it bypasses the capability model enforced by cap-std and camino." ) - .to_string(); + .to_owned(); let help = concat!( "Pass `cap_std::fs::Dir` handles and camino::Utf8Path/Utf8PathBuf arguments down to the call ", "so only explicit capabilities touch the filesystem." - ) - .to_string(); + ).to_owned(); DiagnosticMessageSet::new(primary, note, help) } -fn sanitize_message(text: String) -> String { +fn sanitize_message(text: &str) -> String { text.chars() .filter(|ch| !matches!(ch, '\u{2068}' | '\u{2069}')) .collect() @@ -83,7 +82,7 @@ pub(crate) fn localized_messages( let mut args: Arguments<'static> = Arguments::default(); args.insert( Cow::Borrowed("operation"), - FluentValue::from(operation.to_string()), + FluentValue::from(operation.to_owned()), ); resolve_message_set(lookup, MESSAGE_KEY, &args) } @@ -96,13 +95,13 @@ mod tests { #[test] fn removes_isolation_marks() { - let raw = String::from("\u{2068}std::fs::read\u{2069}"); + let raw = "\u{2068}std::fs::read\u{2069}"; assert_eq!(sanitize_message(raw), "std::fs::read"); } #[test] fn preserves_clean_text() { - let raw = String::from("std::fs::File::open"); - assert_eq!(sanitize_message(raw.clone()), raw); + let raw = "std::fs::File::open"; + assert_eq!(sanitize_message(raw), raw); } } diff --git a/crates/no_std_fs_operations/src/driver.rs b/crates/no_std_fs_operations/src/driver.rs index 379e0cb78..d38b50cd9 100644 --- a/crates/no_std_fs_operations/src/driver.rs +++ b/crates/no_std_fs_operations/src/driver.rs @@ -16,6 +16,8 @@ use whitaker_common::SimplePath; use whitaker_common::i18n::Localizer; use whitaker_common::i18n::get_localizer_for_lint; +/// Lint pass that tracks localization and exclusion state while checking +/// `std::fs` usage. pub struct NoStdFsOperations { localizer: Localizer, excluded: bool, @@ -32,13 +34,31 @@ impl Default for NoStdFsOperations { } } -dylint_linting::impl_late_lint! { - pub NO_STD_FS_OPERATIONS, - Deny, - "std::fs operations bypass Whitaker's capability-based filesystem policy", - NoStdFsOperations::default() +/// Dylint lint declaration and registration glue. +/// +/// `impl_late_lint!` expands to the Dylint ABI entry point and the +/// `impl_lint_pass!` accessor, neither of which has a source location that +/// could carry documentation. Isolating the invocation keeps the expectation +/// scoped to exactly those generated items. +mod declaration { + #![expect( + missing_docs, + reason = "dylint_linting macro expansion emits items with no documentable source location" + )] + + use super::NoStdFsOperations; + + dylint_linting::impl_late_lint! { + /// Denies direct `std::fs` use that bypasses the capability policy. + pub NO_STD_FS_OPERATIONS, + Deny, + "std::fs operations bypass Whitaker's capability-based filesystem policy", + NoStdFsOperations::default() + } } +pub use declaration::NO_STD_FS_OPERATIONS; + impl<'tcx> LateLintPass<'tcx> for NoStdFsOperations { fn check_crate(&mut self, cx: &LateContext<'tcx>) { let shared_config = SharedConfig::load(); @@ -113,7 +133,7 @@ impl<'tcx> LateLintPass<'tcx> for NoStdFsOperations { .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()); + usage = Self::receiver_usage_for_method(cx, receiver, segment.ident.as_str()); } self.emit_optional(cx, site, usage); @@ -157,7 +177,7 @@ struct LintSite { impl NoStdFsOperations { /// Centralizes exclusion logic for all lint pass methods. #[inline] - fn should_skip(&self) -> bool { + const fn should_skip(&self) -> bool { self.excluded } @@ -165,7 +185,7 @@ impl NoStdFsOperations { if self.should_skip() { return; } - let Some(usage) = usage else { + let Some(detected) = usage else { return; }; // Resolve the enclosing item's path only for genuine `std::fs` hits @@ -174,7 +194,7 @@ impl NoStdFsOperations { if self.is_path_excluded(cx, site.hir_id) { return; } - self.emit(cx, site.span, usage); + self.emit(cx, site.span, &detected); } /// Returns `true` when the item enclosing `hir_id` falls within a @@ -187,7 +207,7 @@ impl NoStdFsOperations { .excludes(&enclosing_item_path(cx, hir_id)) } - fn emit(&self, cx: &LateContext<'_>, span: Span, usage: StdFsUsage) { + fn emit(&self, cx: &LateContext<'_>, span: Span, usage: &StdFsUsage) { emit_diagnostic(cx, span, usage, &self.localizer); } @@ -196,7 +216,6 @@ impl NoStdFsOperations { /// 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<'_>, receiver: &hir::Expr<'_>, method: &str, diff --git a/crates/no_std_fs_operations/src/exclusion.rs b/crates/no_std_fs_operations/src/exclusion.rs index e8861b85e..eb6d481f9 100644 --- a/crates/no_std_fs_operations/src/exclusion.rs +++ b/crates/no_std_fs_operations/src/exclusion.rs @@ -67,7 +67,7 @@ impl PathExclusions { /// /// The driver consults this before resolving an item's path so the common /// case pays no lookup cost. - pub(crate) fn is_empty(&self) -> bool { + pub(crate) const fn is_empty(&self) -> bool { self.prefixes.is_empty() } @@ -77,10 +77,9 @@ impl PathExclusions { /// `std::fs` usage. pub(crate) fn excludes(&self, item_path: &SimplePath) -> bool { let item = item_path.segments(); - self.prefixes.iter().any(|prefix| { - let prefix = prefix.segments(); - prefix.len() <= item.len() && item[..prefix.len()] == *prefix - }) + self.prefixes + .iter() + .any(|prefix| item.starts_with(prefix.segments())) } } @@ -105,7 +104,8 @@ fn bounded_entry(entry: &str) -> String { while !entry.is_char_boundary(end) { end -= 1; } - format!("{:?}… ({} bytes total)", &entry[..end], entry.len()) + let truncated = entry.get(..end).unwrap_or(entry); + format!("{truncated:?}… ({} bytes total)", entry.len()) } #[cfg(test)] @@ -247,9 +247,9 @@ mod tests { // Oracle: excluded iff some configured prefix is a genuine // segment-wise prefix of the item path. - let expected = configured.iter().any(|prefix| { - prefix.len() <= item.len() && item[..prefix.len()] == prefix[..] - }); + let expected = configured + .iter() + .any(|prefix| item.starts_with(prefix.as_slice())); prop_assert_eq!(exclusions.excludes(&item_path), expected); } diff --git a/crates/no_std_fs_operations/src/exclusion_behaviour.rs b/crates/no_std_fs_operations/src/exclusion_behaviour.rs index 542bdd72a..4c202706f 100644 --- a/crates/no_std_fs_operations/src/exclusion_behaviour.rs +++ b/crates/no_std_fs_operations/src/exclusion_behaviour.rs @@ -27,8 +27,10 @@ impl ExclusionWorld { } fn suppressed(&self) -> bool { - self.suppressed - .expect("a usage should have been evaluated before asserting") + let Some(suppressed) = self.suppressed else { + panic!("a usage should have been evaluated before asserting") + }; + suppressed } } diff --git a/crates/no_std_fs_operations/src/lib.rs b/crates/no_std_fs_operations/src/lib.rs index 7af853cb0..1db30eb08 100644 --- a/crates/no_std_fs_operations/src/lib.rs +++ b/crates/no_std_fs_operations/src/lib.rs @@ -20,5 +20,10 @@ mod tests; mod usage; #[cfg(feature = "dylint-driver")] pub use config::NoStdFsConfig; +// Re-export only the documented lint surface. `impl_late_lint!` also expands +// to the Dylint ABI entry point and lint-pass glue, which have no source +// location that could carry documentation; keeping them out of the public +// path satisfies `missing_docs` without suppressing it. The `no_mangle` +// symbol is still exported from the cdylib for standalone Dylint loading. #[cfg(feature = "dylint-driver")] -pub use driver::*; +pub use driver::{NO_STD_FS_OPERATIONS, NoStdFsOperations}; diff --git a/crates/no_std_fs_operations/src/usage/mod.rs b/crates/no_std_fs_operations/src/usage/mod.rs index 51bf45488..1ca9d50d0 100644 --- a/crates/no_std_fs_operations/src/usage/mod.rs +++ b/crates/no_std_fs_operations/src/usage/mod.rs @@ -26,7 +26,7 @@ impl StdFsUsage { /// let usage = StdFsUsage::new(String::from("std::fs::read")); /// assert_eq!(usage.operation(), "std::fs::read"); /// ``` - pub fn new(operation: String) -> Self { + pub const fn new(operation: String) -> Self { Self { operation } } @@ -122,12 +122,11 @@ pub fn classify_def_id(cx: &LateContext<'_>, def_id: DefId) -> Option bool { - let segments = path.segments(); - segments.len() >= 2 && segments[0] == "std" && segments[1] == "fs" + matches!(path.segments(), [first, second, ..] if first == "std" && second == "fs") } -/// Returns true if the character should be rejected in a valid std::fs label. -fn is_invalid_label_char(ch: char) -> bool { +/// Returns true if the character should be rejected in a valid `std::fs` label. +const fn is_invalid_label_char(ch: char) -> bool { ch.is_whitespace() || matches!(ch, '(' | ')') } @@ -144,11 +143,9 @@ pub(crate) fn label_is_std_fs(label: &str) -> bool { return false; } - if !label.starts_with("std::fs") { + let Some(remainder) = label.strip_prefix("std::fs") else { return false; - } - - let remainder = &label["std::fs".len()..]; + }; if remainder.is_empty() { return true; } diff --git a/crates/no_std_fs_operations/src/usage/tests.rs b/crates/no_std_fs_operations/src/usage/tests.rs index 5bf11f04e..5941d0528 100644 --- a/crates/no_std_fs_operations/src/usage/tests.rs +++ b/crates/no_std_fs_operations/src/usage/tests.rs @@ -1,4 +1,4 @@ -//! Tests for classifying std::fs usage and its reporting metadata. +//! Tests for classifying `std::fs` usage and its reporting metadata. use super::{StdFsUsage, label_is_std_fs}; use rstest::rstest; diff --git a/crates/no_std_fs_operations/tests/no_default_features_build.rs b/crates/no_std_fs_operations/tests/no_default_features_build.rs index 1f3c14d45..c04b01d7d 100644 --- a/crates/no_std_fs_operations/tests/no_default_features_build.rs +++ b/crates/no_std_fs_operations/tests/no_default_features_build.rs @@ -40,7 +40,14 @@ const CRATE: &str = "no_std_fs_operations"; #[test] fn crate_builds_without_dylint_driver_feature() -> anyhow::Result<()> { let workspace_root = workspace_root()?; - let target_dir = TempDir::new().context("failed to create isolated target directory")?; + let workspace = Dir::open_ambient_dir(&workspace_root, ambient_authority()) + .context("failed to open workspace for isolated target directory")?; + workspace + .create_dir_all("target/tests/no_default_features_build") + .context("failed to create isolated target directory parent")?; + let target_root = workspace_root.join("target/tests/no_default_features_build"); + let target_dir = + TempDir::new_in(&target_root).context("failed to create isolated target directory")?; let output = Command::new("cargo") .arg("check") diff --git a/crates/no_unwrap_or_else_panic/src/context/mod.rs b/crates/no_unwrap_or_else_panic/src/context/mod.rs index 16a0c71fe..84b06df22 100644 --- a/crates/no_unwrap_or_else_panic/src/context/mod.rs +++ b/crates/no_unwrap_or_else_panic/src/context/mod.rs @@ -34,10 +34,7 @@ use rustc_span::sym; /// 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 summarize_context<'tcx>( - cx: &LateContext<'tcx>, - hir_id: hir::HirId, -) -> ContextSummary { +pub(crate) fn summarize_context(cx: &LateContext<'_>, hir_id: hir::HirId) -> ContextSummary { let mut entries = Vec::new(); let mut has_cfg_test = false; @@ -72,7 +69,7 @@ fn context_entry_for(node: Node<'_>, attrs: &[hir::Attribute]) -> Option Some(ContextEntry::new( - "impl".to_string(), + "impl".to_owned(), ContextKind::Impl, convert_attributes(attrs), )), @@ -93,7 +90,7 @@ fn context_entry_for(node: Node<'_>, attrs: &[hir::Attribute]) -> Option None, }, Node::Block(_) => Some(ContextEntry::new( - "block".to_string(), + "block".to_owned(), ContextKind::Block, convert_attributes(attrs), )), @@ -121,10 +118,10 @@ fn convert_attribute(attr: &hir::Attribute) -> Attribute { return Attribute::new(AttributePath::from(PARSED_ATTRIBUTE_PLACEHOLDER), kind); }; let mut names = attr.path().into_iter().map(|symbol| symbol.to_string()); - match names.next() { - Some(first) => AttributePath::new(std::iter::once(first).chain(names)), - None => AttributePath::from("unknown"), - } + names.next().map_or_else( + || AttributePath::from("unknown"), + |first| AttributePath::new(std::iter::once(first).chain(names)), + ) }; Attribute::new(path, kind) @@ -135,7 +132,7 @@ fn attribute_style(attr: &hir::Attribute) -> AttrStyle { match attr { hir::Attribute::Unparsed(item) => item.style, hir::Attribute::Parsed(HirAttributeKind::DocComment { style, .. }) => *style, - _ => AttrStyle::Outer, + hir::Attribute::Parsed(_) => AttrStyle::Outer, } } @@ -155,24 +152,21 @@ fn is_cfg_test_attribute(attr: &hir::Attribute) -> bool { }; let path = attr.path(); - if path.len() != 1 { + let [name] = path.as_slice() else { return false; - } + }; - if path[0] == sym::cfg { + if *name == sym::cfg { return attr .meta_item_list() - .map(|items| items.iter().cloned().any(meta_item_inner_contains_test)) - .unwrap_or(false); + .is_some_and(|items| items.iter().cloned().any(meta_item_inner_contains_test)); } - if path[0] != sym::cfg_attr { + if *name != sym::cfg_attr { return false; } - attr.meta_item_list() - .map(check_cfg_attr_for_test) - .unwrap_or(false) + attr.meta_item_list().is_some_and(check_cfg_attr_for_test) } #[cfg(feature = "dylint-driver")] @@ -215,32 +209,27 @@ fn meta_contains_test_with_polarity(meta: &MetaItem, is_positive: bool) -> bool } if path_is_ident(&meta.path, sym::not) { - return meta - .meta_item_list() - .map(|items| { - items - .iter() - .cloned() - .any(|item| meta_item_inner_contains_test_with_polarity(item, !is_positive)) - }) - .unwrap_or(false); + return meta.meta_item_list().is_some_and(|items| { + items + .iter() + .cloned() + .any(|item| meta_item_inner_contains_test_with_polarity(item, !is_positive)) + }); } meta.meta_item_list() - .map(|items| items.iter().cloned().any(meta_item_inner_contains_test)) - .unwrap_or(false) + .is_some_and(|items| items.iter().cloned().any(meta_item_inner_contains_test)) } #[cfg(feature = "dylint-driver")] fn meta_contains_test_cfg(meta: &MetaItem) -> bool { meta.meta_item_list() - .map(|items| items.iter().cloned().any(meta_item_inner_contains_test)) - .unwrap_or(false) + .is_some_and(|items| items.iter().cloned().any(meta_item_inner_contains_test)) } #[cfg(feature = "dylint-driver")] fn path_is_ident(path: &AstPath, ident: rustc_span::Symbol) -> bool { - path.segments.len() == 1 && path.segments[0].ident.name == ident + matches!(&*path.segments, [segment] if segment.ident.name == ident) } #[cfg(test)] diff --git a/crates/no_unwrap_or_else_panic/src/context/tests.rs b/crates/no_unwrap_or_else_panic/src/context/tests.rs index 20a410c89..d22d97079 100644 --- a/crates/no_unwrap_or_else_panic/src/context/tests.rs +++ b/crates/no_unwrap_or_else_panic/src/context/tests.rs @@ -31,7 +31,7 @@ fn convert_attribute_handles_parsed_must_use() { // Should return a placeholder "parsed" path instead of panicking. assert_eq!( attribute.path().segments(), - &[PARSED_ATTRIBUTE_PLACEHOLDER.to_string()] + &[PARSED_ATTRIBUTE_PLACEHOLDER.to_owned()] ); assert_eq!(attribute.kind(), AttributeKind::Outer); } diff --git a/crates/no_unwrap_or_else_panic/src/diagnostics.rs b/crates/no_unwrap_or_else_panic/src/diagnostics.rs index 97ea2d72b..d8a7628d3 100644 --- a/crates/no_unwrap_or_else_panic/src/diagnostics.rs +++ b/crates/no_unwrap_or_else_panic/src/diagnostics.rs @@ -48,9 +48,9 @@ pub(crate) fn emit_diagnostic( NO_UNWRAP_OR_ELSE_PANIC, expr.span, rustc_lint::errors::DiagDecorator(|lint| { - lint.primary_message(messages.primary().to_string()); - lint.span_note(receiver.span, messages.note().to_string()); - lint.help(messages.help().to_string()); + lint.primary_message(messages.primary().to_owned()); + lint.span_note(receiver.span, messages.note().to_owned()); + lint.help(messages.help().to_owned()); }), ); } diff --git a/crates/no_unwrap_or_else_panic/src/driver.rs b/crates/no_unwrap_or_else_panic/src/driver.rs index eaf1588ea..b96f11979 100644 --- a/crates/no_unwrap_or_else_panic/src/driver.rs +++ b/crates/no_unwrap_or_else_panic/src/driver.rs @@ -14,13 +14,31 @@ use std::collections::HashSet; use whitaker::SharedConfig; use whitaker_common::i18n::{Localizer, get_localizer_for_lint}; -dylint_linting::impl_late_lint! { - pub NO_UNWRAP_OR_ELSE_PANIC, - Deny, - "forbid `unwrap_or_else` whose closure panics (directly or via unwrap/expect)", - NoUnwrapOrElsePanic::default() +/// Dylint lint declaration and registration glue. +/// +/// `impl_late_lint!` expands to the Dylint ABI entry point and the +/// `impl_lint_pass!` accessor, neither of which has a source location that +/// could carry documentation. Isolating the invocation keeps the expectation +/// scoped to exactly those generated items. +mod declaration { + #![expect( + missing_docs, + reason = "dylint_linting macro expansion emits items with no documentable source location" + )] + + use super::NoUnwrapOrElsePanic; + + dylint_linting::impl_late_lint! { + /// Denies `unwrap_or_else` fallbacks whose closure panics. + pub NO_UNWRAP_OR_ELSE_PANIC, + Deny, + "forbid `unwrap_or_else` whose closure panics (directly or via unwrap/expect)", + NoUnwrapOrElsePanic::default() + } } +pub use declaration::NO_UNWRAP_OR_ELSE_PANIC; + #[derive(Debug, Default, Deserialize)] #[serde(default, deny_unknown_fields)] struct Config { @@ -110,7 +128,7 @@ impl<'tcx> LateLintPass<'tcx> for NoUnwrapOrElsePanic { ); let panic_info = closure_panics(cx, body_id); - if !should_flag(&self.policy, &summary, &panic_info, self.is_doctest) { + if !should_flag(self.policy, summary, panic_info, self.is_doctest) { return; } @@ -118,8 +136,8 @@ impl<'tcx> LateLintPass<'tcx> for NoUnwrapOrElsePanic { } } -fn is_inside_harness_test_function<'tcx>( - cx: &LateContext<'tcx>, +fn is_inside_harness_test_function( + cx: &LateContext<'_>, hir_id: hir::HirId, harness_test_functions: &HashSet, ) -> bool { @@ -138,8 +156,8 @@ 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 summarize_context_with_harness<'tcx>( - cx: &LateContext<'tcx>, +fn summarize_context_with_harness( + cx: &LateContext<'_>, hir_id: hir::HirId, is_test_harness: bool, harness_test_functions: &HashSet, @@ -151,12 +169,13 @@ fn summarize_context_with_harness<'tcx>( summary } -fn closure_body(expr: &hir::Expr<'_>) -> Option { +const fn closure_body(expr: &hir::Expr<'_>) -> Option { match expr.kind { ExprKind::Closure(hir::Closure { body, .. }) => Some(*body), _ => None, } } + fn load_configuration() -> Config { match dylint_linting::config::(LINT_NAME) { Ok(Some(config)) => config, diff --git a/crates/no_unwrap_or_else_panic/src/lib.rs b/crates/no_unwrap_or_else_panic/src/lib.rs index 7407c8d8f..c862c6fae 100644 --- a/crates/no_unwrap_or_else_panic/src/lib.rs +++ b/crates/no_unwrap_or_else_panic/src/lib.rs @@ -23,8 +23,13 @@ mod panic_detector; #[cfg(feature = "dylint-driver")] mod policy; +// Re-export only the documented lint surface. `impl_late_lint!` also expands +// to the Dylint ABI entry point and lint-pass glue, which have no source +// location that could carry documentation; keeping them out of the public +// path satisfies `missing_docs` without suppressing it. The `no_mangle` +// symbol is still exported from the cdylib for standalone Dylint loading. #[cfg(feature = "dylint-driver")] -pub use driver::*; +pub use driver::{NO_UNWRAP_OR_ELSE_PANIC, NoUnwrapOrElsePanic}; #[cfg(not(feature = "dylint-driver"))] mod stub { diff --git a/crates/no_unwrap_or_else_panic/src/panic_detector.rs b/crates/no_unwrap_or_else_panic/src/panic_detector.rs index d114a7627..c958aba99 100644 --- a/crates/no_unwrap_or_else_panic/src/panic_detector.rs +++ b/crates/no_unwrap_or_else_panic/src/panic_detector.rs @@ -50,7 +50,7 @@ impl PanicInfo { /// Returns `true` when the closure has at least one interpolated panic /// and no plain (non-interpolating) panic. #[must_use] - pub(crate) fn is_interpolated_only(&self) -> bool { + pub(crate) const fn is_interpolated_only(self) -> bool { self.has_interpolated_panic && !self.has_plain_panic } } @@ -58,7 +58,7 @@ impl PanicInfo { /// Analyses the closure referenced by `body_id` and returns a [`PanicInfo`] /// describing whether it panics and distinguishing plain vs interpolated panics. #[must_use] -pub(crate) fn closure_panics<'tcx>(cx: &LateContext<'tcx>, body_id: hir::BodyId) -> PanicInfo { +pub(crate) fn closure_panics(cx: &LateContext<'_>, body_id: hir::BodyId) -> PanicInfo { let mut detector = PanicDetector { cx, panics: false, @@ -85,12 +85,12 @@ pub(crate) fn receiver_is_option_or_result<'tcx>( } fn ty_is_option_or_result<'tcx>(cx: &LateContext<'tcx>, ty: ty::Ty<'tcx>) -> bool { - let ty = cx + let normalized = cx .tcx .normalize_erasing_regions(cx.typing_env(), ty::Unnormalized::new_wip(ty)) .peel_refs(); - let Some(adt) = ty.ty_adt_def() else { + let Some(adt) = normalized.ty_adt_def() else { return false; }; @@ -105,7 +105,7 @@ struct PanicDetector<'a, 'tcx> { has_interpolated_panic: bool, } -impl<'a, 'tcx> rustc_hir::intravisit::Visitor<'tcx> for PanicDetector<'a, 'tcx> { +impl<'tcx> rustc_hir::intravisit::Visitor<'tcx> for PanicDetector<'_, 'tcx> { fn visit_expr(&mut self, expr: &'tcx Expr<'tcx>) { if is_panic_call(self.cx, expr) { self.panics = true; @@ -157,11 +157,11 @@ fn is_panic_call(cx: &LateContext<'_>, expr: &Expr<'_>) -> bool { /// entry point) for `Arguments::new_v1` or `Arguments::new_v1_formatted`, /// which are only used when format arguments are present. /// -/// The check examines the message argument's call tree for fmt::Arguments +/// The check examines the message argument's call tree for `fmt::Arguments` /// constructors, but only considers calls that are part of the compiler- -/// generated format_args expansion. This avoids false positives from unrelated +/// generated `format_args` expansion. This avoids false positives from unrelated /// user code like `panic_any(MyType::new_v1())` where `MyType::new_v1()` is -/// the payload, not a format_args constructor. +/// the payload, not a `format_args` constructor. fn panic_args_use_interpolation<'tcx>(cx: &LateContext<'tcx>, expr: &Expr<'tcx>) -> bool { // Extract the panic message argument (first argument to the panic call). let ExprKind::Call(_, args) = expr.kind else { @@ -187,7 +187,7 @@ struct RuntimeArgsFinder<'a, 'tcx> { found: bool, } -impl<'a, 'tcx> rustc_hir::intravisit::Visitor<'tcx> for RuntimeArgsFinder<'a, 'tcx> { +impl<'tcx> rustc_hir::intravisit::Visitor<'tcx> for RuntimeArgsFinder<'_, 'tcx> { fn visit_expr(&mut self, expr: &'tcx Expr<'tcx>) { if self.found { return; diff --git a/crates/no_unwrap_or_else_panic/src/policy.rs b/crates/no_unwrap_or_else_panic/src/policy.rs index df7c4a6e9..f837dad72 100644 --- a/crates/no_unwrap_or_else_panic/src/policy.rs +++ b/crates/no_unwrap_or_else_panic/src/policy.rs @@ -39,13 +39,13 @@ impl LintPolicy { /// has_plain_panic: true, /// has_interpolated_panic: false, /// }; -/// assert!(should_flag(&policy, &summary, &info, false)); +/// assert!(should_flag(policy, summary, info, false)); /// ``` #[must_use] -pub(crate) fn should_flag( - policy: &LintPolicy, - summary: &ContextSummary, - panic_info: &PanicInfo, +pub(crate) const fn should_flag( + policy: LintPolicy, + summary: ContextSummary, + panic_info: PanicInfo, is_doctest: bool, ) -> bool { if !panic_info.panics { @@ -155,13 +155,9 @@ mod tests { })] fn policy_evaluation(#[case] case: PolicyCase) { assert_eq!( - should_flag( - &case.policy, - &case.context, - &case.panic_info, - case.is_doctest - ), - case.should_flag + should_flag(case.policy, case.context, case.panic_info, case.is_doctest), + case.should_flag, + "policy evaluation should match the expected decision" ); } } diff --git a/crates/no_unwrap_or_else_panic/src/tests/behaviour.rs b/crates/no_unwrap_or_else_panic/src/tests/behaviour.rs index 5f0d6a065..4b7666b4a 100644 --- a/crates/no_unwrap_or_else_panic/src/tests/behaviour.rs +++ b/crates/no_unwrap_or_else_panic/src/tests/behaviour.rs @@ -21,12 +21,12 @@ struct DecisionWorld { } impl DecisionWorld { - fn evaluate(&self) -> bool { + const fn evaluate(&self) -> bool { let policy = LintPolicy::new(self.allow_in_main.get()); should_flag( - &policy, - &self.summary.get(), - &self.panic_info.get(), + policy, + self.summary.get(), + self.panic_info.get(), self.is_doctest.get(), ) } diff --git a/crates/rstest_helper_should_be_fixture/examples/collection_zero_diagnostic.rs b/crates/rstest_helper_should_be_fixture/examples/collection_zero_diagnostic.rs index 5f015040c..01e152378 100644 --- a/crates/rstest_helper_should_be_fixture/examples/collection_zero_diagnostic.rs +++ b/crates/rstest_helper_should_be_fixture/examples/collection_zero_diagnostic.rs @@ -67,7 +67,7 @@ fn rstest_helper_call_collection_stays_silent(fixture: &str) { assert_eq!(built, "fixture-suffix"); } -fn nested_helper(fixture: &str) -> &str { +const fn nested_helper(fixture: &str) -> &str { fixture } diff --git a/crates/rstest_helper_should_be_fixture/src/collector.rs b/crates/rstest_helper_should_be_fixture/src/collector.rs index b7c458255..70f54632f 100644 --- a/crates/rstest_helper_should_be_fixture/src/collector.rs +++ b/crates/rstest_helper_should_be_fixture/src/collector.rs @@ -38,7 +38,7 @@ impl CallSiteRecord { /// assert_eq!(record.callee_def_id, callee); /// # } /// ``` - pub(crate) fn new( + pub(crate) const fn new( callee_def_id: DefId, fingerprint: ArgFingerprint, test_source_def_id: DefId, @@ -137,10 +137,7 @@ impl CallSiteCollector { if !self.seen.insert(location) { debug!( target: "rstest_helper_should_be_fixture", - "dropping duplicate rstest helper call-site evidence: callee={}, lo={:?}, hi={:?}", - callee_key, - lo, - hi, + "dropping duplicate rstest helper call-site evidence: callee={callee_key}, lo={lo:?}, hi={hi:?}", ); return false; } @@ -291,8 +288,7 @@ fn local_fixture_atom( } else { debug!( target: "rstest_helper_should_be_fixture", - "lowering unsupported local argument: `{}` is not an rstest fixture local", - name, + "lowering unsupported local argument: `{name}` is not an rstest fixture local", ); ArgAtom::unsupported() } @@ -336,8 +332,7 @@ pub(crate) fn resolve_local_callee<'tcx>( } else { debug!( target: "rstest_helper_should_be_fixture", - "callee resolution skipped non-local or non-function callee: {:?}", - def_id, + "callee resolution skipped non-local or non-function callee: {def_id:?}", ); None } diff --git a/crates/rstest_helper_should_be_fixture/src/driver.rs b/crates/rstest_helper_should_be_fixture/src/driver.rs index fe40f9997..8d2426387 100644 --- a/crates/rstest_helper_should_be_fixture/src/driver.rs +++ b/crates/rstest_helper_should_be_fixture/src/driver.rs @@ -40,13 +40,31 @@ const DEFAULT_PROVIDER_PARAM_ATTRIBUTES: &[&str] = type ConfigLoadResult = Result; -dylint_linting::impl_late_lint! { - pub RSTEST_HELPER_SHOULD_BE_FIXTURE, - Warn, - "repeated rstest helper calls should be extracted into fixtures", - RstestHelperShouldBeFixture::default() +/// Dylint lint declaration and registration glue. +/// +/// `impl_late_lint!` expands to the Dylint ABI entry point and the +/// `impl_lint_pass!` accessor, neither of which has a source location that +/// could carry documentation. Isolating the invocation keeps the expectation +/// scoped to exactly those generated items. +mod declaration { + #![expect( + missing_docs, + reason = "dylint_linting macro expansion emits items with no documentable source location" + )] + + use super::RstestHelperShouldBeFixture; + + dylint_linting::impl_late_lint! { + /// Warns when repeated rstest helper calls should become fixtures. + pub RSTEST_HELPER_SHOULD_BE_FIXTURE, + Warn, + "repeated rstest helper calls should be extracted into fixtures", + RstestHelperShouldBeFixture::default() + } } +pub use declaration::RSTEST_HELPER_SHOULD_BE_FIXTURE; + /// Configuration for the `rstest_helper_should_be_fixture` lint. /// /// Values are loaded from `dylint.toml` and normalized so threshold settings @@ -131,10 +149,10 @@ impl RstestHelperShouldBeFixture { fn apply_loaded_crate_configuration( &mut self, config: ConfigLoadResult, - shared_config: SharedConfig, + shared_config: &SharedConfig, ) { - let config = match config { - Ok(config) => config, + let resolved = match config { + Ok(loaded) => loaded, Err(error) => { debug!( target: LINT_NAME, @@ -144,10 +162,10 @@ impl RstestHelperShouldBeFixture { } }; - self.apply_crate_configuration(config, shared_config); + self.apply_crate_configuration(resolved, shared_config); } - fn apply_crate_configuration(&mut self, config: Config, shared_config: SharedConfig) { + fn apply_crate_configuration(&mut self, config: Config, shared_config: &SharedConfig) { debug!( target: LINT_NAME, "applying `{LINT_NAME}` configuration: min_calls={}, min_distinct_tests={}, \ @@ -185,8 +203,7 @@ impl RstestHelperShouldBeFixture { { debug!( target: LINT_NAME, - "skipping helper call-site collection for non-rstest function: def_id={:?}", - def_id, + "skipping helper call-site collection for non-rstest function: def_id={def_id:?}", ); return; } @@ -204,7 +221,7 @@ impl RstestHelperShouldBeFixture { impl<'tcx> LateLintPass<'tcx> for RstestHelperShouldBeFixture { fn check_crate(&mut self, cx: &LateContext<'tcx>) { - self.apply_loaded_crate_configuration(load_configuration(), load_shared_config()); + self.apply_loaded_crate_configuration(load_configuration(), &load_shared_config()); self.rstest_collection_roots = whitaker::hir::collect_rstest_companion_test_functions(cx); } @@ -241,8 +258,7 @@ impl<'tcx> LateLintPass<'tcx> for RstestHelperShouldBeFixture { if let Err(error) = self.write_collection_summary() { debug!( target: LINT_NAME, - "failed to write rstest helper call-site collection summary: {}", - error, + "failed to write rstest helper call-site collection summary: {error}", ); } debug!( @@ -262,12 +278,15 @@ impl RstestHelperShouldBeFixture { self.collector.record_count(), ); for (callee, records) in self.collector.iter() { - summary.push_str(&format!("callee={callee};records={}\n", records.len())); + summary.push_str("callee="); + summary.push_str(callee); + summary.push_str(";records="); + summary.push_str(&records.len().to_string()); + summary.push('\n'); for record in records { - summary.push_str(&format!( - "fingerprint={}\n", - redacted_fingerprint_shape(&record.fingerprint) - )); + summary.push_str("fingerprint="); + summary.push_str(&redacted_fingerprint_shape(&record.fingerprint)); + summary.push('\n'); } } summary diff --git a/crates/rstest_helper_should_be_fixture/src/driver_tests.rs b/crates/rstest_helper_should_be_fixture/src/driver_tests.rs index b855167f1..843f8d26b 100644 --- a/crates/rstest_helper_should_be_fixture/src/driver_tests.rs +++ b/crates/rstest_helper_should_be_fixture/src/driver_tests.rs @@ -154,7 +154,7 @@ fn applying_crate_configuration_initializes_pass_state() { ..Config::default() }; - pass.apply_crate_configuration(config.clone(), SharedConfig::default()); + pass.apply_crate_configuration(config.clone(), &SharedConfig::default()); assert_eq!(pass.config, config.normalized()); assert!(pass.detection_options.use_expansion_trace_fallback()); @@ -174,7 +174,7 @@ fn check_crate_configuration_loads_and_normalizes_config() { pass.apply_loaded_crate_configuration( loaded_configuration::(Ok(Some(config))), - SharedConfig::default(), + &SharedConfig::default(), ); assert_eq!(pass.config.min_calls, 2); diff --git a/crates/rstest_helper_should_be_fixture/src/lib.rs b/crates/rstest_helper_should_be_fixture/src/lib.rs index fe2b6a98f..4d2af2b1f 100644 --- a/crates/rstest_helper_should_be_fixture/src/lib.rs +++ b/crates/rstest_helper_should_be_fixture/src/lib.rs @@ -13,5 +13,10 @@ mod driver; #[cfg(feature = "dylint-driver")] mod visitor; +// Re-export only the documented lint surface. `impl_late_lint!` also expands +// to the Dylint ABI entry point and lint-pass glue, which have no source +// location that could carry documentation; keeping them out of the public +// path satisfies `missing_docs` without suppressing it. The `no_mangle` +// symbol is still exported from the cdylib for standalone Dylint loading. #[cfg(feature = "dylint-driver")] -pub use driver::*; +pub use driver::{RSTEST_HELPER_SHOULD_BE_FIXTURE, RstestHelperShouldBeFixture}; diff --git a/crates/rstest_helper_should_be_fixture/src/visitor.rs b/crates/rstest_helper_should_be_fixture/src/visitor.rs index 28ae8c412..d890a199f 100644 --- a/crates/rstest_helper_should_be_fixture/src/visitor.rs +++ b/crates/rstest_helper_should_be_fixture/src/visitor.rs @@ -31,7 +31,7 @@ pub(crate) struct CallSiteVisitor<'a, 'tcx> { } impl<'a, 'tcx> CallSiteVisitor<'a, 'tcx> { - pub(crate) fn new( + pub(crate) const fn new( cx: &'a LateContext<'tcx>, collector: &'a mut CallSiteCollector, test_source_def_id: DefId, @@ -94,7 +94,7 @@ impl<'tcx> Visitor<'tcx> for CallSiteVisitor<'_, 'tcx> { match expr.kind { hir::ExprKind::Call(_, args) => self.collect_call(expr, args), hir::ExprKind::MethodCall(_, receiver, args, _) => { - self.collect_call(expr, std::iter::once(receiver).chain(args)) + self.collect_call(expr, std::iter::once(receiver).chain(args)); } hir::ExprKind::Closure(hir::Closure { .. }) => { self.closure_span_fallbacks.push(expr.span); @@ -115,7 +115,7 @@ impl CallSiteVisitor<'_, '_> { self.closure_span_fallbacks .iter() .rev() - .find_map(|span| whitaker::hir::recover_user_editable_hir_span(*span)) + .find_map(|fallback| whitaker::hir::recover_user_editable_hir_span(*fallback)) }) } } @@ -190,11 +190,13 @@ fn attribute_kind(attr: &hir::Attribute) -> AttributeKind { } } +/// Parsed attributes never reach this helper because `attribute_path` filters +/// them out; treating them as outer keeps the mapping total. fn attribute_style(attr: &hir::Attribute) -> AttrStyle { - let hir::Attribute::Unparsed(item) = attr else { - unreachable!("attribute_path filters parsed attributes"); - }; - item.style + match attr { + hir::Attribute::Unparsed(item) => item.style, + hir::Attribute::Parsed(_) => AttrStyle::Outer, + } } pub(crate) fn redacted_fingerprint_shape(fingerprint: &ArgFingerprint) -> String { @@ -208,7 +210,7 @@ pub(crate) fn redacted_fingerprint_shape(fingerprint: &ArgFingerprint) -> String .join(",") } -fn redacted_atom_shape(atom: &ArgAtom) -> &'static str { +const fn redacted_atom_shape(atom: &ArgAtom) -> &'static str { match atom { ArgAtom::FixtureLocal { .. } => "fixture-local", ArgAtom::ConstLit { .. } => "const-lit", diff --git a/crates/test_must_not_have_example/src/behaviour.rs b/crates/test_must_not_have_example/src/behaviour.rs index 38cd6135d..c52a38f15 100644 --- a/crates/test_must_not_have_example/src/behaviour.rs +++ b/crates/test_must_not_have_example/src/behaviour.rs @@ -13,7 +13,7 @@ struct DocumentationWorld { impl DocumentationWorld { fn push_line(&self, line: &str) { - self.lines.borrow_mut().push(line.to_string()); + self.lines.borrow_mut().push(line.to_owned()); } fn evaluate(&self) { diff --git a/crates/test_must_not_have_example/src/driver.rs b/crates/test_must_not_have_example/src/driver.rs index bc4667a11..453c17def 100644 --- a/crates/test_must_not_have_example/src/driver.rs +++ b/crates/test_must_not_have_example/src/driver.rs @@ -25,13 +25,31 @@ struct Config { additional_test_attributes: Vec, } -dylint_linting::impl_late_lint! { - pub TEST_MUST_NOT_HAVE_EXAMPLE, - Warn, - "test functions should not include examples or fenced code in documentation", - TestMustNotHaveExample::default() +/// Dylint lint declaration and registration glue. +/// +/// `impl_late_lint!` expands to the Dylint ABI entry point and the +/// `impl_lint_pass!` accessor, neither of which has a source location that +/// could carry documentation. Isolating the invocation keeps the expectation +/// scoped to exactly those generated items. +mod declaration { + #![expect( + missing_docs, + reason = "dylint_linting macro expansion emits items with no documentable source location" + )] + + use super::TestMustNotHaveExample; + + dylint_linting::impl_late_lint! { + /// Warns when a test function's documentation contains an example. + pub TEST_MUST_NOT_HAVE_EXAMPLE, + Warn, + "test functions should not include examples or fenced code in documentation", + TestMustNotHaveExample::default() + } } +pub use declaration::TEST_MUST_NOT_HAVE_EXAMPLE; + /// Lint pass that checks test documentation for example sections. pub struct TestMustNotHaveExample { /// Additional attribute paths configured as test-like markers. @@ -61,14 +79,14 @@ enum ItemKindInfo<'a> { } impl<'a> ItemKindInfo<'a> { - fn ident(&self) -> &Ident { + const fn ident(&self) -> &Ident { match self { ItemKindInfo::Item { ident, .. } => ident, ItemKindInfo::ImplItem { ident, .. } | ItemKindInfo::TraitItem { ident, .. } => ident, } } - fn attrs(&self) -> &'a [hir::Attribute] { + const fn attrs(&self) -> &'a [hir::Attribute] { match self { ItemKindInfo::Item { attrs, .. } | ItemKindInfo::ImplItem { attrs, .. } @@ -85,7 +103,7 @@ macro_rules! impl_check_method { let attrs = cx.tcx.hir_attrs(item.hir_id()); self.check_function_item( cx, - ItemKindInfo::$variant { + &ItemKindInfo::$variant { ident: &item.ident, attrs, }, @@ -113,8 +131,7 @@ impl<'tcx> LateLintPass<'tcx> for TestMustNotHaveExample { Err(error) => { debug!( target: LINT_NAME, - "failed to parse `{}` configuration: {error}; using defaults", - LINT_NAME + "failed to parse `{LINT_NAME}` configuration: {error}; using defaults" ); Config::default() } @@ -136,7 +153,7 @@ impl<'tcx> LateLintPass<'tcx> for TestMustNotHaveExample { return; }; let attrs = cx.tcx.hir_attrs(item.hir_id()); - self.check_function_item(cx, ItemKindInfo::Item { ident, attrs }, Some(item)); + self.check_function_item(cx, &ItemKindInfo::Item { ident, attrs }, Some(item)); } } @@ -155,11 +172,7 @@ impl<'tcx> LateLintPass<'tcx> for TestMustNotHaveExample { } impl TestMustNotHaveExample { - fn detect_violation( - &self, - attrs: &[hir::Attribute], - is_test: bool, - ) -> Option { + fn detect_violation(attrs: &[hir::Attribute], is_test: bool) -> Option { if !is_test { return None; } @@ -175,13 +188,13 @@ impl TestMustNotHaveExample { fn emit_violation( &self, cx: &LateContext<'_>, - function: FunctionSite<'_>, + function: &FunctionSite<'_>, violation: DocExampleViolation, ) { let messages = localized_messages(&self.localizer, function.name, violation); - let primary = messages.primary().to_string(); - let note = messages.note().to_string(); - let help = messages.help().to_string(); + let primary = messages.primary().to_owned(); + let note = messages.note().to_owned(); + let help = messages.help().to_owned(); cx.emit_span_lint( TEST_MUST_NOT_HAVE_EXAMPLE, @@ -197,20 +210,26 @@ impl TestMustNotHaveExample { fn check_function_item<'tcx>( &mut self, cx: &LateContext<'tcx>, - item_info: ItemKindInfo<'_>, + item_info: &ItemKindInfo<'_>, item: Option<&'tcx hir::Item<'tcx>>, ) { let attrs = item_info.attrs(); - let is_test = if let Some(item) = item { - is_test_function_item(cx, item, attrs, self.additional_test_attributes.as_slice()) - } else { - has_test_like_hir_attributes(attrs, self.additional_test_attributes.as_slice()) - }; + let is_test = item.map_or_else( + || has_test_like_hir_attributes(attrs, self.additional_test_attributes.as_slice()), + |test_item| { + is_test_function_item( + cx, + test_item, + attrs, + self.additional_test_attributes.as_slice(), + ) + }, + ); - if let Some(violation) = self.detect_violation(attrs, is_test) { + if let Some(violation) = Self::detect_violation(attrs, is_test) { self.emit_violation( cx, - FunctionSite { + &FunctionSite { name: item_info.ident().name.as_str(), span: item_info.ident().span, }, @@ -319,13 +338,13 @@ fn localized_messages( let mut args: Arguments<'static> = Arguments::default(); args.insert( Cow::Borrowed("test"), - FluentValue::from(function_name.to_string()), + FluentValue::from(function_name.to_owned()), ); let reason = violation.note_detail(); args.insert( Cow::Borrowed("reason"), - FluentValue::from(reason.to_string()), + FluentValue::from(reason.to_owned()), ); let resolution = MessageResolution { lint_name: LINT_NAME, diff --git a/crates/test_must_not_have_example/src/heuristics.rs b/crates/test_must_not_have_example/src/heuristics.rs index af5de74a2..18347195f 100644 --- a/crates/test_must_not_have_example/src/heuristics.rs +++ b/crates/test_must_not_have_example/src/heuristics.rs @@ -62,7 +62,10 @@ fn is_examples_heading(line: &str) -> bool { return false; } - let remainder = trimmed[heading_level..].trim_start(); + let Some(after_hashes) = trimmed.get(heading_level..) else { + return false; + }; + let remainder = after_hashes.trim_start(); matches!( remainder .trim_end_matches(|ch: char| ch.is_ascii_whitespace()) diff --git a/crates/test_must_not_have_example/src/lib.rs b/crates/test_must_not_have_example/src/lib.rs index e2f8029b3..a5ea1715e 100644 --- a/crates/test_must_not_have_example/src/lib.rs +++ b/crates/test_must_not_have_example/src/lib.rs @@ -11,8 +11,13 @@ mod heuristics; #[path = "lib_ui_tests.rs"] mod ui; +// Re-export only the documented lint surface. `impl_late_lint!` also expands +// to the Dylint ABI entry point and lint-pass glue, which have no source +// location that could carry documentation; keeping them out of the public +// path satisfies `missing_docs` without suppressing it. The `no_mangle` +// symbol is still exported from the cdylib for standalone Dylint loading. #[cfg(feature = "dylint-driver")] -pub use driver::*; +pub use driver::{TEST_MUST_NOT_HAVE_EXAMPLE, TestMustNotHaveExample}; #[cfg(not(feature = "dylint-driver"))] mod stub { diff --git a/crates/whitaker_clones_core/Cargo.toml b/crates/whitaker_clones_core/Cargo.toml index d3b31fc53..bf1797dd3 100644 --- a/crates/whitaker_clones_core/Cargo.toml +++ b/crates/whitaker_clones_core/Cargo.toml @@ -28,6 +28,7 @@ cap-std = { workspace = true } insta = { workspace = true } metrics-util = { workspace = true } proptest = { workspace = true } +semver = { workspace = true } rstest = { workspace = true } rstest-bdd = { workspace = true } rstest-bdd-macros = { workspace = true } @@ -38,6 +39,7 @@ toml = { workspace = true } [build-dependencies] camino = { workspace = true } cap-std = { workspace = true } +semver = { workspace = true } toml = { workspace = true } [lints] workspace = true diff --git a/crates/whitaker_clones_core/build_support.rs b/crates/whitaker_clones_core/build_support.rs index 51860980e..7a986fc82 100644 --- a/crates/whitaker_clones_core/build_support.rs +++ b/crates/whitaker_clones_core/build_support.rs @@ -8,6 +8,7 @@ use std::{error::Error, io}; use camino::{Utf8Path, Utf8PathBuf}; use cap_std::{ambient_authority, fs_utf8::Dir}; +use semver::Version; const PARSER_DEPENDENCY: &str = "ra_ap_syntax"; pub(crate) fn parser_dependency_requirement(manifest: &str) -> Result> { @@ -27,11 +28,17 @@ pub(crate) fn parser_dependency_requirement(manifest: &str) -> Result Result<&str, io::Error> { - requirement + let version = requirement .strip_prefix('=') - .filter(|version| !version.is_empty()) - .ok_or_else(|| non_exact_workspace_dependency(requirement)) + .ok_or_else(|| non_exact_workspace_dependency(requirement))?; + Version::parse(version).map_err(|_| non_exact_workspace_dependency(requirement))?; + Ok(version) } pub(crate) fn find_workspace_manifest( @@ -76,8 +83,8 @@ fn read_manifest_text(candidate: &Utf8Path) -> Result, Box Ok(Some(manifest)), Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None), Err(error) => Err(error.into()), @@ -118,8 +125,7 @@ fn workspace_manifest_not_found(manifest_dir: &Utf8Path) -> io::Error { io::Error::new( io::ErrorKind::NotFound, format!( - "could not find a parent Cargo.toml with a [workspace] table from `{}`", - manifest_dir + "could not find a parent Cargo.toml with a [workspace] table from `{manifest_dir}`" ), ) } diff --git a/crates/whitaker_clones_core/src/ast/cover.rs b/crates/whitaker_clones_core/src/ast/cover.rs index bb46f80da..7e3f7f292 100644 --- a/crates/whitaker_clones_core/src/ast/cover.rs +++ b/crates/whitaker_clones_core/src/ast/cover.rs @@ -19,18 +19,41 @@ pub fn select_smallest_covering( candidates: &[std::ops::Range], target: &std::ops::Range, ) -> Option { - if target.end < target.start { - return None; + let mut selected: Option = None; + for (index, candidate) in candidates.iter().enumerate() { + let selected_width = selected.map(|selected_index| { + let selected = &candidates[selected_index]; + selected.end - selected.start + }); + if should_select_covering_candidate(candidate, target, selected_width) { + selected = Some(index); + } } - candidates - .iter() - .enumerate() - .filter(|(_, candidate)| { - candidate.end >= candidate.start - && candidate.start <= target.start - && candidate.end >= target.end - }) - .min_by_key(|(_, candidate)| candidate.end - candidate.start) - .map(|(index, _)| index) + selected +} + +/// Return whether `candidate` covers a valid `target` range. +pub(super) fn candidate_covers_target( + candidate: &std::ops::Range, + target: &std::ops::Range, +) -> bool { + candidate.end >= candidate.start + && target.end >= target.start + && candidate.start <= target.start + && candidate.end >= target.end +} + +/// Decide whether production selection should replace its current candidate. +/// +/// Sharing this predicate keeps the parser adapter's pruned traversal and the +/// parser-independent Kani helper on the same coverage and minimal-width +/// rules. Equal-width candidates retain the first candidate encountered. +pub(super) fn should_select_covering_candidate( + candidate: &std::ops::Range, + target: &std::ops::Range, + selected_width: Option, +) -> bool { + candidate_covers_target(candidate, target) + && selected_width.is_none_or(|width| candidate.end - candidate.start < width) } diff --git a/crates/whitaker_clones_core/src/ast/features.rs b/crates/whitaker_clones_core/src/ast/features.rs index 1d12d9731..c7f4160d1 100644 --- a/crates/whitaker_clones_core/src/ast/features.rs +++ b/crates/whitaker_clones_core/src/ast/features.rs @@ -167,7 +167,9 @@ impl ProductionMultiset { #[must_use] pub fn kind_counts(tree: &NormalizedTree) -> KindCounts { let mut counts = KindCounts::default(); - count_node_kinds(tree.root(), Depth::root(), &mut counts); + for_each_node_at_depth(tree.root(), |node, depth| { + counts.increment(node.kind(), depth); + }); counts } @@ -201,22 +203,26 @@ pub fn production_multiset(tree: &NormalizedTree) -> ProductionMultiset { productions } -fn count_node_kinds(node: &NormalizedNode, depth: Depth, counts: &mut KindCounts) { - let mut pending = vec![(node, depth)]; +/// Visit each node in preorder with its depth relative to `root`. +/// +/// Keep traversal separate from the count sink so bounded proofs can validate +/// depth assignment without expanding `BTreeMap` internals. Production callers +/// still accumulate these visits into `KindCounts`. +pub(super) fn for_each_node_at_depth( + root: &NormalizedNode, + mut visit: impl FnMut(&NormalizedNode, Depth), +) { + let mut pending = vec![(root, Depth::root())]; while let Some((current, current_depth)) = pending.pop() { - counts.increment(current.kind(), current_depth); + visit(current, current_depth); let child_depth = next_depth(current_depth); - pending.extend( - current - .children() - .iter() - .rev() - .map(|child| (child, child_depth)), - ); + for child in current.children().iter().rev() { + pending.push((child, child_depth)); + } } } -fn next_depth(depth: Depth) -> Depth { +const fn next_depth(depth: Depth) -> Depth { Depth::new(depth.get().saturating_add(1)) } @@ -272,10 +278,10 @@ mod tests { proptest! { #[test] fn each_representable_count_increases_its_weight( - depth in 0_u16..64, + raw_depth in 0_u16..64, count in 0_u32..u32::MAX ) { - let depth = Depth::new(depth); + let depth = Depth::new(raw_depth); let current_weight = weighted_contribution(depth, count); let increased_weight = weighted_contribution(depth, count + 1); diff --git a/crates/whitaker_clones_core/src/ast/hash.rs b/crates/whitaker_clones_core/src/ast/hash.rs index a56240e66..5e7ac86e3 100644 --- a/crates/whitaker_clones_core/src/ast/hash.rs +++ b/crates/whitaker_clones_core/src/ast/hash.rs @@ -49,26 +49,25 @@ fn seed_hash() -> u64 { } fn hash_node(seed: u64, node: &NormalizedNode) -> u64 { - let mut pending = vec![(node, 0, hash_node_header(seed, node))]; - loop { - let Some((current, child_index, _)) = pending.last_mut() else { - unreachable!("hash_node seeds pending with one node and returns before it empties") - }; - if let Some(child) = current.children().get(*child_index) { - *child_index += 1; + // Pop-based traversal keeps every stack access provably in bounds: each + // iteration pops the top entry, either revisits it with the next child + // pushed on top, or folds its completed hash into the parent. The final + // completed hash is the root's, so the loop needs no unreachable arms. + let mut pending = vec![(node, 0_usize, hash_node_header(seed, node))]; + let mut completed_hash = 0_u64; + while let Some((current, child_index, current_hash)) = pending.pop() { + if let Some(child) = current.children().get(child_index) { + pending.push((current, child_index.saturating_add(1), current_hash)); pending.push((child, 0, hash_node_header(seed, child))); continue; } - let Some((_, _, completed_hash)) = pending.pop() else { - unreachable!("hash_node pops the node it just observed through last_mut") - }; + completed_hash = current_hash; if let Some((_, _, parent_hash)) = pending.last_mut() { - *parent_hash = mix_u64(*parent_hash, completed_hash); - } else { - return completed_hash; + *parent_hash = mix_u64(*parent_hash, current_hash); } } + completed_hash } fn hash_node_header(mut hash: u64, node: &NormalizedNode) -> u64 { @@ -83,7 +82,7 @@ fn child_count(node: &NormalizedNode) -> u64 { u64::try_from(node.children().len()).unwrap_or(u64::MAX) } -fn leaf_tag(leaf: Option) -> u8 { +const fn leaf_tag(leaf: Option) -> u8 { match leaf { Some(LeafClass::Ident) => b'i', Some(LeafClass::Literal) => b'l', diff --git a/crates/whitaker_clones_core/src/ast/kani.rs b/crates/whitaker_clones_core/src/ast/kani.rs index bda926fb6..47c4d236d 100644 --- a/crates/whitaker_clones_core/src/ast/kani.rs +++ b/crates/whitaker_clones_core/src/ast/kani.rs @@ -5,21 +5,42 @@ use std::ops::Range; +use super::features::for_each_node_at_depth; use super::{ - ByteSpan, Depth, KindId, LeafClass, NormalizedNode, NormalizedTree, kind_counts, - select_smallest_covering, + ByteSpan, KindId, LeafClass, NormalizedNode, NormalizedTree, select_smallest_covering, }; const KANI_AST_MAX_DEPTH: usize = 3; const KANI_AST_MAX_CHILDREN: usize = 2; -const KANI_AST_UNWIND: usize = 5; +const KANI_AST_NODE_COUNT: usize = 5; +const KANI_AST_UNWIND: usize = KANI_AST_NODE_COUNT + 1; const _: () = assert!(KANI_AST_MAX_CHILDREN == 2); -const _: () = assert!(KANI_AST_UNWIND == KANI_AST_MAX_DEPTH + 2); +const _: () = assert!(KANI_AST_UNWIND == 6); + +/// Create a symbolic kind from four ordered ids. +fn symbolic_kind_from_small_alphabet() -> KindId { + // Two symbolic bits produce four ordered ids, enough to realize every + // equality partition and ordering of the root, two child, and query labels. + // The full-width `KindId` round trip is proved in a separate harness. + let low_bit: bool = kani::any(); + let high_bit: bool = kani::any(); + let raw = u16::from(low_bit) | (u16::from(high_bit) << 1); + KindId::new(raw) +} -fn symbolic_kind() -> KindId { - KindId::new(kani::any()) +/// Assert that `KindId` comparisons agree with comparisons of their raw ids. +fn assert_kind_comparisons_match_raw(left: KindId, right: KindId) { + kani::assert( + (left == right) == (left.get() == right.get()), + "KindId equality must preserve raw-id equality", + ); + kani::assert( + left.cmp(&right) == left.get().cmp(&right.get()), + "KindId ordering must preserve raw-id ordering", + ); } +/// Build the valid static span used by synthetic AST fixtures. fn ast_span() -> ByteSpan { match ByteSpan::new("abcd", 0, 1) { Ok(span) => span, @@ -27,10 +48,12 @@ fn ast_span() -> ByteSpan { } } +/// Build a leaf node with the requested kind and class. fn leaf(kind: KindId, leaf: LeafClass) -> NormalizedNode { NormalizedNode::new(kind, Some(leaf), Vec::new()) } +/// Build a branch node with the requested kind and children. fn branch(kind: KindId, children: Vec) -> NormalizedNode { NormalizedNode::new(kind, None, children) } @@ -40,6 +63,7 @@ struct BranchSpec { leaf_kind: KindId, } +/// Build a five-node tree whose leaves sit at depth two. fn bounded_tree( root_kind: KindId, left: BranchSpec, @@ -59,6 +83,7 @@ fn bounded_tree( NormalizedTree::new(branch(root_kind, children), ast_span()) } +/// Generate a valid range within the bounded offset domain. fn symbolic_range() -> Range { let start: u32 = kani::any(); let end: u32 = kani::any(); @@ -68,14 +93,17 @@ fn symbolic_range() -> Range { start..end } +/// Return whether `candidate` encloses `target`. fn covers(candidate: &Range, target: &Range) -> bool { candidate.start <= target.start && candidate.end >= target.end } +/// Compare the lengths of two valid ranges. fn strictly_smaller(left: &Range, right: &Range) -> bool { left.end - left.start < right.end - right.start } +/// Assert that `selected` is a smallest candidate covering `target`. fn assert_minimal_cover(candidates: &[Range], target: &Range, selected: usize) { let selected_candidate = &candidates[selected]; kani::assert( @@ -93,6 +121,7 @@ fn assert_minimal_cover(candidates: &[Range], target: &Range, selected } } +/// Verify selection returns a smallest covering range. #[kani::proof] #[kani::unwind(5)] fn verify_smallest_covering_node_selects_minimal_range() { @@ -112,14 +141,15 @@ fn verify_smallest_covering_node_selects_minimal_range() { } } +/// Verify missing covering candidates leave selection empty. #[kani::proof] #[kani::unwind(5)] -fn verify_smallest_covering_root_fallback() { +fn verify_missing_cover_returns_none() { let empty: [Range; 0] = []; let target = 4..8; kani::assert( select_smallest_covering(&empty, &target).is_none(), - "empty candidate sets must fall back to the parser root", + "empty candidate sets must leave selection empty", ); let candidates = [0..2, 9..12, 12..16]; @@ -131,75 +161,78 @@ fn verify_smallest_covering_root_fallback() { ); kani::assert( select_smallest_covering(&candidates, &target).is_none(), - "non-covering candidate sets must fall back to the parser root", + "non-covering candidate sets must leave selection empty", ); } +/// Verify `KindId` preserves every bit of its raw `u16` id. #[kani::proof] -#[kani::unwind(5)] -fn verify_kind_index_is_bounded() { +#[kani::unwind(1)] +fn verify_kind_id_round_trips_full_u16_domain() { let raw: u16 = kani::any(); let kind = KindId::new(raw); - kani::assert(kind.get() == raw, "KindId must preserve the lowered index"); +} + +/// Verify traversal does not observe nodes beyond the configured depth. +/// +/// Six unwind steps cover five node visits and the final termination check. +/// The child loop needs at most three steps (two children plus exhaustion), +/// including its iterator helpers. Fixed kinds keep this proof focused on +/// depth; the production `BTreeMap` is exercised by runtime and property tests. +/// Unwinding assertions remain enabled. +#[kani::proof] +#[kani::unwind(6)] +fn verify_kind_counts_stop_at_max_depth() { + let kind = KindId::new(1); let tree = bounded_tree( kind, BranchSpec { - kind: symbolic_kind(), - leaf_kind: symbolic_kind(), + kind, + leaf_kind: kind, }, BranchSpec { - kind: symbolic_kind(), - leaf_kind: symbolic_kind(), + kind, + leaf_kind: kind, }, true, ); - let beyond_bound = Depth::new(KANI_AST_MAX_DEPTH as u16 + 1); + let mut depth_counts = [0_u8; KANI_AST_MAX_DEPTH + 1]; + let mut observed_beyond_max_depth = false; + for_each_node_at_depth(tree.root(), |_, depth| { + let depth_index = usize::from(depth.get()); + if depth_index < depth_counts.len() { + depth_counts[depth_index] = depth_counts[depth_index].saturating_add(1); + } else { + observed_beyond_max_depth = true; + } + }); kani::assert( - kind_counts(&tree).count(kind, beyond_bound) == 0, + !observed_beyond_max_depth, "bounded trees must contain no nodes beyond KANI_AST_MAX_DEPTH", ); + kani::assert( + depth_counts[0] == 1 + && depth_counts[1] == 2 + && depth_counts[2] == 2 + && depth_counts[3] == 0, + "the depth-only fixture must count all five nodes", + ); } +/// Verify `KindId` equality and ordering over the documented small alphabet. #[kani::proof] -#[kani::unwind(5)] -fn verify_count_accumulation_is_order_independent_bounded() { - let root_kind = symbolic_kind(); - let left_branch_kind = symbolic_kind(); - let right_branch_kind = symbolic_kind(); - let left_leaf_kind = symbolic_kind(); - let right_leaf_kind = symbolic_kind(); - let forward = bounded_tree( - root_kind, - BranchSpec { - kind: left_branch_kind, - leaf_kind: left_leaf_kind, - }, - BranchSpec { - kind: right_branch_kind, - leaf_kind: right_leaf_kind, - }, - true, - ); - let reverse = bounded_tree( - root_kind, - BranchSpec { - kind: left_branch_kind, - leaf_kind: left_leaf_kind, - }, - BranchSpec { - kind: right_branch_kind, - leaf_kind: right_leaf_kind, - }, - false, - ); - let query_kind = symbolic_kind(); - let query_depth = Depth::new(kani::any()); - kani::assume(query_depth.get() <= KANI_AST_MAX_DEPTH as u16); - - kani::assert( - kind_counts(&forward).count(query_kind, query_depth) - == kind_counts(&reverse).count(query_kind, query_depth), - "kind-count contribution folds must ignore sibling visit order for the bounded tree", - ); +#[kani::unwind(1)] +fn verify_kind_id_equality_and_order_cover_small_alphabet() { + let root_kind = symbolic_kind_from_small_alphabet(); + let left_kind = symbolic_kind_from_small_alphabet(); + let right_kind = symbolic_kind_from_small_alphabet(); + let query_kind = symbolic_kind_from_small_alphabet(); + + assert_kind_comparisons_match_raw(root_kind, left_kind); + assert_kind_comparisons_match_raw(root_kind, right_kind); + assert_kind_comparisons_match_raw(root_kind, query_kind); + assert_kind_comparisons_match_raw(left_kind, right_kind); + assert_kind_comparisons_match_raw(left_kind, query_kind); + assert_kind_comparisons_match_raw(right_kind, query_kind); } diff --git a/crates/whitaker_clones_core/src/ast/lowering.rs b/crates/whitaker_clones_core/src/ast/lowering.rs index d85af4789..0d792a5e2 100644 --- a/crates/whitaker_clones_core/src/ast/lowering.rs +++ b/crates/whitaker_clones_core/src/ast/lowering.rs @@ -10,6 +10,7 @@ use ra_ap_syntax::{ }; use tracing::{debug, error, warn}; +use super::cover::{candidate_covers_target, should_select_covering_candidate}; use super::metrics::record_lower_span_metrics; use super::{AstError, AstResult, ByteSpan, KindId, LeafClass, NormalizedNode, NormalizedTree}; @@ -50,6 +51,13 @@ fn trace_ast_error( /// Feature-vector emission metrics remain reserved for the 7.3.2 scoring and /// SARIF Run 1 consumption boundary. /// +/// # Errors +/// +/// Returns an [`AstError`] when the span fails re-validation against +/// `file_text`, when no syntax node covers the requested span, when the +/// selected subtree contains parser error elements, or when lowering exceeds +/// the depth or node budgets. +/// /// # Examples /// /// ``` @@ -73,7 +81,7 @@ pub fn lower_span(file_text: &str, span: ByteSpan) -> AstResult fn lower_span_inner(file_text: &str, span: ByteSpan) -> (AstResult, bool) { let mut recovered = false; let result = (|| -> AstResult { - let span = ByteSpan::new(file_text, span.start(), span.end()).map_err(|error| { + ByteSpan::new(file_text, span.start(), span.end()).map_err(|error| { trace_ast_error( error, "AST span lies outside the supplied source text", @@ -161,24 +169,23 @@ fn validate_covering_node_budget(span: ByteSpan, depth: usize, node_count: usize } fn node_covers_target(node: &SyntaxNode, target: &Range) -> bool { - let range = range_to_u32(node.text_range()); - range.start <= target.start && range.end >= target.end + candidate_covers_target(&range_to_u32(node.text_range()), target) } -/// Records `node` as the current best when it is narrower than the incumbent. +/// Records `node` as the current best when it satisfies the shared selection rule. /// -/// Callers must confirm `node` covers the target before calling this; the -/// smallest such node encountered wins, and the strict `<` comparison keeps the -/// first of any equal-width covering nodes (the shallowest, given pre-order -/// descent). -fn update_smallest_covering_node(selected: &mut Option<(SyntaxNode, u32)>, node: &SyntaxNode) { +/// The parser-independent range predicate is also used by +/// [`super::select_smallest_covering`], which Kani verifies without parsing. +fn update_smallest_covering_node( + selected: &mut Option<(SyntaxNode, u32)>, + node: &SyntaxNode, + target: &Range, +) { let range = range_to_u32(node.text_range()); let width = range.end - range.start; - let is_strictly_smaller = selected - .as_ref() - .is_none_or(|(_, selected_width)| width < *selected_width); + let selected_width = selected.as_ref().map(|(_, selected_width)| *selected_width); - if is_strictly_smaller { + if should_select_covering_candidate(&range, target, selected_width) { *selected = Some((node.clone(), width)); } } @@ -213,7 +220,7 @@ fn select_covering_node(root: &SyntaxNode, span: ByteSpan) -> AstResult AstResult, + /// Normalized nodes emitted so far, including token leaves. + lowered_node_count: Cell, } impl LoweringLimits { - fn new(span: ByteSpan) -> Self { + const fn new(span: ByteSpan) -> Self { Self::with_depth_limit(MAX_AST_DEPTH, span) } - fn with_depth_limit(maximum_depth: usize, span: ByteSpan) -> Self { + const fn with_depth_limit(maximum_depth: usize, span: ByteSpan) -> Self { Self { maximum_depth, maximum_nodes: MAX_AST_NODES, span, - node_count: Cell::new(0), + lowered_node_count: Cell::new(0), } } - /// Lowers `node` while rejecting any parser `ERROR` node or token in the - /// same descent, so error detection needs no separate subtree walk. The - /// running node count bounds the lowered subtree so an oversized selection - /// cannot turn a single parse into unbounded lowering work. - fn lower(&self, node: &SyntaxNode, depth: usize) -> AstResult { + /// Reserve one normalized node at `depth`, enforcing both lowering budgets. + fn claim_node(&self, depth: usize) -> AstResult<()> { if depth > self.maximum_depth { error!( start = self.span.start(), @@ -281,20 +286,30 @@ impl LoweringLimits { limit: self.maximum_depth, }); } - let lowered_nodes = self.node_count.get(); + let lowered_nodes = self.lowered_node_count.get(); if lowered_nodes == self.maximum_nodes { error!( start = self.span.start(), end = self.span.end(), - node_count = lowered_nodes, + normalized_node_count = lowered_nodes, limit = self.maximum_nodes, - "AST lowering exceeded the node budget" + "AST lowering exceeded the normalized-node budget" ); return Err(AstError::TreeTooLarge { limit: self.maximum_nodes, }); } - self.node_count.set(lowered_nodes + 1); + self.lowered_node_count.set(lowered_nodes + 1); + Ok(()) + } + + /// Lowers `node` while rejecting any parser `ERROR` node or token in the + /// same descent, so error detection needs no separate subtree walk. The + /// running normalized-node count bounds the lowered subtree, including + /// token leaves, so an oversized selection cannot turn a parse into + /// unbounded lowering work. + fn lower(&self, node: &SyntaxNode, depth: usize) -> AstResult { + self.claim_node(depth)?; if node.kind() == SyntaxKind::ERROR { return Err(self.unparsable_span()); } @@ -309,6 +324,7 @@ impl LoweringLimits { return Err(self.unparsable_span()); } NodeOrToken::Token(token) if !token.kind().is_trivia() => { + self.claim_node(depth + 1)?; children.push(lower_token(&token)); } NodeOrToken::Token(_) => {} @@ -318,7 +334,7 @@ impl LoweringLimits { Ok(NormalizedNode::new(kind_id(node.kind()), None, children)) } - fn unparsable_span(&self) -> AstError { + const fn unparsable_span(&self) -> AstError { AstError::UnparsableSpan { start: self.span.start(), end: self.span.end(), diff --git a/crates/whitaker_clones_core/src/ast/lowering_tests.rs b/crates/whitaker_clones_core/src/ast/lowering_tests.rs index 56f45fe68..f3a8a2710 100644 --- a/crates/whitaker_clones_core/src/ast/lowering_tests.rs +++ b/crates/whitaker_clones_core/src/ast/lowering_tests.rs @@ -1,6 +1,7 @@ //! Adapter-scoped tests for Rust syntax lowering. use insta::assert_json_snapshot; +use proptest::prelude::*; use ra_ap_syntax::{AstNode, Edition, SourceFile}; use rstest::rstest; use serde_json::json; @@ -104,6 +105,23 @@ fn oversized_source_is_rejected_by_the_node_budget() { ); } +#[rstest] +fn flat_macro_token_tree_is_rejected_by_the_normalized_node_budget() { + let tokens = (0..=MAX_AST_NODES) + .map(|index| format!("token_{index}")) + .collect::>() + .join(" "); + let source = format!("macro_rules! generated {{ () => {{ {tokens} }} }}"); + let span = whole_span(&source).expect("span should validate"); + + assert_eq!( + lower_span(&source, span), + Err(AstError::TreeTooLarge { + limit: MAX_AST_NODES + }) + ); +} + #[rstest] fn deeply_nested_syntax_obeys_the_lowering_depth_budget() { let source = "fn f() { if true { if true { if true { if true { 0; } } } } }"; @@ -210,6 +228,31 @@ fn error_subtree_is_rejected() { ); } +proptest! { + #[test] + fn generated_source_payloads_do_not_change_normalized_ast_hash( + left_name in "[a-z][a-z0-9]{0,8}", + right_name in "[a-z][a-z0-9]{0,8}", + left_literal in 0_u32..u32::MAX, + right_literal in 0_u32..u32::MAX, + ) { + let left_source = format!( + "fn function_{left_name}() {{ let binding_{left_name} = {left_literal}; }}" + ); + let right_source = format!( + "fn function_{right_name}() {{ let binding_{right_name} = {right_literal}; }}" + ); + let left_span = whole_span(&left_source).expect("generated source span should validate"); + let right_span = whole_span(&right_source).expect("generated source span should validate"); + let left = lower_span(&left_source, left_span) + .expect("generated source with identifier and integer literal should parse"); + let right = lower_span(&right_source, right_span) + .expect("generated source with identifier and integer literal should parse"); + + prop_assert_eq!(canonical_hash(&left), canonical_hash(&right)); + } +} + /// Lower the first occurrence of `needle` within `source`. /// /// # Errors diff --git a/crates/whitaker_clones_core/src/ast/tests.rs b/crates/whitaker_clones_core/src/ast/tests.rs index 12ebcbb2c..f16d991f5 100644 --- a/crates/whitaker_clones_core/src/ast/tests.rs +++ b/crates/whitaker_clones_core/src/ast/tests.rs @@ -245,6 +245,23 @@ fn feature_functions_reflect_tree_contents() { proptest! { + #[test] + fn generated_tree_feature_outputs_are_deterministic( + root in normalized_node_strategy() + ) { + let first_tree = tree_with_root(root.clone()).expect("static test span should be valid"); + let second_tree = tree_with_root(root).expect("static test span should be valid"); + let first_counts = kind_counts(&first_tree); + let second_counts = kind_counts(&second_tree); + let first_productions = production_multiset(&first_tree); + let second_productions = production_multiset(&second_tree); + + prop_assert_eq!(&first_counts, &second_counts); + prop_assert_eq!(weighted_histogram(&first_counts), weighted_histogram(&second_counts)); + prop_assert_eq!(first_productions, second_productions); + prop_assert_eq!(canonical_hash(&first_tree), canonical_hash(&second_tree)); + } + #[test] fn count_and_production_features_ignore_sibling_visit_order( root in normalized_node_strategy() diff --git a/crates/whitaker_clones_core/src/ast/tree.rs b/crates/whitaker_clones_core/src/ast/tree.rs index 38ad82c26..0ae7ea304 100644 --- a/crates/whitaker_clones_core/src/ast/tree.rs +++ b/crates/whitaker_clones_core/src/ast/tree.rs @@ -97,7 +97,7 @@ pub enum LeafClass { pub struct NormalizedNode { kind: KindId, leaf: Option, - children: Vec, + children: Vec, } impl NormalizedNode { @@ -111,7 +111,7 @@ impl NormalizedNode { /// leaf-erasure feature extraction rely on that invariant to stay /// unambiguous. #[must_use] - pub fn new(kind: KindId, leaf: Option, children: Vec) -> Self { + pub fn new(kind: KindId, leaf: Option, children: Vec) -> Self { debug_assert!( leaf.is_none() || children.is_empty(), "a leaf-tagged NormalizedNode must have no children" @@ -137,7 +137,7 @@ impl NormalizedNode { /// Returns the ordered child nodes. #[must_use] - pub fn children(&self) -> &[NormalizedNode] { + pub fn children(&self) -> &[Self] { &self.children } } @@ -199,7 +199,15 @@ pub struct ByteSpan { impl ByteSpan { /// Validates and creates a half-open byte span. - pub fn new(source_text: &str, start: u32, end: u32) -> AstResult { + /// + /// # Errors + /// + /// Returns [`AstError::InvalidSpan`] when `end` precedes `start`, + /// [`AstError::EmptySpan`] when the span is zero width, + /// [`AstError::SpanOutOfBounds`] when the span exceeds `source_text`, and + /// [`AstError::NonCharBoundary`] when either offset splits a UTF-8 + /// character. + pub const fn new(source_text: &str, start: u32, end: u32) -> AstResult { if end < start { return Err(AstError::InvalidSpan { start, end }); } diff --git a/crates/whitaker_clones_core/src/hashing.rs b/crates/whitaker_clones_core/src/hashing.rs index 9162970aa..699c176f8 100644 --- a/crates/whitaker_clones_core/src/hashing.rs +++ b/crates/whitaker_clones_core/src/hashing.rs @@ -80,7 +80,12 @@ pub(crate) fn mix_bytes(mut current: u64, bytes: &[u8]) -> u64 { /// assert_eq!(mix_u16(FNV_OFFSET_BASIS, 0x1234), 0x0832_9407_b4eb_8443); /// ``` pub(crate) fn mix_u16(current: u64, value: u16) -> u64 { - mix_bytes(current, &value.to_le_bytes()) + // Decompose into little-endian bytes with explicit masks and shifts so the + // serialization order is spelled out rather than delegated to an + // endianness-specific method. + let low = (value & 0x00ff) as u8; + let high = (value >> 8) as u8; + mix_bytes(current, &[low, high]) } /// Mixes a `u64` using little-endian bytes. @@ -97,8 +102,16 @@ pub(crate) fn mix_u16(current: u64, value: u16) -> u64 { /// 0x999a_7071_7b39_65dd /// ); /// ``` -pub(crate) fn mix_u64(current: u64, value: u64) -> u64 { - mix_bytes(current, &value.to_le_bytes()) +pub(crate) fn mix_u64(mut current: u64, value: u64) -> u64 { + // Mix the eight little-endian bytes lowest first, using explicit masks and + // shifts so the serialization order is spelled out rather than delegated to + // an endianness-specific method. + let mut remaining = value; + for _ in 0..8 { + current = mix_byte(current, (remaining & 0xff) as u8); + remaining >>= 8; + } + current } #[cfg(test)] diff --git a/crates/whitaker_clones_core/src/index/error.rs b/crates/whitaker_clones_core/src/index/error.rs index 58de6ee7a..63bb67b74 100644 --- a/crates/whitaker_clones_core/src/index/error.rs +++ b/crates/whitaker_clones_core/src/index/error.rs @@ -1,4 +1,4 @@ -//! Error types for MinHash sketching and LSH configuration. +//! Error types for `MinHash` sketching and LSH configuration. use thiserror::Error; @@ -16,17 +16,17 @@ pub enum IndexError { /// The number of rows per band must be greater than zero. #[error("LSH rows must be greater than zero")] ZeroRows, - /// The band and row product must equal the fixed MinHash sketch size. + /// The band and row product must equal the fixed `MinHash` sketch size. #[error("LSH bands ({bands}) multiplied by rows ({rows}) must equal {expected}")] InvalidBandRowProduct { /// Requested number of bands. bands: usize, /// Requested number of rows per band. rows: usize, - /// Required fixed MinHash sketch size. + /// Required fixed `MinHash` sketch size. expected: usize, }, - /// MinHash requires at least one retained fingerprint hash. + /// `MinHash` requires at least one retained fingerprint hash. #[error("retained fingerprints must not be empty")] EmptyFingerprintSet, } diff --git a/crates/whitaker_clones_core/src/index/fragment_id.rs b/crates/whitaker_clones_core/src/index/fragment_id.rs index 09cd683c7..b927ac75c 100644 --- a/crates/whitaker_clones_core/src/index/fragment_id.rs +++ b/crates/whitaker_clones_core/src/index/fragment_id.rs @@ -24,7 +24,7 @@ impl FragmentId { /// Returns the fragment identifier as a string slice. #[must_use] - pub fn as_str(&self) -> &str { + pub const fn as_str(&self) -> &str { self.0.as_str() } diff --git a/crates/whitaker_clones_core/src/index/kani.rs b/crates/whitaker_clones_core/src/index/kani.rs index 958d2c37e..5e1f501ac 100644 --- a/crates/whitaker_clones_core/src/index/kani.rs +++ b/crates/whitaker_clones_core/src/index/kani.rs @@ -4,7 +4,7 @@ //! //! ```bash //! cargo kani --manifest-path crates/whitaker_clones_core/Cargo.toml \ -//! --harness verify_min_hasher_sketch_is_deterministic +//! --harness verify_min_hasher_lane_minimum_for_boundary_hashes //! ``` //! //! Or through the repository wrapper: @@ -23,10 +23,10 @@ //! branch with non-zero overflowing inputs. //! - `verify_min_hasher_sketch_rejects_empty_input` checks the empty //! retained-fingerprint boundary. -//! - `verify_min_hasher_sketch_is_deterministic` checks first, middle, and last -//! signature lanes for wide boundary-hash inputs. -//! - `verify_min_hasher_sketch_ignores_duplicate_hashes` compares a wide -//! boundary-hash set against the same set with repeated hashes. +//! - `verify_min_hasher_lane_minimum_for_boundary_hashes` checks the +//! production lane minimum for fixed full-width boundary hashes. +//! - `verify_min_hasher_unique_hashes_sorts_and_deduplicates` checks the +//! production normalization of a fixed, unsorted input with duplicates. //! - `verify_lsh_index_rejects_self_pairs` checks that repeated insertion of //! one fragment cannot produce a self-pair. //! - `verify_lsh_index_canonicalizes_pair_order` checks that reverse lexical @@ -36,17 +36,21 @@ //! - `verify_lsh_index_is_insertion_order_independent` checks that a bounded //! three-fragment index produces the same candidates in forward and reverse //! insertion order. +//! +//! The MinHash proofs stop at one production lane and the input-normalization +//! helper; they do not construct or compare complete 128-lane signatures. +//! Runtime tests cover full-signature determinism, duplicate semantics, and +//! identical-set order independence. use crate::token::Fingerprint; +use super::minhash::{Seed, minimum_mixed_hash, unique_hashes}; use super::{ CandidatePair, FragmentId, IndexError, LshConfig, LshIndex, MINHASH_SIZE, MinHashSignature, MinHasher, }; const KANI_MINHASH_SEED: u64 = 0xA076_1D64_78BD_642F; -const KANI_MINHASH_MIDDLE_SEED: u64 = 0xE703_7ED1_A0B4_28DB; -const KANI_MINHASH_LAST_SEED: u64 = 0x8EBC_6AF0_9C88_C6E3; const KANI_LSH_UNWIND: usize = 7; const _: () = assert!(KANI_LSH_UNWIND == super::lsh::KANI_MAX_RECORDED_PAIRS + 1); @@ -54,20 +58,12 @@ fn fingerprint(hash: u64, start: usize) -> Fingerprint { Fingerprint::new(hash, start..start.saturating_add(1)) } -fn checked_lane_hasher() -> MinHasher { - MinHasher::from_checked_lane_seeds_for_kani( - KANI_MINHASH_SEED, - KANI_MINHASH_MIDDLE_SEED, - KANI_MINHASH_LAST_SEED, - ) -} - fn fragment(id: &str) -> FragmentId { FragmentId::from(id) } fn repeated_signature(value: u64) -> MinHashSignature { - MinHashSignature::new([value; MINHASH_SIZE]) + MinHashSignature::new(&[value; MINHASH_SIZE]) } fn two_band_config() -> LshConfig { @@ -94,44 +90,6 @@ fn assert_lsh_summary_matches_single_pair(index: &LshIndex, expected: CandidateP ); } -fn assert_lane_selects_singleton_min( - lane: usize, - signature: &MinHashSignature, - left_singleton: &MinHashSignature, - right_singleton: &MinHashSignature, -) { - let actual = signature.values()[lane]; - let left = left_singleton.values()[lane]; - let right = right_singleton.values()[lane]; - - kani::assert( - left != right, - "singleton lane values must distinguish the checked input hashes", - ); - kani::assert( - actual == left || actual == right, - "combined sketch lane must select a hash value present in a singleton sketch", - ); - kani::assert( - actual <= left && actual <= right, - "combined sketch lane must contain the minimum singleton hash value", - ); -} - -fn assert_checked_lanes_select_singleton_min( - signature: &MinHashSignature, - left_singleton: &MinHashSignature, - right_singleton: &MinHashSignature, -) { - kani::assert( - signature.values().len() == MINHASH_SIZE, - "signature must keep the fixed MinHash width", - ); - assert_lane_selects_singleton_min(0, signature, left_singleton, right_singleton); - assert_lane_selects_singleton_min(MINHASH_SIZE / 2, signature, left_singleton, right_singleton); - assert_lane_selects_singleton_min(MINHASH_SIZE - 1, signature, left_singleton, right_singleton); -} - #[kani::proof] #[kani::unwind(4)] fn verify_lsh_config_new_smoke() { @@ -259,85 +217,55 @@ fn verify_min_hasher_sketch_rejects_empty_input() { } } +/// Verify the production lane minimum for distinct full-width boundary hashes. #[kani::proof] -#[kani::unwind(4)] -fn verify_min_hasher_sketch_is_deterministic() { +#[kani::unwind(3)] +fn verify_min_hasher_lane_minimum_for_boundary_hashes() { let hashes = [0, u64::MAX]; - let fingerprints = [fingerprint(hashes[0], 0), fingerprint(hashes[1], 1)]; - - let left = checked_lane_hasher() - .sketch(&fingerprints) - .expect("non-empty fingerprints should sketch"); - let right = checked_lane_hasher() - .sketch(&fingerprints) - .expect("non-empty fingerprints should sketch"); - let first_singleton = checked_lane_hasher() - .sketch(&[fingerprints[0].clone()]) - .expect("single fingerprint should sketch"); - let second_singleton = checked_lane_hasher() - .sketch(&[fingerprints[1].clone()]) - .expect("single fingerprint should sketch"); + let seed = Seed(KANI_MINHASH_SEED); + let left = minimum_mixed_hash(seed, &[hashes[0]]); + let right = minimum_mixed_hash(seed, &[hashes[1]]); + let actual = minimum_mixed_hash(seed, &hashes); kani::assert( - left.values()[0] == right.values()[0], - "sketching the same fingerprints must be deterministic for the first lane", - ); - kani::assert( - left.values()[MINHASH_SIZE / 2] == right.values()[MINHASH_SIZE / 2], - "sketching the same fingerprints must be deterministic for the middle lane", + left != right, + "boundary hashes must exercise a distinct mixed-value comparison", ); kani::assert( - left.values()[MINHASH_SIZE - 1] == right.values()[MINHASH_SIZE - 1], - "sketching the same fingerprints must be deterministic for the last lane", + actual == left.min(right), + "a MinHash lane must equal the minimum mixed value for each input hash", ); - assert_checked_lanes_select_singleton_min(&left, &first_singleton, &second_singleton); } +/// Verify production normalization sorts and deduplicates fixed fingerprints. #[kani::proof] -#[kani::unwind(4)] -fn verify_min_hasher_sketch_ignores_duplicate_hashes() { - let hashes = [u64::from(u8::MAX) + 1, u64::from(u16::MAX)]; - let unique = [fingerprint(hashes[0], 0), fingerprint(hashes[1], 1)]; - let duplicated = [ - fingerprint(hashes[0], 0), - fingerprint(hashes[1], 1), - fingerprint(hashes[0], 2), - fingerprint(hashes[1], 3), +#[kani::unwind(8)] +fn verify_min_hasher_unique_hashes_sorts_and_deduplicates() { + let fingerprints = [ + fingerprint(u64::MAX, 0), + fingerprint(17, 1), + fingerprint(17, 2), + fingerprint(0, 3), ]; - let hasher = checked_lane_hasher(); - let unique_signature = hasher - .sketch(&unique) - .expect("non-empty fingerprints should sketch"); - let duplicated_signature = hasher - .sketch(&duplicated) - .expect("non-empty fingerprints should sketch"); - let first_singleton = hasher - .sketch(&[unique[0].clone()]) - .expect("single fingerprint should sketch"); - let second_singleton = hasher - .sketch(&[unique[1].clone()]) - .expect("single fingerprint should sketch"); - - kani::assert( - unique_signature.values()[0] == duplicated_signature.values()[0], - "duplicate fingerprint hashes must not change the first signature lane", - ); - kani::assert( - unique_signature.values()[MINHASH_SIZE / 2] - == duplicated_signature.values()[MINHASH_SIZE / 2], - "duplicate fingerprint hashes must not change the middle signature lane", - ); - kani::assert( - unique_signature.values()[MINHASH_SIZE - 1] - == duplicated_signature.values()[MINHASH_SIZE - 1], - "duplicate fingerprint hashes must not change the last signature lane", - ); - assert_checked_lanes_select_singleton_min( - &unique_signature, - &first_singleton, - &second_singleton, - ); + match unique_hashes(&fingerprints) { + Ok(hashes) => { + kani::assert( + hashes.len() == 3, + "normalization must remove duplicate hashes", + ); + kani::assert( + hashes[0] == 0, + "normalization must sort the smallest hash first", + ); + kani::assert(hashes[1] == 17, "normalization must retain one middle hash"); + kani::assert( + hashes[2] == u64::MAX, + "normalization must sort the largest hash last", + ); + } + Err(_) => kani::assert(false, "non-empty fingerprints must normalize"), + } } #[kani::proof] diff --git a/crates/whitaker_clones_core/src/index/lsh.rs b/crates/whitaker_clones_core/src/index/lsh.rs index f88aaa3af..ec0cc0075 100644 --- a/crates/whitaker_clones_core/src/index/lsh.rs +++ b/crates/whitaker_clones_core/src/index/lsh.rs @@ -1,4 +1,4 @@ -//! Locality-sensitive hashing over fixed-width MinHash signatures. +//! Locality-sensitive hashing over fixed-width `MinHash` signatures. //! //! This module contains [`LshIndex`], the token-pass index that groups //! [`MinHashSignature`] band slices into locality-sensitive hashing (LSH) @@ -66,7 +66,7 @@ impl LshIndex { /// # Ok::<(), whitaker_clones_core::IndexError>(()) /// ``` #[must_use] - pub fn new(config: LshConfig) -> Self { + pub const fn new(config: LshConfig) -> Self { Self { config, #[cfg(not(kani))] diff --git a/crates/whitaker_clones_core/src/index/minhash.rs b/crates/whitaker_clones_core/src/index/minhash.rs index 9da0e5a3f..4512cc0c2 100644 --- a/crates/whitaker_clones_core/src/index/minhash.rs +++ b/crates/whitaker_clones_core/src/index/minhash.rs @@ -1,4 +1,4 @@ -//! Deterministic MinHash sketch generation for token-pass clone candidates. +//! Deterministic `MinHash` sketch generation for token-pass clone candidates. //! //! This module turns retained token [`Fingerprint`] values into fixed-width //! [`MinHashSignature`] values for the clone-detector index API. [`MinHasher`] @@ -7,15 +7,16 @@ //! deduplicated hash values so sketching has set semantics rather than multiset //! semantics. //! -//! Internally, [`Seed`] keeps MinHash seed values distinct from raw fingerprint +//! Internally, [`Seed`] keeps `MinHash` seed values distinct from raw fingerprint //! hashes at the type level. The hashing core still accepts raw `u64` hash //! values because fingerprints and signature lanes are represented as hash //! words throughout the index API. //! -//! The `#[cfg(kani)]` constructors and the unrolled `sketch_values` -//! implementation are proof seams for bounded model checking. They keep Kani -//! harnesses focused on [`MinHasher::sketch`] invariants without changing the -//! production API or the production [`array::from_fn`] implementation. +//! The `#[cfg(kani)]` constructor and unrolled `sketch_values` implementation +//! are proof seams for bounded model checking. Kani checks the lane-minimum +//! operation and fingerprint normalization separately, while runtime tests +//! cover complete [`MinHasher::sketch`] signatures. The proof seams do not +//! change the production API or the production array `map` implementation. //! //! [`LshIndex`](super::LshIndex) in `lsh.rs` consumes the signatures produced //! here by partitioning them into configured bands and emitting candidate @@ -33,14 +34,14 @@ const SEED_STREAM_START: u64 = 0x243F_6A88_85A3_08D3; const SEED_STREAM_STEP: u64 = 0x9E37_79B9_7F4A_7C15; const HASH_MIX: u64 = 0x94D0_49BB_1331_11EB; -/// A typed MinHash seed value. +/// A typed `MinHash` seed value. /// /// Keeps seed values distinct from raw hash values at the type level, /// preventing accidental argument transposition inside the hashing core. #[derive(Clone, Copy, Debug, PartialEq, Eq)] -struct Seed(u64); +pub(super) struct Seed(pub(super) u64); -/// Deterministic MinHash sketcher for retained token fingerprints. +/// Deterministic `MinHash` sketcher for retained token fingerprints. #[derive(Clone, Debug, PartialEq, Eq)] pub struct MinHasher { seeds: [Seed; MINHASH_SIZE], @@ -53,7 +54,7 @@ impl Default for MinHasher { } impl MinHasher { - /// Creates the fixed 128-seed MinHash family used by roadmap item 7.2.2. + /// Creates the fixed 128-seed `MinHash` family used by roadmap item 7.2.2. /// /// # Examples /// @@ -72,10 +73,10 @@ impl MinHasher { Self { seeds } } - /// Builds a MinHash sketch from retained fingerprint hashes. + /// Builds a `MinHash` sketch from retained fingerprint hashes. /// /// Duplicate fingerprint hash values are collapsed first so the sketch uses - /// MinHash set semantics rather than multiset semantics. + /// `MinHash` set semantics rather than multiset semantics. /// /// # Errors /// @@ -84,7 +85,7 @@ impl MinHasher { pub fn sketch(&self, fingerprints: &[Fingerprint]) -> IndexResult { let unique_hashes = unique_hashes(fingerprints)?; let values = sketch_values(&self.seeds, &unique_hashes); - Ok(MinHashSignature::new(values)) + Ok(MinHashSignature::new(&values)) } /// Creates a deterministic proof-only fixture for Kani harnesses. @@ -101,43 +102,19 @@ impl MinHasher { seeds: [Seed(seed); MINHASH_SIZE], } } - - /// Creates a deterministic proof-only fixture with selected lane seeds. - /// - /// This `pub(super)` `#[cfg(kani)]` helper is restricted to the parent - /// module and exists for Kani harnesses that need to observe the first, - /// middle, and last MinHash lanes with distinct seed values. The remaining - /// lanes reuse `first_seed`. - /// - /// `first_seed` initializes every lane, `middle_seed` replaces lane - /// [`MINHASH_SIZE`] / 2, and `last_seed` replaces lane [`MINHASH_SIZE`] - - /// 1. Callers provide raw `u64` seeds at this proof boundary; the function - /// wraps them as internal [`Seed`] values and returns a [`MinHasher`] ready - /// for [`MinHasher::sketch`]. There are no additional preconditions. - #[cfg(kani)] - pub(super) fn from_checked_lane_seeds_for_kani( - first_seed: u64, - middle_seed: u64, - last_seed: u64, - ) -> Self { - let mut seeds = [Seed(first_seed); MINHASH_SIZE]; - seeds[MINHASH_SIZE / 2] = Seed(middle_seed); - seeds[MINHASH_SIZE - 1] = Seed(last_seed); - Self { seeds } - } } -/// Computes the 128-lane MinHash signature in production builds. +/// Computes the 128-lane `MinHash` signature in production builds. /// /// Two `cfg`-specific implementations exist to balance production idiom /// against proof tractability. This production variant uses idiomatic -/// [`array::from_fn`] iteration. It is mechanically equivalent to the +/// array `map` iteration. It is mechanically equivalent to the /// `#[cfg(kani)]` proof-seam variant: both call [`minimum_mixed_hash`] once per -/// lane to produce the same 128-lane MinHash signature from the seeds and +/// lane to produce the same 128-lane `MinHash` signature from the seeds and /// unique hashes. #[cfg(not(kani))] fn sketch_values(seeds: &[Seed; MINHASH_SIZE], unique_hashes: &[u64]) -> [u64; MINHASH_SIZE] { - array::from_fn(|index| minimum_mixed_hash(seeds[index], unique_hashes)) + seeds.map(|seed| minimum_mixed_hash(seed, unique_hashes)) } /// Computes the 128-lane MinHash signature with explicit unrolling for Kani. @@ -146,7 +123,7 @@ fn sketch_values(seeds: &[Seed; MINHASH_SIZE], unique_hashes: &[u64]) -> [u64; M /// against proof tractability. This `#[cfg(kani)]` proof seam manually unrolls /// every [`minimum_mixed_hash`] call so Kani's bounded model checker does not /// spend proof budget on iterator or loop state expansion. The explicit array -/// literal is mechanically equivalent to the production [`array::from_fn`] +/// literal is mechanically equivalent to the production array `map` /// implementation: both compute the same 128-lane MinHash signature from the /// seeds and unique hashes. // `@codescene/suppress` Large Method proof-seam: keep manual unrolling for Kani tractability @@ -284,7 +261,7 @@ fn sketch_values(seeds: &[Seed; MINHASH_SIZE], unique_hashes: &[u64]) -> [u64; M ] } -/// Extracts sorted, deduplicated fingerprint hashes for MinHash set semantics. +/// Extracts sorted, deduplicated fingerprint hashes for `MinHash` set semantics. /// /// This `pub(super)` helper is restricted to the parent module and converts /// retained [`Fingerprint`] values into the Vec-backed set representation used @@ -306,13 +283,17 @@ pub(super) fn unique_hashes(fingerprints: &[Fingerprint]) -> IndexResult u64 { +/// Return the minimum mixed hash for one lane. +/// +/// The sibling Kani harness checks this production operation directly instead +/// of constructing all 128 lanes of a signature. +pub(super) fn minimum_mixed_hash(seed: Seed, hashes: &[u64]) -> u64 { hashes.iter().fold(u64::MAX, |current, hash| { current.min(mix_hash(seed.0, *hash)) }) } -fn mix_hash(seed: u64, hash: u64) -> u64 { +const fn mix_hash(seed: u64, hash: u64) -> u64 { splitmix64(seed ^ hash.wrapping_mul(HASH_MIX)) } @@ -321,16 +302,16 @@ fn mix_hash(seed: u64, hash: u64) -> u64 { /// Both `next_seed` and `splitmix64` intentionally add `SEED_STREAM_STEP` to /// create a non-overlapping, deterministic seed sequence compatible with the /// seed-streaming approach. This double-increment is deliberate, not a bug. -fn next_seed(state: &mut u64) -> Seed { +const fn next_seed(state: &mut u64) -> Seed { *state = state.wrapping_add(SEED_STREAM_STEP); Seed(splitmix64(*state)) } -/// SplitMix64 generator with deliberate `SEED_STREAM_STEP` addition. +/// `SplitMix64` generator with deliberate `SEED_STREAM_STEP` addition. /// /// This function applies `SEED_STREAM_STEP` in addition to the increment in /// `next_seed` to ensure deterministic, non-overlapping seed values. -fn splitmix64(value: u64) -> u64 { +const fn splitmix64(value: u64) -> u64 { let mut mixed = value.wrapping_add(SEED_STREAM_STEP); mixed = (mixed ^ (mixed >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); mixed = (mixed ^ (mixed >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); diff --git a/crates/whitaker_clones_core/src/index/mod.rs b/crates/whitaker_clones_core/src/index/mod.rs index daec3fc48..fcfe230d6 100644 --- a/crates/whitaker_clones_core/src/index/mod.rs +++ b/crates/whitaker_clones_core/src/index/mod.rs @@ -1,4 +1,4 @@ -//! MinHash and LSH indexing for token-pass candidate generation. +//! `MinHash` and LSH indexing for token-pass candidate generation. mod error; mod fragment_id; diff --git a/crates/whitaker_clones_core/src/index/tests.rs b/crates/whitaker_clones_core/src/index/tests.rs index 5f09180c2..05ae395f1 100644 --- a/crates/whitaker_clones_core/src/index/tests.rs +++ b/crates/whitaker_clones_core/src/index/tests.rs @@ -1,4 +1,4 @@ -//! Unit tests for MinHash and LSH candidate generation. +//! Unit tests for `MinHash` and LSH candidate generation. use rstest::{fixture, rstest}; use whitaker_test_macros::allow_fixture_expansion_lints; @@ -111,8 +111,8 @@ fn config_rejects_invalid_inputs(#[case] case: ((usize, usize), IndexError)) { #[rstest] #[case((1, MINHASH_SIZE))] -#[case((2, MINHASH_SIZE / 2))] -#[case((4, MINHASH_SIZE / 4))] +#[case((2, MINHASH_SIZE.div_euclid(2)))] +#[case((4, MINHASH_SIZE.div_euclid(4)))] #[case((32, 4))] fn config_accepts_valid_inputs(#[case] case: (usize, usize)) { let (bands, rows) = case; @@ -241,7 +241,7 @@ fn canonical_ordering_across_multiple_pairs_and_bands( shared_signature: MinHashSignature, distinct_signature: MinHashSignature, ) { - let config = LshConfig::new(4, MINHASH_SIZE / 4).expect("LSH config should validate"); + let config = LshConfig::new(4, MINHASH_SIZE.div_euclid(4)).expect("LSH config should validate"); let mut forward = LshIndex::new(config); forward.insert(&fragment_ids.alpha, &shared_signature); diff --git a/crates/whitaker_clones_core/src/index/types.rs b/crates/whitaker_clones_core/src/index/types.rs index faeef0f91..107ee5a61 100644 --- a/crates/whitaker_clones_core/src/index/types.rs +++ b/crates/whitaker_clones_core/src/index/types.rs @@ -1,10 +1,10 @@ -//! Shared MinHash and LSH index types. +//! Shared `MinHash` and LSH index types. use std::{num::NonZeroUsize, slice::ChunksExact}; use super::{FragmentId, IndexError, IndexResult}; -/// The fixed MinHash sketch width for roadmap item 7.2.2. +/// The fixed `MinHash` sketch width for roadmap item 7.2.2. pub const MINHASH_SIZE: usize = 128; /// A canonical fragment pair emitted by the LSH candidate filter. @@ -58,7 +58,7 @@ impl CandidatePair { } } -/// Validated LSH settings for the fixed-width MinHash sketch. +/// Validated LSH settings for the fixed-width `MinHash` sketch. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct LshConfig { bands: NonZeroUsize, @@ -88,14 +88,17 @@ impl LshConfig { /// # Ok::<(), whitaker_clones_core::IndexError>(()) /// ``` pub fn new(bands: usize, rows: usize) -> IndexResult { - let Some(bands) = NonZeroUsize::new(bands) else { + let Some(band_count) = NonZeroUsize::new(bands) else { return Err(IndexError::ZeroBands); }; - let Some(rows) = NonZeroUsize::new(rows) else { + let Some(row_count) = NonZeroUsize::new(rows) else { return Err(IndexError::ZeroRows); }; - validate_product(bands, rows)?; - Ok(Self { bands, rows }) + validate_product(band_count, row_count)?; + Ok(Self { + bands: band_count, + rows: row_count, + }) } /// Returns the number of LSH bands. @@ -111,7 +114,7 @@ impl LshConfig { } } -fn validate_product(bands: NonZeroUsize, rows: NonZeroUsize) -> IndexResult<()> { +const fn validate_product(bands: NonZeroUsize, rows: NonZeroUsize) -> IndexResult<()> { match bands.get().checked_mul(rows.get()) { Some(MINHASH_SIZE) => Ok(()), Some(_) | None => Err(IndexError::invalid_band_row_product( @@ -121,14 +124,14 @@ fn validate_product(bands: NonZeroUsize, rows: NonZeroUsize) -> IndexResult<()> } } -/// A fixed-width MinHash sketch over retained fingerprint hashes. +/// A fixed-width `MinHash` sketch over retained fingerprint hashes. #[derive(Clone, Debug, PartialEq, Eq)] pub struct MinHashSignature([u64; MINHASH_SIZE]); impl MinHashSignature { #[must_use] - pub(crate) const fn new(values: [u64; MINHASH_SIZE]) -> Self { - Self(values) + pub(crate) const fn new(values: &[u64; MINHASH_SIZE]) -> Self { + Self(*values) } /// Returns the sketch values in order. diff --git a/crates/whitaker_clones_core/src/lib.rs b/crates/whitaker_clones_core/src/lib.rs index 1d3339a6a..1b76b2e20 100644 --- a/crates/whitaker_clones_core/src/lib.rs +++ b/crates/whitaker_clones_core/src/lib.rs @@ -7,7 +7,7 @@ //! - `k`-shingling over normalized token streams. //! - 64-bit Rabin-Karp rolling hashes for shingles. //! - Winnowing to retain stable representative fingerprints. -//! - Deterministic MinHash sketches over retained fingerprints. +//! - Deterministic `MinHash` sketches over retained fingerprints. //! - Locality-sensitive hashing (LSH) candidate generation. //! - Token-pass acceptance and SARIF Run 0 emission for accepted pairs. diff --git a/crates/whitaker_clones_core/src/run0/emit.rs b/crates/whitaker_clones_core/src/run0/emit.rs index 52bd8d504..3dff322bf 100644 --- a/crates/whitaker_clones_core/src/run0/emit.rs +++ b/crates/whitaker_clones_core/src/run0/emit.rs @@ -202,25 +202,25 @@ fn compact_span(region: &whitaker_sarif::Region) -> String { region.end_line.unwrap_or(region.start_line), region .end_column - .unwrap_or(region.start_column.unwrap_or(1)) + .unwrap_or_else(|| region.start_column.unwrap_or(1)) ) } -fn profile_number(profile: NormProfile) -> &'static str { +const fn profile_number(profile: NormProfile) -> &'static str { match profile { NormProfile::T1 => "1", NormProfile::T2 => "2", } } -fn profile_name(profile: NormProfile) -> &'static str { +const fn profile_name(profile: NormProfile) -> &'static str { match profile { NormProfile::T1 => "T1", NormProfile::T2 => "T2", } } -fn profile_sort_key(profile: NormProfile) -> u8 { +const fn profile_sort_key(profile: NormProfile) -> u8 { match profile { NormProfile::T1 => 1, NormProfile::T2 => 2, @@ -300,11 +300,24 @@ fn token_hash(left: &TokenFragment, right: &TokenFragment) -> String { let mut hasher = Sha256::new(); for value in values { - hasher.update(value.to_be_bytes()); + hasher.update(u64_big_endian_bytes(value)); } digest_hex(hasher.finalize()) } +/// Serializes a `u64` as big-endian bytes with explicit masks and shifts. +/// +/// The digest contract fixes the byte order, so the decomposition is spelled +/// out here rather than delegated to an endianness-specific method. +fn u64_big_endian_bytes(value: u64) -> [u8; 8] { + let mut bytes = [0_u8; 8]; + let mut remaining = value; + for slot in bytes.iter_mut().rev() { + *slot = (remaining & 0xff) as u8; + remaining >>= 8; + } + bytes +} fn digest_hex(bytes: impl AsRef<[u8]>) -> String { let mut output = String::new(); for byte in bytes.as_ref() { diff --git a/crates/whitaker_clones_core/src/run0/score.rs b/crates/whitaker_clones_core/src/run0/score.rs index 9a17afb6c..1bcf94584 100644 --- a/crates/whitaker_clones_core/src/run0/score.rs +++ b/crates/whitaker_clones_core/src/run0/score.rs @@ -128,7 +128,7 @@ impl SimilarityThreshold { } /// Returns `true` if the threshold represents a ratio within `(0, 1]`. - fn is_valid(self) -> bool { + const fn is_valid(self) -> bool { self.numerator != 0 && self.denominator != 0 && self.numerator <= self.denominator } @@ -142,7 +142,7 @@ impl SimilarityThreshold { } } -pub(crate) fn select_rule_profile( +pub(crate) const fn select_rule_profile( profile: NormProfile, score: SimilarityRatio, type1_threshold: SimilarityThreshold, @@ -184,7 +184,7 @@ fn unique_hashes(fingerprints: &[Fingerprint]) -> BTreeSet { /// to avoid panics on pathological inputs; clamped results only occur in /// extreme/unrealistic cases. Callers should validate counts if they may /// approach `usize::MAX`. -fn meets_threshold(score: SimilarityRatio, threshold: SimilarityThreshold) -> bool { +const fn meets_threshold(score: SimilarityRatio, threshold: SimilarityThreshold) -> bool { score.intersection.saturating_mul(threshold.denominator) >= score.union.saturating_mul(threshold.numerator) } @@ -195,8 +195,10 @@ fn repeated_division(numerator: usize, denominator: usize) -> Option<(usize, usi return None; } - let quotient = numerator / denominator; - let remainder = numerator % denominator; + // For unsigned integers Euclidean division matches truncating division + // exactly, so the quotient and remainder are unchanged. + let quotient = numerator.div_euclid(denominator); + let remainder = numerator.rem_euclid(denominator); Some((quotient, remainder)) } diff --git a/crates/whitaker_clones_core/src/run0/span.rs b/crates/whitaker_clones_core/src/run0/span.rs index 0023a23b3..bd22fa62d 100644 --- a/crates/whitaker_clones_core/src/run0/span.rs +++ b/crates/whitaker_clones_core/src/run0/span.rs @@ -12,11 +12,17 @@ pub(crate) fn region_for_range( validate_range(fragment_id, source_text, &range)?; let starts = line_starts(source_text); let (start_line, start_column) = line_and_column(source_text, &starts, range.start); - let end_position = source_text[..range.end] + // `validate_range` has already proved `range.end` is an in-bounds char + // boundary, so the fallible slice only fails defensively. + let Some(prefix) = source_text.get(..range.end) else { + return Err(Run0Error::InvalidUtf8Boundary { + fragment_id: fragment_id.to_owned(), + }); + }; + let end_position = prefix .char_indices() .next_back() - .map(|(i, _)| i) - .unwrap_or(range.start); + .map_or(range.start, |(i, _)| i); let (end_line, end_column) = line_and_column(source_text, &starts, end_position); RegionBuilder::new(start_line) @@ -64,9 +70,11 @@ fn line_and_column(source_text: &str, starts: &[usize], offset: usize) -> (usize let line_index = starts .partition_point(|start| *start <= offset) .saturating_sub(1); - let line_start = starts[line_index]; + // `starts` always begins with 0, so both fallbacks preserve the + // start-of-text behaviour if the partition point ever degenerated. + let line_start = starts.get(line_index).copied().unwrap_or(0); let clamped_offset = offset.min(source_text.len()); - let line_slice = &source_text[line_start..clamped_offset]; + let line_slice = source_text.get(line_start..clamped_offset).unwrap_or(""); let utf16_count = line_slice.encode_utf16().count(); (line_index.saturating_add(1), utf16_count.saturating_add(1)) } diff --git a/crates/whitaker_clones_core/src/run0/test_helpers.rs b/crates/whitaker_clones_core/src/run0/test_helpers.rs index 43b90ed39..671fba632 100644 --- a/crates/whitaker_clones_core/src/run0/test_helpers.rs +++ b/crates/whitaker_clones_core/src/run0/test_helpers.rs @@ -16,7 +16,7 @@ pub(super) fn fingerprint(hash: u64, range: std::ops::Range) -> Fingerpri Fingerprint::new(hash, range) } -pub(super) fn fragment(input: FragmentInput<'_>) -> TokenFragment { +pub(super) fn fragment(input: &FragmentInput<'_>) -> TokenFragment { TokenFragment::new( FragmentId::from(input.id), input.profile, diff --git a/crates/whitaker_clones_core/src/run0/tests.rs b/crates/whitaker_clones_core/src/run0/tests.rs index aae0d3a4e..043e630ac 100644 --- a/crates/whitaker_clones_core/src/run0/tests.rs +++ b/crates/whitaker_clones_core/src/run0/tests.rs @@ -22,7 +22,7 @@ fn build_pair_and_accept( right: FragmentInput<'_>, cfg: &TokenPassConfig, ) -> Result, Run0Error> { - let fragments = vec![fragment(left), fragment(right)]; + let fragments = vec![fragment(&left), fragment(&right)]; accept_candidate_pairs(&fragments, &[pair("alpha", "beta")], cfg) } @@ -33,8 +33,11 @@ fn assert_single_accepted( expected_score: SimilarityRatio, ) { assert_eq!(accepted.len(), 1); - assert_eq!(accepted[0].profile(), expected_profile); - assert_eq!(accepted[0].score(), expected_score); + let only = accepted + .first() + .expect("exactly one accepted pair should be present"); + assert_eq!(only.profile(), expected_profile); + assert_eq!(only.score(), expected_score); } /// Resolve the region for `range`, panicking with context on failure. @@ -161,14 +164,14 @@ fn multi_line_region_tracks_trailing_newline() { #[test] fn emit_run0_uses_primary_and_related_locations() { let fragments = vec![ - fragment(FragmentInput { + fragment(&FragmentInput { id: "alpha", profile: NormProfile::T1, file_uri: "src/a.rs", source_text: "fn a() {}\n", hashes: &[(11, 0..8)], }), - fragment(FragmentInput { + fragment(&FragmentInput { id: "beta", profile: NormProfile::T1, file_uri: "src/b.rs", @@ -207,28 +210,28 @@ fn emit_run0_uses_primary_and_related_locations() { #[test] fn emit_run0_sorts_and_deduplicates_results() { let fragments = vec![ - fragment(FragmentInput { + fragment(&FragmentInput { id: "alpha", profile: NormProfile::T1, file_uri: "src/a.rs", source_text: "fn a() {}\n", hashes: &[(11, 0..8)], }), - fragment(FragmentInput { + fragment(&FragmentInput { id: "beta", profile: NormProfile::T1, file_uri: "src/b.rs", source_text: "fn b() {}\n", hashes: &[(11, 0..8)], }), - fragment(FragmentInput { + fragment(&FragmentInput { id: "gamma", profile: NormProfile::T2, file_uri: "src/c.rs", source_text: "fn c(x: i32) {}\n", hashes: &[(1, 0..15), (2, 0..15)], }), - fragment(FragmentInput { + fragment(&FragmentInput { id: "delta", profile: NormProfile::T2, file_uri: "src/d.rs", @@ -258,8 +261,11 @@ fn emit_run0_sorts_and_deduplicates_results() { .unwrap_or_else(|error| panic!("unexpected emit error: {error}")); assert_eq!(run.results.len(), 2); - assert_eq!(run.results[0].rule_id, WHK001_ID); - assert_eq!(run.results[1].rule_id, WHK002_ID); + let [first, second] = run.results.as_slice() else { + panic!("expected exactly two results"); + }; + assert_eq!(first.rule_id, WHK001_ID); + assert_eq!(second.rule_id, WHK002_ID); } #[test] @@ -303,7 +309,7 @@ fn invalid_utf8_boundary_produces_typed_error() { #[test] fn missing_fragment_produces_typed_error() { - let fragments = vec![fragment(FragmentInput { + let fragments = vec![fragment(&FragmentInput { id: "alpha", profile: NormProfile::T1, file_uri: "src/a.rs", @@ -327,14 +333,14 @@ fn missing_fragment_produces_typed_error() { #[test] fn mixed_profiles_produces_typed_error() { let fragments = vec![ - fragment(FragmentInput { + fragment(&FragmentInput { id: "alpha", profile: NormProfile::T1, file_uri: "src/a.rs", source_text: "fn a() {}\n", hashes: &[(1, 0..8), (2, 0..8)], }), - fragment(FragmentInput { + fragment(&FragmentInput { id: "beta", profile: NormProfile::T2, file_uri: "src/b.rs", diff --git a/crates/whitaker_clones_core/src/run0/tests_emit.rs b/crates/whitaker_clones_core/src/run0/tests_emit.rs index 310d07cfe..0b8063c93 100644 --- a/crates/whitaker_clones_core/src/run0/tests_emit.rs +++ b/crates/whitaker_clones_core/src/run0/tests_emit.rs @@ -36,14 +36,14 @@ fn jaccard_returns_none_for_empty_fragments() { fn make_t2_emission_run() -> whitaker_sarif::Run { let fragments = vec![ - fragment(FragmentInput { + fragment(&FragmentInput { id: "alpha", profile: NormProfile::T2, file_uri: "src/a.rs", source_text: "fn a(x: i32) {}\n", hashes: &[(1, 0..15), (2, 0..15)], }), - fragment(FragmentInput { + fragment(&FragmentInput { id: "beta", profile: NormProfile::T2, file_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 8ee0a8229..07fdca3b7 100644 --- a/crates/whitaker_clones_core/src/run0/types.rs +++ b/crates/whitaker_clones_core/src/run0/types.rs @@ -73,19 +73,19 @@ impl TokenFragment { /// Returns the source artefact URI used in SARIF output. #[must_use] - pub fn file_uri(&self) -> &str { + pub const fn file_uri(&self) -> &str { self.file_uri.as_str() } /// Returns the original source text used for byte-range mapping. #[must_use] - pub fn source_text(&self) -> &str { + pub const fn source_text(&self) -> &str { self.source_text.as_str() } /// Returns the retained token fingerprints for this fragment. #[must_use] - pub fn retained_fingerprints(&self) -> &[Fingerprint] { + pub const fn retained_fingerprints(&self) -> &[Fingerprint] { self.retained_fingerprints.as_slice() } } @@ -160,13 +160,13 @@ impl TokenPassConfig { /// Returns the SARIF producer name. #[must_use] - pub fn tool_name(&self) -> &str { + pub const fn tool_name(&self) -> &str { self.tool_name.as_str() } /// Returns the SARIF producer version. #[must_use] - pub fn tool_version(&self) -> &str { + pub const fn tool_version(&self) -> &str { self.tool_version.as_str() } diff --git a/crates/whitaker_clones_core/src/token/fingerprint.rs b/crates/whitaker_clones_core/src/token/fingerprint.rs index dee874702..b20e1213b 100644 --- a/crates/whitaker_clones_core/src/token/fingerprint.rs +++ b/crates/whitaker_clones_core/src/token/fingerprint.rs @@ -182,32 +182,33 @@ fn hash_canonical_identifier_bytes(mut hash: u64, index: usize) -> u64 { hash_byte(hash, b'>') } -fn hash_usize_bytes(mut hash: u64, value: usize) -> u64 { +fn hash_usize_bytes(mut hash: u64, mut value: usize) -> u64 { + const ASCII_DIGITS: [u8; 10] = *b"0123456789"; let mut buffer = [0_u8; 20]; - let mut value = value; - let mut remaining = buffer.as_mut_slice(); if value == 0 { return hash_byte(hash, b'0'); } - while value > 0 { - let (slot, rest) = match remaining.split_last_mut() { - Some(parts) => parts, - None => unreachable!("usize decimal digits always fit within the buffer"), - }; - #[expect( - clippy::cast_possible_truncation, - reason = "a decimal digit always fits in u8" - )] - { - *slot = b'0' + (value % 10) as u8; + // Write digits least-significant first from the end of the buffer. A + // 64-bit `usize` has at most 20 decimal digits, so the reverse iterator + // never runs out before `value` reaches zero. + let mut digits = 0_usize; + for slot in buffer.iter_mut().rev() { + if value == 0 { + break; } - remaining = rest; - value /= 10; + // `rem_euclid(10)` is always below 10, so the lookup never falls + // through to the fallback digit. + *slot = ASCII_DIGITS + .get(value.rem_euclid(10)) + .copied() + .unwrap_or(b'0'); + value = value.div_euclid(10); + digits = digits.saturating_add(1); } - let start = remaining.len(); + let start = buffer.len().saturating_sub(digits); for byte in buffer.iter().skip(start) { hash = hash_byte(hash, *byte); } @@ -215,7 +216,7 @@ fn hash_usize_bytes(mut hash: u64, value: usize) -> u64 { hash } -fn token_kind_tag(token: &NormalizedToken) -> u8 { +const fn token_kind_tag(token: &NormalizedToken) -> u8 { match token.kind { super::types::NormalizedTokenKind::Atom(_) => b'a', super::types::NormalizedTokenKind::Identifier(_) => b'i', diff --git a/crates/whitaker_clones_core/src/token/normalize.rs b/crates/whitaker_clones_core/src/token/normalize.rs index c9a82b8bc..84680dc5a 100644 --- a/crates/whitaker_clones_core/src/token/normalize.rs +++ b/crates/whitaker_clones_core/src/token/normalize.rs @@ -121,10 +121,12 @@ fn process_token( start: range.start, end: range.end, }), - TokenKind::Literal { kind, .. } => { - ensure_literal_is_terminated(kind, &range)?; + TokenKind::Literal { + kind: literal_kind, .. + } => { + ensure_literal_is_terminated(literal_kind, &range)?; Ok(Some(NormalizedToken::new( - normalize_literal(text, kind, profile), + normalize_literal(text, literal_kind, profile), range, ))) } @@ -217,7 +219,7 @@ pub fn normalize(source: &str, profile: NormProfile) -> Result) -> Result<()> { +const fn ensure_literal_is_terminated(kind: LiteralKind, range: &Range) -> Result<()> { let terminated = match kind { LiteralKind::Int { .. } | LiteralKind::Float { .. } => true, LiteralKind::Char { terminated } @@ -245,15 +247,17 @@ fn normalize_ident( profile: NormProfile, state: &mut CanonicalState, ) -> NormalizedTokenKind { - match keyword_label(text) { - Some(keyword) => NormalizedTokenKind::Atom(keyword), - None => normalize_symbolic_text( - text, - profile, - || state.identifier_index(text), - NormalizedTokenKind::Identifier, - ), - } + keyword_label(text).map_or_else( + || { + normalize_symbolic_text( + text, + profile, + || state.identifier_index(text), + NormalizedTokenKind::Identifier, + ) + }, + NormalizedTokenKind::Atom, + ) } fn normalize_literal(text: &str, kind: LiteralKind, profile: NormProfile) -> NormalizedTokenKind { @@ -276,7 +280,7 @@ struct LiteralLabels { kind: &'static str, } -fn literal_labels(kind: LiteralKind) -> LiteralLabels { +const fn literal_labels(kind: LiteralKind) -> LiteralLabels { match kind { LiteralKind::Int { .. } => LiteralLabels { canonical: "", @@ -361,8 +365,7 @@ fn atom_label(kind: TokenKind) -> &'static str { | TokenKind::Unknown => { debug_assert!( false, - "Token kind {:?} should be handled before atom_label", - kind + "Token kind {kind:?} should be handled before atom_label" ); "" } diff --git a/crates/whitaker_clones_core/src/token/tests.rs b/crates/whitaker_clones_core/src/token/tests.rs index 007ffe138..3cfc71125 100644 --- a/crates/whitaker_clones_core/src/token/tests.rs +++ b/crates/whitaker_clones_core/src/token/tests.rs @@ -67,7 +67,9 @@ fn t2_canonicalizes_identifiers_literals_and_lifetimes() { fn byte_ranges_point_to_original_source() { let source = "fn demo() { value + 1 }"; let tokens = normalize(source, NormProfile::T1).expect("normalization should succeed"); - let value = &tokens[5]; + let value = tokens + .get(5) + .expect("token stream should contain the `value` identifier"); assert_eq!(value.range, 12..17); assert_eq!(source.get(value.range.clone()), Some("value")); @@ -102,7 +104,8 @@ fn exact_k_tokens_yields_one_hash() { ); assert_eq!(hashes.len(), 1); - assert_eq!(hashes[0].range, 0..12); + let only = hashes.first().expect("exactly one hash should be present"); + assert_eq!(only.range, 0..12); } #[test] @@ -232,7 +235,11 @@ fn shebang_is_stripped_equivalently_to_shebang_free_source() { "shebang should not affect the normalized token kinds" ); assert_eq!( - with_shebang[0].range.start, + with_shebang + .first() + .expect("normalized stream should not be empty") + .range + .start, source_with_shebang.find("fn").expect("fn present") ); } @@ -300,7 +307,13 @@ fn literal_variants_are_terminated_and_canonicalized( literal_syms.len() >= 2, "expected at least two literal tokens for the repeated literal pair" ); - assert_eq!(literal_syms[0], literal_syms[1], "{assertion_message}"); + let first = literal_syms + .first() + .expect("first literal symbol should be present"); + let second = literal_syms + .get(1) + .expect("second literal symbol should be present"); + assert_eq!(first, second, "{assertion_message}"); } #[test] diff --git a/crates/whitaker_clones_core/src/token/types.rs b/crates/whitaker_clones_core/src/token/types.rs index d1c705340..08a446d01 100644 --- a/crates/whitaker_clones_core/src/token/types.rs +++ b/crates/whitaker_clones_core/src/token/types.rs @@ -108,10 +108,9 @@ impl TryFrom for ShingleSize { type Error = TokenPassError; fn try_from(value: usize) -> Result { - match NonZeroUsize::new(value) { - Some(value) => Ok(Self(value)), - None => Err(TokenPassError::ZeroShingleSize), - } + NonZeroUsize::new(value) + .ok_or(TokenPassError::ZeroShingleSize) + .map(Self) } } @@ -131,10 +130,9 @@ impl TryFrom for WinnowWindow { type Error = TokenPassError; fn try_from(value: usize) -> Result { - match NonZeroUsize::new(value) { - Some(value) => Ok(Self(value)), - None => Err(TokenPassError::ZeroWinnowWindow), - } + NonZeroUsize::new(value) + .ok_or(TokenPassError::ZeroWinnowWindow) + .map(Self) } } diff --git a/crates/whitaker_clones_core/tests/build_script_integration.rs b/crates/whitaker_clones_core/tests/build_script_integration.rs index 553286697..70adac476 100644 --- a/crates/whitaker_clones_core/tests/build_script_integration.rs +++ b/crates/whitaker_clones_core/tests/build_script_integration.rs @@ -10,7 +10,7 @@ use std::{ use camino::{Utf8Path, Utf8PathBuf}; use cap_std::{ambient_authority, fs_utf8::Dir}; use rstest::{fixture, rstest}; -use tempfile::tempdir; +use tempfile::tempdir_in; const FIXTURE_PACKAGE: &str = "parser_pin_build_script_fixture"; @@ -53,40 +53,33 @@ fn build_script_accepts_an_exact_workspace_parser_pin( Ok(()) } +/// Reject missing, loose, partial, and ranged workspace parser pins. #[rstest] -fn build_script_rejects_a_loose_workspace_parser_pin( - #[with(Some("0.0.334"))] build_fixture: Result>, +#[case::loose(Some("0.0.334"), "must be exact-pinned")] +#[case::missing(None, "workspace dependency `ra_ap_syntax` is missing")] +#[case::partial_major(Some("=1"), "must be exact-pinned")] +#[case::partial_minor(Some("=1.2"), "must be exact-pinned")] +#[case::additional_comparator(Some("=1.2.3, <1.2.4"), "must be exact-pinned")] +fn build_script_rejects_invalid_parser_pins( + #[case] requirement: Option<&str>, + #[case] expected_error: &str, ) -> Result<(), Box> { - let fixture = build_fixture?; - let output = cargo_check(&fixture)?; - let stderr = String::from_utf8_lossy(&output.stderr); - - if output.status.success() { - return Err("loose parser pin must fail the build script".into()); - } - if !stderr.contains("must be exact-pinned") { - return Err( - format!("build-script failure must explain the parser-pin rule:\n{stderr}").into(), - ); - } - Ok(()) -} - -#[rstest] -fn build_script_rejects_a_missing_workspace_parser_pin( - #[with(None)] build_fixture: Result>, -) -> Result<(), Box> { - let fixture = build_fixture?; + let fixture = build_fixture(requirement)?; let output = cargo_check(&fixture)?; let stderr = String::from_utf8_lossy(&output.stderr); + let pin_description = requirement.map_or_else( + || "missing parser pin".to_owned(), + |version| format!("parser pin `{version}`"), + ); if output.status.success() { - return Err("missing parser pin must fail the build script".into()); + return Err(format!("{pin_description} must fail the build script").into()); } - if !stderr.contains("workspace dependency `ra_ap_syntax` is missing") { - return Err( - format!("build-script failure must explain the missing parser pin:\n{stderr}").into(), - ); + if !stderr.contains(expected_error) { + return Err(format!( + "build-script failure for {pin_description} must explain the parser-pin rule:\n{stderr}" + ) + .into()); } Ok(()) } @@ -95,7 +88,7 @@ fn build_script_rejects_a_missing_workspace_parser_pin( fn build_fixture( #[default(None)] requirement: Option<&str>, ) -> Result> { - let directory = tempdir()?; + let directory = parser_pin_fixture_directory()?; let fixture_root = Utf8Path::from_path(directory.path()).ok_or("temporary path is not valid UTF-8")?; let manifest_path = fixture_root.join("fixture").join("Cargo.toml"); @@ -140,6 +133,7 @@ fn build_fixture( "[build-dependencies]\n", "camino = \"1.2.1\"\n", "cap-std = {{ version = \"4.0.2\", features = [\"fs_utf8\"] }}\n", + "semver = \"1.0.28\"\n", "toml = \"1.1.2\"\n", ), FIXTURE_PACKAGE = FIXTURE_PACKAGE, @@ -170,6 +164,28 @@ fn build_fixture( }) } +/// Allocate an isolated parser-pin fixture beneath the workspace target tree. +/// +/// Keep nested Cargo output out of the shared system temporary filesystem while +/// preserving a distinct directory for each fixture. +/// +/// # Errors +/// +/// Returns an error if the workspace root cannot be found or the fixture +/// directory cannot be created. +fn parser_pin_fixture_directory() -> Result> { + let crate_directory = Utf8Path::new(env!("CARGO_MANIFEST_DIR")); + let workspace_root = crate_directory + .parent() + .and_then(Utf8Path::parent) + .ok_or("workspace root not found above CARGO_MANIFEST_DIR")?; + let workspace = Dir::open_ambient_dir(workspace_root, ambient_authority())?; + workspace.create_dir_all("target/tests/build_script_integration")?; + Ok(tempdir_in( + workspace_root.join("target/tests/build_script_integration"), + )?) +} + fn cargo_check(fixture: &BuildFixture) -> Result> { Ok(cargo_command("check", fixture).output()?) } diff --git a/crates/whitaker_clones_core/tests/build_script_parsing.rs b/crates/whitaker_clones_core/tests/build_script_parsing.rs index c8a688111..2228209d1 100644 --- a/crates/whitaker_clones_core/tests/build_script_parsing.rs +++ b/crates/whitaker_clones_core/tests/build_script_parsing.rs @@ -82,6 +82,11 @@ fn rejects_invalid_dependency_forms(#[case] manifest: &str, #[case] expected: &s #[case::caret("0.0.334")] #[case::caret_explicit("^0.0.334")] #[case::empty_exact("=")] +#[case::partial_major("=1")] +#[case::partial_minor("=1.2")] +#[case::additional_comparator("=1.2.3, <1.2.4")] +#[case::double_equality("==1.2.3")] +#[case::whitespace("=1.2.3 ")] fn rejects_non_exact_requirements(#[case] requirement: &str) { assert!(exact_version(requirement).is_err()); } @@ -193,13 +198,14 @@ fn read_of_absent_manifest_reports_not_found() -> Result<(), Box Self { + pub const fn new(start_line: usize) -> Self { Self { start_line, start_column: None, @@ -45,35 +45,35 @@ impl RegionBuilder { /// Sets the 1-based start column. #[must_use] - pub fn with_start_column(mut self, col: usize) -> Self { + pub const fn with_start_column(mut self, col: usize) -> Self { self.start_column = Some(col); self } /// Sets the 1-based end line. #[must_use] - pub fn with_end_line(mut self, line: usize) -> Self { + pub const fn with_end_line(mut self, line: usize) -> Self { self.end_line = Some(line); self } /// Sets the 1-based end column. #[must_use] - pub fn with_end_column(mut self, col: usize) -> Self { + pub const fn with_end_column(mut self, col: usize) -> Self { self.end_column = Some(col); self } /// Sets the byte offset from the start of the artefact. #[must_use] - pub fn with_byte_offset(mut self, offset: usize) -> Self { + pub const fn with_byte_offset(mut self, offset: usize) -> Self { self.byte_offset = Some(offset); self } /// Sets the byte length. #[must_use] - pub fn with_byte_length(mut self, length: usize) -> Self { + pub const fn with_byte_length(mut self, length: usize) -> Self { self.byte_length = Some(length); self } @@ -192,7 +192,7 @@ impl LocationBuilder { /// Sets the region within the artefact. #[must_use] - pub fn with_region(mut self, region: Region) -> Self { + pub const fn with_region(mut self, region: Region) -> Self { self.region = Some(region); self } @@ -270,7 +270,7 @@ mod tests { .with_region(region) .build(); match loc.physical_location.region.as_ref() { - Some(region) => assert_eq!(region.start_line, 42), + Some(attached) => assert_eq!(attached.start_line, 42), None => panic!("expected region to be present"), } } diff --git a/crates/whitaker_sarif/src/builders/result_builder.rs b/crates/whitaker_sarif/src/builders/result_builder.rs index 574e413be..f791d4a15 100644 --- a/crates/whitaker_sarif/src/builders/result_builder.rs +++ b/crates/whitaker_sarif/src/builders/result_builder.rs @@ -50,7 +50,7 @@ impl ResultBuilder { /// Sets the severity level. #[must_use] - pub fn with_level(mut self, level: Level) -> Self { + pub const fn with_level(mut self, level: Level) -> Self { self.level = level; self } diff --git a/crates/whitaker_sarif/src/merge.rs b/crates/whitaker_sarif/src/merge.rs index e16bf4331..083b73666 100644 --- a/crates/whitaker_sarif/src/merge.rs +++ b/crates/whitaker_sarif/src/merge.rs @@ -43,7 +43,7 @@ struct RegionKey { } impl RegionKey { - fn from_region(region: &Region) -> Self { + const fn from_region(region: &Region) -> Self { Self { start_line: region.start_line, start_column: region.start_column, diff --git a/crates/whitaker_sarif/src/test_support.rs b/crates/whitaker_sarif/src/test_support.rs index c9a8eca90..d2114b8e8 100644 --- a/crates/whitaker_sarif/src/test_support.rs +++ b/crates/whitaker_sarif/src/test_support.rs @@ -13,7 +13,7 @@ use crate::builders::{LocationBuilder, RegionBuilder, ResultBuilder}; use crate::merge::WHITAKER_FRAGMENT_KEY; use crate::model::result::{Level, SarifResult}; -/// Assert that `value` survives a JSON serialize/deserialize round trip +/// Asserts that `value` survives a JSON serialize/deserialize round trip /// unchanged. /// /// Panics with the type name and underlying serde error on failure; intended @@ -36,7 +36,7 @@ where } } -/// Serialize `value` to JSON and pass the resulting string to `check`. +/// Serializes `value` to JSON and passes the resulting string to `check`. /// /// Panics with the underlying serde error if serialization fails; intended /// only for test code. @@ -47,11 +47,16 @@ pub fn assert_serialized_json(value: &impl Serialize, check: impl FnOnce(&str)) } } -/// Build a [`SarifResult`] with a fingerprint, location, and region. +/// Builds a [`SarifResult`] with a fingerprint, location, and region. /// /// Computes the end line as `line + 5`; integer overflow follows the active -/// Rust overflow-check configuration. Panics if a region or result builder -/// rejects its input; intended only for test code. +/// Rust overflow-check configuration. Intended only for test code. +/// +/// # Panics +/// +/// Panics if a region or result builder rejects its input, which indicates a +/// defect in the test fixture rather than a recoverable condition. +#[must_use] pub fn make_keyed_result(rule: &str, file: &str, line: usize, fp: &str) -> SarifResult { let region = match RegionBuilder::new(line).with_end_line(line + 5).build() { Ok(r) => r, diff --git a/crates/whitaker_sarif/src/whitaker_properties.rs b/crates/whitaker_sarif/src/whitaker_properties.rs index 78fd2dbe0..36ad5f38f 100644 --- a/crates/whitaker_sarif/src/whitaker_properties.rs +++ b/crates/whitaker_sarif/src/whitaker_properties.rs @@ -152,42 +152,42 @@ impl WhitakerPropertiesBuilder { /// Sets the k-shingle size. #[must_use] - pub fn with_k(mut self, k: usize) -> Self { + pub const fn with_k(mut self, k: usize) -> Self { self.k = k; self } /// Sets the winnowing window size. #[must_use] - pub fn with_window(mut self, window: usize) -> Self { + pub const fn with_window(mut self, window: usize) -> Self { self.window = window; self } /// Sets the Jaccard similarity score. #[must_use] - pub fn with_jaccard(mut self, jaccard: f64) -> Self { + pub const fn with_jaccard(mut self, jaccard: f64) -> Self { self.jaccard = jaccard; self } /// Sets the cosine similarity score. #[must_use] - pub fn with_cosine(mut self, cosine: f64) -> Self { + pub const fn with_cosine(mut self, cosine: f64) -> Self { self.cosine = cosine; self } /// Sets the clone group identifier. #[must_use] - pub fn with_group_id(mut self, group_id: usize) -> Self { + pub const fn with_group_id(mut self, group_id: usize) -> Self { self.group_id = group_id; self } /// Sets the clone class size. #[must_use] - pub fn with_class_size(mut self, class_size: usize) -> Self { + pub const fn with_class_size(mut self, class_size: usize) -> Self { self.class_size = class_size; self } @@ -235,12 +235,12 @@ mod tests { .with_class_size(4) .build(); match props { - Ok(props) => { - assert_eq!(props.profile, "T1"); - assert_eq!(props.k, 25); - assert_eq!(props.window, 16); - assert_eq!(props.group_id, 174); - assert_eq!(props.class_size, 4); + Ok(built) => { + assert_eq!(built.profile, "T1"); + assert_eq!(built.k, 25); + assert_eq!(built.window, 16); + assert_eq!(built.group_id, 174); + assert_eq!(built.class_size, 4); } Err(e) => panic!("unexpected error: {e}"), } @@ -250,10 +250,10 @@ mod tests { fn into_value_wraps_under_whitaker_key() { let props = WhitakerPropertiesBuilder::new("T2").build(); match props { - Ok(props) => { - let value = props.try_to_value(); + Ok(built) => { + let value = built.try_to_value(); match value { - Ok(value) => assert!(value.get("whitaker").is_some()), + Ok(wrapped) => assert!(wrapped.get("whitaker").is_some()), Err(e) => panic!("unexpected serialization error: {e}"), } } @@ -265,9 +265,9 @@ mod tests { fn try_from_value_extracts_properties() { let props = WhitakerPropertiesBuilder::new("T1").with_k(10).build(); match props { - Ok(props) => match props.try_to_value() { + Ok(built) => match built.try_to_value() { Ok(value) => match WhitakerProperties::try_from(&value) { - Ok(extracted) => assert_eq!(extracted, props), + Ok(extracted) => assert_eq!(extracted, built), Err(e) => panic!("unexpected extraction error: {e}"), }, Err(e) => panic!("unexpected serialization error: {e}"), diff --git a/crates/whitaker_sarif/tests/api_contract.rs b/crates/whitaker_sarif/tests/api_contract.rs index 3737b1d46..32b9bacc5 100644 --- a/crates/whitaker_sarif/tests/api_contract.rs +++ b/crates/whitaker_sarif/tests/api_contract.rs @@ -4,6 +4,7 @@ #[test] fn renamed_sarif_api_is_public_and_legacy_names_stay_removed() { let cases = trybuild::TestCases::new(); + cases.pass("tests/ui/pass_const_apis.rs"); 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/pass_const_apis.rs b/crates/whitaker_sarif/tests/ui/pass_const_apis.rs new file mode 100644 index 000000000..75437353b --- /dev/null +++ b/crates/whitaker_sarif/tests/ui/pass_const_apis.rs @@ -0,0 +1,14 @@ +//! Compile-pass checks for const SARIF builder APIs in an external crate. + +use whitaker_sarif::RegionBuilder; + +const REGION_BUILDER: RegionBuilder = RegionBuilder::new(10).with_end_line(15); + +/// Assert that the public const builder methods work in an external crate. +fn main() { + let region = REGION_BUILDER + .build() + .unwrap_or_else(|_| panic!("const region builder should be valid")); + assert_eq!(region.start_line, 10); + assert_eq!(region.end_line, Some(15)); +} diff --git a/docs/brain-trust-lints-design.md b/docs/brain-trust-lints-design.md index 3196871b8..f96ea122a 100644 --- a/docs/brain-trust-lints-design.md +++ b/docs/brain-trust-lints-design.md @@ -529,40 +529,26 @@ further decomposition analysis was omitted. `common` crate, running all harnesses by default; passing a harness name as an argument filters to that specific harness. `make kani` exposes the workflow as a top-level quality gate. -- **Bounded symbolic model uses fixed-size edge arrays**: each Kani harness - generates symbolic `SimilarityEdge` values from a fixed-size array of up to 3 - edges with an active-length field. The maximum node count is 3 (with - `unwind(7)`), giving at most C(3,2) = 3 possible unique undirected edges. - These bounds are kept deliberately small because Rust's standard `sort_by` - generates deeply nested loops that cause the C Bounded Model Checker (CBMC) - state-space explosion at higher bounds. This keeps the search space tractable - and avoids requiring `kani::Arbitrary` for `Vec`. -- **Harness inputs constrained to the production edge contract**: +- **Generated graph tests cover adjacency invariants**: the Proptest calls the + production `build_adjacency` function and checks row count, sorted rows, + exact directed edge tuples, symmetry, bounds, edge preservation, and the + absence of spurious entries. Exact tuple-set equality against valid input + endpoints plus the output-entry count catches missing and duplicate entries. +- **Kani does not model `build_adjacency` output invariants**: the row-count, + edge-preservation, and bounds harnesses each exhausted the fixed 8 GiB + process-tree cap. CBMC generated 16,317, 25,092, and 19,611 verification + conditions respectively; Kissat also exhausted the cap on the bounds proof. + No-spurious and symmetry properties had already moved to Proptest after the + same failure, and Kani sorting had previously timed out unwinding Rust's + stable-sort internals. Kani still verifies label-propagation properties. +- **Generated adjacency inputs follow the edge contract**: `build_similarity_edges` guarantees `left < right < node_count`, - `weight > 0`, and no duplicate unordered pairs. The Kani harnesses enforce - these same `kani::assume` preconditions, so the proof covers exactly the - inputs that production code can generate. -- **Six separate proof harnesses for failure localization**: the six - properties (correct length, edge preservation, in-bounds indices, symmetry, - no spurious edges, sorted neighbours) each have a dedicated harness. This - simplifies root-cause analysis when a single property fails. The sixth harness - (`verify_build_adjacency_no_spurious_edges`) closes an exclusion gap: - without it, a buggy implementation that injected extra symmetric, in-bounds - edges would pass the other five proofs. -- **Sort-coverage limitation is a conscious bound trade-off**: with - `MAX_NODES = 3`, no node can have more than 2 neighbours, so the sorted - neighbours harness only exercises 0-, 1-, and 2-element bucket sorts. Raising - `MAX_NODES` to 4 (C(4,2) = 6 edges, degree-3 lists) would expose sort defects - at 3+ elements, but Rust's standard `sort_by` generates deeply nested - control-flow paths that cause CBMC state-space explosion at that scale. The - current bound is retained as a conscious engineering trade-off. -- **`kani::assume(node_count > 0)` documents intent**: the guard in - `verify_build_adjacency_preserves_edges` and - `verify_build_adjacency_symmetry` is technically redundant — when - `node_count = 0`, the `right < node_count` constraint inside - `constrained_edges` is unsatisfiable, so `edges` is always empty and the - assertion loops never execute. The assumption is retained for readability and - is documented in the harness doc-comments. + `weight > 0`, and no duplicate unordered pairs. The generated test creates + unique, in-range endpoints and derives its oracle from those edges. + `build_adjacency_rows_unordered` is private to `community` and is called only + by the ordered production wrapper; sorting is checked through + `build_adjacency` itself. Propagation proofs use arbitrary neighbour order + because score aggregation and tie-breaking do not depend on that order. - **`build_adjacency` promoted to `pub(crate)`**: the function was private; promoting it to `pub(crate)` aligns with `build_similarity_edges` and allows unit tests and the `test_support::decomposition` adjacency report helper to @@ -602,10 +588,11 @@ further decomposition analysis was omitted. for Kani because it pulled `BTreeMap` comparison into the proof path. The shipped runtime now breaks equal-score ties by lexical method name, with the label index as a deterministic fallback when names match. -- **The Kani proof file is split by concern**: - `community_kani/adjacency.rs` keeps the completed 6.4.5 harnesses, while - `community_kani/propagate_labels.rs` holds the new 6.4.6 harness family and - `community_kani/shared.rs` holds the shared adjacency model for 6.4.5. +- **The Kani proof files are scoped by concern**: + `community_kani/propagate_labels.rs` holds the label-propagation harnesses; + `community_kani/shared.rs` provides their bounded iteration model. Adjacency + invariants are covered through the generated production-builder property test + because CBMC exceeded the configured memory cap on the Vec models. - **The Kani install sidecar had a warm-cache trap bug**: `scripts/install-kani.sh` originally exited with status `1` after printing the cached install directory because the `trap` cleanup function returned the @@ -626,6 +613,28 @@ further decomposition analysis was omitted. `propagate_labels_report` directly. The `#[expect(dead_code)]` annotation was therefore unjustified and the function was deleted. +### Review hardening + +- **Community detection keeps its implementation modules private to + decomposition advice**: `community.rs` coordinates detection and retains the + checked adjacency constructor used by the generated property tests; + `community/propagation.rs` owns pass limits and convergence; and + `community/selection.rs` owns neighbour scoring and deterministic + tie-breaking. The private `PropagationPlan` groups the active-node set and + iteration bound for one propagation call; it is constructed and consumed + within `community/propagation.rs`. These modules are internal to + decomposition advice. Other lints should use the decomposition API rather + than reuse graph internals. +- **Similarity-edge construction stays private to decomposition advice**: + `decomposition_advice::graph` owns vector-pair scoring and weighted edge + creation. This boundary keeps the graph representation and its propagation + rules local to the diagnostic that consumes them. +- **Malformed intermediate state fails closed**: adjacency construction, + propagation, and suggestion rendering return typed internal errors. The + infallible `suggest_decomposition` boundary logs a warning, increments a + fixed-cardinality counter, and returns no advice, so callers cannot publish a + partial decomposition as valid output. + ## SARIF output The lints can optionally emit SARIF 2.1.0 (Static Analysis Results Interchange diff --git a/docs/contents.md b/docs/contents.md index bdc2a3d59..94646fe9e 100644 --- a/docs/contents.md +++ b/docs/contents.md @@ -124,3 +124,8 @@ - [docs/execplans/](execplans/) stores execution plans for branch-scoped implementation work and larger follow-up tasks. + +## Verification evidence + +- [Clone-detector proof run for PR 365](verification/clone-detector/pr365-2026-10-19/README.md) + preserves the bounded Kani output and the related Verus run record for audit. diff --git a/docs/debugging/debugging-plan-2026-10-26-kani-adjacency-memory-cap.md b/docs/debugging/debugging-plan-2026-10-26-kani-adjacency-memory-cap.md new file mode 100644 index 000000000..c50a2e8b1 --- /dev/null +++ b/docs/debugging/debugging-plan-2026-10-26-kani-adjacency-memory-cap.md @@ -0,0 +1,538 @@ +# Debugging Record: Kani Adjacency Proof Memory Cap + +- Generated: 2026-10-26 06:03 UTC +- Issue ID: PR-365-KANI-OOM +- Severity: High +- Falsification sub-agent: `scrutineer` (fallback; `alchemist` unavailable) + +## Problem Statement + +The adjacency Kani harnesses for no-spurious edges, symmetry, bounds, edge +preservation, and row count exceeded their 8 GiB process-tree memory cap. The +generated Proptest now covers all these properties through the production +builder, alongside sorted-neighbour ordering. + +## Context Summary + +| Aspect | Details | +| ------------------- | ------------------------------------------------------------------------------------------------------------------- | +| First observed | Candidate tree `91cea89efb69317452ab6230ad231115976e52bc` | +| Reproduction rate | One 8 GiB OOM on this tree; prior trees timed out in stable sort | +| Affected components | `community_kani/adjacency.rs`, `community_kani/shared.rs`, `community.rs` | +| Recent changes | Adjacency Kani models were removed after repeated cap failures; a generated Proptest covers the production builder. | + +### Error Artefacts + +```text +Generated 76017 VCC(s), 30643 remaining after simplification +Kani resources: harness=decomposition-all exit_status=143 +duration_seconds=338 peak_memory_mib=8192 memory_cap_mib=8192 +scope_result=oom-kill +Kani exceeded the process-tree memory cap; proof is inconclusive +``` + +### Information Gaps + +- The precise contribution of symbolic edge cardinality, endpoint choices, + weights and `Vec` allocation to the SAT memory peak is unknown. +- The timed-out proof's solver trace does not identify which input dimension + dominates the remaining 30,643 verification conditions. + +______________________________________________________________________ + +## Hypotheses + +### H1: The symbolic three-edge domain drives the memory peak + +**Claim**: Symbolic edge count, endpoints and weights in the three-node model +drive the first harness beyond the memory cap; the standard sort implementation +is no longer the dominant cost. + +**Plausibility**: High — the current proof no longer reaches sort internals, +but still explores up to three symbolic edges with symbolic endpoints and +64-bit weights. + +**Prediction**: If this hypothesis holds, lowering only the model's maximum +edge count from three to two will let the focused no-spurious-edge harness +complete under the existing 8 GiB cap. Two edges still exercise nontrivial +adjacency buckets, symmetry and edge preservation. + +#### H1 Falsification Plan + +| Step | Action | Expected Negative Result | +| ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | +| 1 | Temporarily set `MAX_EDGES` to 2 in `community_kani/shared.rs`, then run `./scripts/run-kani.sh decomposition verify_build_adjacency_no_spurious_edges` once. | The hypothesis is falsified if the focused harness still reaches the 8 GiB cap or times out before producing a proof verdict. | +| 2 | Restore the staged candidate exactly after capturing the experiment result. | A changed tree after restoration invalidates the experiment and must be reported. | + +**Tooling**: The repository's `run-kani.sh` and its per-process limited runner; +shared Cargo cache and compiler admission remain enabled. + +**Confidence on falsification**: A memory-cap failure with two edges would +strongly falsify edge cardinality as the decisive cause. A pass would support +the hypothesis but would not establish the original three-edge proof. + +#### H1 Result + +**Verdict**: Falsified with high confidence. The focused two-edge run still +generated 76,017 VCCs, retained 30,643 after simplification, and hit the 8 GiB +cap after 320 seconds. See `/tmp/pr365-alchemist-h1-max-edges-2.out`. + +The edge-count bound alone does not reduce the symbolic state. The next +experiment removes symbolic endpoint generation and edge permutations while +retaining every simple three-node topology and arbitrary positive weights. + +### H2: Symbolic endpoint generation drives the memory peak + +**Claim**: Symbolic edge endpoints and permutation choices in +`constrained_edges` produce most of the verification conditions; replacing them +with a three-bit topology mask and fixed endpoint pairs will let the +no-spurious proof finish within the resource cap. + +**Plausibility**: High — H1 changed the edge-count constraint without changing +the 76,017 generated conditions, while symbolic endpoints and loop-generated +edge order remain. + +**Prediction**: If H2 holds, the focused harness will generate materially fewer +than 76,017 conditions and return a proof verdict below 8 GiB. The model must +cover all eight simple graphs over three nodes and positive symbolic weights +for every active edge. + +#### H2 Result + +**Verdict**: Falsified with high confidence. Fixing the node count and endpoint +pairs while retaining all graph masks and symbolic positive weights reduced the +conditions only to 72,890; CBMC still reached the 8 GiB cap after 327 seconds. +See `/tmp/pr365-alchemist-h2-fixed-endpoints.out`. + +The next experiment lowers only the harness unwind bound. Every input loop is +bounded by three nodes or three edges, so unwind four should cover the maximum +iterations plus the terminating condition while keeping unwinding assertions +enabled. + +______________________________________________________________________ + +### H3: Unwind seven over-expands bounded adjacency loops + +**Claim**: `#[kani::unwind(7)]` expands loops whose maximum body count is +three, producing unnecessary verification conditions in the no-spurious proof. + +**Plausibility**: Medium — fixed endpoints changed the generated condition +count only slightly, and the current unwind bound is more than twice the +maximum graph sizes used by the proof. + +**Prediction**: If H3 holds, changing only this harness to unwind four will +preserve a proof verdict and reduce solver work enough to remain under the +existing 8 GiB cap. All loop unwinding assertions must pass. + +#### H3 Falsification Plan + +| Step | Action | Expected Negative Result | +| ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | +| 1 | Temporarily change only `verify_build_adjacency_no_spurious_edges` from `#[kani::unwind(7)]` to `#[kani::unwind(4)]`, then run `./scripts/run-kani.sh decomposition verify_build_adjacency_no_spurious_edges` once. | H3 is falsified if a loop-unwinding assertion fails, or the proof still reaches the 8 GiB cap or times out. | +| 2 | Restore the staged candidate and verify its index tree and worktree are unchanged. | A changed tree after restoration invalidates the experiment. | + +**Tooling**: The repository's `run-kani.sh` and its per-process limited runner; +shared Cargo cache and compiler admission remain enabled. + +**Confidence on falsification**: A complete proof with unwind assertions +enabled decisively validates that four is a sufficient loop bound for this +harness. A resource failure would falsify unwind count as the main cause. + +Kani documents that insufficient loop bounds fail through unwinding assertions +when those checks remain enabled; see the +[unwind attribute reference][kani-unwind]. + +#### H3 Result + +**Verdict**: Falsified as a standalone fix. Unwind four reduced symbolic +execution to 30.8 seconds and 21,098 VCCs, but the SAT solver still reached the +8 GiB cap after 66 seconds. No proof or unwinding-assertion verdict was +produced. See `/tmp/pr365-alchemist-h3-unwind-4.out`. + +The next experiment removes the nested search for each output neighbour in the +input edge list. It derives absence of spurious entries from edge preservation +and an exact total-entry count. + +### H4: Nested membership searches dominate the solver formula + +**Claim**: The per-output-neighbour `is_edge_in_input` search and per-node +incident-edge recount produce an unnecessarily large formula. In a graph with +unique, non-self input edges, proving every input edge appears in both +directions and the output contains exactly twice the input edge count proves +that no extra adjacency entries exist. + +**Plausibility**: High — the no-spurious proof scans the input once for every +output entry after already computing each node's incident degree; other +harnesses show the edge-preservation property separately. + +**Prediction**: Replacing the nested membership and incident-count assertions +with edge-preservation checks plus one total adjacency-entry count will reduce +the solver formula enough for the full three-node, three-edge proof to finish +under the existing cap. + +#### H4 Falsification Plan + +| Step | Action | Expected Negative Result | +| ---- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | +| 1 | Temporarily rewrite only the no-spurious harness to require each input edge in both output directions, sum all output bucket lengths, and assert the sum equals `2 * edges.len()`. Keep `MAX_NODES`, `MAX_EDGES`, generated unique edges and unwind 7 unchanged. Run only `./scripts/run-kani.sh decomposition verify_build_adjacency_no_spurious_edges`. | H4 is falsified if the harness still reaches the 8 GiB cap or times out without a verdict. | +| 2 | Restore the staged candidate and verify its index tree and worktree are unchanged. | A changed tree after restoration invalidates the experiment. | + +**Tooling**: The repository's `run-kani.sh` and its per-process limited runner; +shared Cargo cache and compiler admission remain enabled. + +**Confidence on falsification**: A proof verdict under the cap supports the +formula-size hypothesis. A memory cap failure falsifies this reduction as a +sufficient fix, although it does not rule out other solver-cost reductions. + +#### H4 Result + +**Verdict**: Falsified as a sufficient fix. The assertion reformulation reduced +the formula to 25,487 VCCs, but the solver still reached the 8 GiB cap after +122 seconds. See `/tmp/pr365-alchemist-h4-preservation-cardinality.out`. + +The next experiment removes the dynamically allocated symbolic input edge +vector. It passes fixed-length slices for every possible three-node topology +while retaining the production adjacency builder and H4's exact-count proof. + +### H5: The dynamic input edge vector drives solver memory + +**Claim**: The symbolic `Vec` and active-count loop generated by +`constrained_edges` dominate the remaining solver state. Passing fixed length +slices for each of the eight three-node graph topologies will let the +no-spurious proof finish within the cap. + +**Plausibility**: Medium — H4 substantially reduced the formula without +changing the input model, but solver memory still exceeded the cap. + +**Prediction**: If H5 holds, a proof over fixed-size edge arrays and symbolic +positive weights will complete under 8 GiB. The model covers every simple +three-node topology; smaller valid node counts are embedded as graphs with +isolated trailing nodes, which do not alter adjacency for valid endpoints. + +#### H5 Result + +**Verdict**: Falsified. The fixed-array match for all eight topologies +generated 57,513 VCCs and still hit the 8 GiB cap after 167 seconds. See +`/tmp/pr365-alchemist-h5-fixed-edge-arrays.out`. + +The combined proof appears to leave too many topology branches in one solver +problem. The next experiment isolates the largest topology in one harness; if +that fits, the final proof can give each topology its own harness. + +### H6: Separate topology harnesses reduce the peak solver problem + +**Claim**: Combining all eight graph-topology branches in one harness exceeds +the solver's memory budget; proving each topology in a separate harness will +keep the peak below the cap. + +**Plausibility**: High — H5 expanded all eight cases into one formula and +generated 57,513 VCCs before reaching the same cap. + +**Prediction**: If H6 holds, one fixed triangle topology with three symbolic +positive weights will prove under 8 GiB. It exercises the maximum bounded edge +count and all three adjacency rows. + +#### H6 Result + +**Verdict**: Falsified as a sufficient fix. The fixed triangle generated 8,363 +VCCs but still reached the 8 GiB cap after 69 seconds. See +`/tmp/pr365-alchemist-h6-fixed-triangle.out`. + +The bound-four test reduced solver work in the symbolic model. The next test +combines that bound with the fixed triangle to determine whether both +reductions together fit under the cap. + +### H7: The fixed triangle proves with unwind four + +**Claim**: The fixed triangle model remains over budget at unwind seven because +the bound expands loops beyond their maximum sizes; unwind four is sufficient +and will let this isolated topology prove. + +**Plausibility**: Medium — H3 reduced symbolic execution but retained the full +symbolic input model; the fixed triangle has constant node and edge counts. + +**Prediction**: If H7 holds, the fixed triangle with symbolic positive weights +will prove under 8 GiB and satisfy every unwinding assertion at bound four. + +#### H7 Result + +**Verdict**: Falsified as a sufficient fix. The fixed triangle at unwind four +generated 6,923 VCCs but hit the 8 GiB cap during propositional reduction after +50 seconds. No proof or unwinding-assertion verdict was produced. See +`/tmp/kani-focused-09fc3177-61fb-4b01-8e82-4b2e49d936c7-harden-lint-support-crates-71752727.out`. + +The remaining experiment holds topology and unwind constant while replacing +symbolic weights with distinct constants. + +### H8: Symbolic weight bit-vectors drive propositional memory + +**Claim**: Symbolic `u64` weights create solver state that exceeds the memory +cap even for a fixed triangle; concrete edge weights will allow the same +production adjacency proof to complete. + +**Plausibility**: Medium — H6 and H7 used symbolic 64-bit weights and both +reached the cap even after reducing topology and unwind state. + +**Prediction**: If H8 holds, the fixed triangle with weights 1, 2 and 3 will +prove under 8 GiB at unwind seven. + +#### H8 Result + +**Verdict**: Falsified. Concrete weights produced the same 8,363 VCCs as the +symbolic-weight case and reached the 8 GiB cap during propositional reduction +after 50 seconds. See +`/tmp/kani-focused-09fc3177-61fb-4b01-8e82-4b2e49d936c7-harden-lint-support-crates-h8-24f70a7a.out`. + +Both fixed-triangle runs reported one reachable `caller_location` construct and +two foreign functions. The next experiment removes the `Result::expect` +panic-formatting path and checks whether that unsupported code inflates the +proof. + +______________________________________________________________________ + +### H9: The `expect` panic path inflates the Kani model + +**Claim**: The fixed model's `Result::expect` reaches panic formatting and +unsupported caller-location or foreign-function constructs, inflating the proof +enough to exceed the memory cap. + +**Plausibility**: Medium — Kani reports one reachable caller-location construct +and two foreign functions in both fixed-triangle runs; `expect` formats the +error path even though the valid endpoints make it unreachable. + +**Prediction**: If H9 holds, replacing `expect` with an `assert!` on +`Result::is_ok` followed by an explicit `let Ok(...) else { return; }` will +remove the panic-formatting constructs and let the proof finish under 8 GiB. + +#### H9 Falsification Plan + +| Step | Action | Expected Negative Result | +| ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1 | Temporarily use a fixed triangle with constant weights 1, 2 and 3. Replace `Result::expect` with `assert!(result.is_ok()); let Ok(adjacency) = result else { return; };`; retain production adjacency construction, H4's preservation/count assertions and unwind 7. Run only `./scripts/run-kani.sh decomposition verify_build_adjacency_no_spurious_edges`. | H9 is falsified if caller-location/foreign-function constructs remain reachable, or the harness reaches the 8 GiB cap or times out without a verdict. | +| 2 | Restore the staged candidate and verify its index tree and worktree are unchanged. | A changed tree after restoration invalidates the experiment. | + +**Tooling**: The repository's `run-kani.sh` and its per-process limited runner; +shared Cargo cache and compiler admission remain enabled. Kani's assertion +overrides skip string-formatting code in `assert!`, `assert_eq!` and +`assert_ne!`, making this a targeted comparison with `expect`'s panic path. See +[Kani assertion overrides][kani-overrides]. + +**Confidence on falsification**: A proof verdict with those constructs gone +supports the panic-path hypothesis. A resource failure falsifies this fix as +sufficient, though the unsupported path would still merit removal. + +#### H9 Result + +**Verdict**: Falsified. The proof still reported one `caller_location` and two +foreign functions, then reached the 8 GiB cap after 62 seconds with 17,223 VCCs +(4,618 after simplification). See +`/tmp/kani-focused-09fc3177-61fb-4b01-8e82-4b2e49d936c7-harden-lint-support-crates-h9-c0b546ab.out`. + +The next experiment holds the fixed triangle, weights, production builder and +explicit `Result` handling constant, replacing the search-based checks with +direct expected-row assertions. + +### H10: Search-based assertions inflate the fixed-topology proof + +**Claim**: Iterating over all emitted rows and searching input edges for every +adjacency entry creates the solver formula that exhausts memory. Comparing each +fixed triangle row directly with its expected entries will complete under the +cap. + +**Plausibility**: Medium — H9 retained the per-edge and per-row search loops +and generated 17,223 VCCs, more than twice the fixed-triangle H8 run. + +**Prediction**: If H10 holds, the direct row assertions will prove the same +fixed triangle under the existing 8 GiB cap and unwind seven. + +#### H10 Falsification Plan + +| Step | Action | Expected Negative Result | +| ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------- | +| 1 | Temporarily retain the fixed triangle with weights 1, 2 and 3, production adjacency construction, H9's explicit `Result` handling and unwind 7. Replace the per-edge, total-count and nested no-spurious loops with assertions that rows 0, 1 and 2 equal their exact expected neighbour-weight slices. Run only `./scripts/run-kani.sh decomposition verify_build_adjacency_no_spurious_edges`. | H10 is falsified if the proof reaches the 8 GiB cap or times out without a verdict. | +| 2 | Restore the staged candidate and verify its index tree and worktree are unchanged. | A changed tree after restoration invalidates the experiment. | + +**Tooling**: The repository's `run-kani.sh` and its per-process limited runner; +shared Cargo cache and compiler admission remain enabled. + +**Confidence on falsification**: A proof verdict supports assertion-loop +complexity as the dominant cost. Another resource failure falsifies this +assertion reduction as a sufficient fix. + +#### H10 Result + +**Verdict**: Falsified. Direct row assertions reduced the proof to 4,827 VCCs +(1,246 after simplification), but propositional reduction still reached the 8 +GiB cap after 62 seconds. The run reported one `caller_location` and two +foreign functions. See +`/tmp/kani-focused-09fc3177-61fb-4b01-8e82-4b2e49d936c7-harden-lint-support-crates-h10-4523b9ea.out`. + +The remaining common cost is construction of dynamically growing adjacency +rows. The next experiment pre-counts each row's degree and reserves that +capacity before inserting edges, while keeping the H10 harness unchanged. + +### H11: Repeated row-vector growth drives solver memory + +**Claim**: Reallocating each adjacency row as edges are pushed creates the +solver state that exhausts memory. Counting row degrees before construction and +allocating each row once will let the fixed-triangle proof complete. + +**Plausibility**: Medium — H10's direct-row proof removed the search loops but +still reached the cap with only 4,827 VCCs; the builder currently grows inner +`Vec`s through repeated `push` operations. + +**Prediction**: If H11 holds, preallocated rows with the same fixed triangle +and direct assertions will prove under 8 GiB at unwind seven. + +#### H11 Falsification Plan + +| Step | Action | Expected Negative Result | +| ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | +| 1 | Temporarily modify only the existing adjacency helper to validate endpoints, count incident edges per node, allocate each row with that capacity, then populate rows. Retain H10's fixed triangle, explicit `Result` handling, direct expected-row assertions and unwind 7. Run only `./scripts/run-kani.sh decomposition verify_build_adjacency_no_spurious_edges`. | H11 is falsified if the proof reaches the 8 GiB cap or times out without a verdict. | +| 2 | Restore the staged candidate and verify its index tree and worktree are unchanged. | A changed tree after restoration invalidates the experiment. | + +**Tooling**: The repository's `run-kani.sh` and its per-process limited runner; +shared Cargo cache and compiler admission remain enabled. + +**Confidence on falsification**: A proof verdict supports repeated allocation +as the dominant cost. Another resource failure falsifies this optimization as a +sufficient reduction. + +#### H11 Result + +**Verdict**: Falsified. Preallocating each row generated 5,560 VCCs (1,265 +after simplification), then hit the 8 GiB process-tree cap during propositional +reduction at 66 seconds. See +`/tmp/kani-focused-09fc3177-61fb-4b01-8e82-4b2e49d936c7-harden-lint-support-crates-h11-82f75b14.out`. + +The next experiment retains symbolic valid graphs but checks one maximum-degree +row per harness. If a row proof fits, three such harnesses can cover all rows; +the existing preservation harness already checks every input edge in both +directions. + +### H12: Per-row symbolic proofs reduce the solver formula + +**Claim**: The all-row no-spurious harness combines too many row checks into +one solver problem. Proving the maximum-degree middle row independently over +all valid symbolic graphs will fit under the memory cap. + +**Plausibility**: Medium — fixed-triangle direct comparisons still exhausted +memory, while the existing edge-preservation proof separately covers each input +edge. The node-one row has at most two entries in this three-node model. + +**Prediction**: If H12 holds, checking every emitted entry in symbolic row 1 +against the input edge set will prove under 8 GiB at unwind seven. + +#### H12 Falsification Plan + +| Step | Action | Expected Negative Result | +| ---- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | +| 1 | Temporarily replace the combined no-spurious harness with one harness that keeps `symbolic_adjacency`, assumes `node_count > 1`, and checks only row 1 against all input edges. Keep the production builder and unwind 7. Run only `./scripts/run-kani.sh decomposition verify_build_adjacency_no_spurious_edges_node_one`. | H12 is falsified if the proof reaches the 8 GiB cap or times out without a verdict. | +| 2 | Restore the staged candidate and verify its index tree and worktree are unchanged. | A changed tree after restoration invalidates the experiment. | + +**Tooling**: The repository's `run-kani.sh` and its per-process limited runner; +shared Cargo cache and compiler admission remain enabled. + +**Confidence on falsification**: A proof verdict supports splitting by row as a +tractable strategy. Another resource failure falsifies this split as sufficient +for the maximum-degree row. + +#### H12 Result + +**Verdict**: Falsified. The one-row symbolic harness still generated 25,077 +VCCs (10,468 after simplification), then reached the 8 GiB cap during +propositional reduction after 91 seconds. See +`/tmp/kani-focused-09fc3177-61fb-4b01-8e82-4b2e49d936c7-harden-lint-support-crates-h12-87340d96.out`. + +#### Symmetry Gate Result + +The full `make kani` run on staged tree +`d3bc1c351b9cf4b451900a94c82e2badb7b95b2e` also found that +`verify_build_adjacency_symmetry` reached the same resource limit. CBMC +generated 48,955 VCCs (20,710 after simplification), then hit the 8 GiB cap +after 289 seconds. See +`/tmp/kani-09fc3177-61fb-4b01-8e82-4b2e49d936c7-harden-lint-support-crates-d3bc1c35.out`. + +### H13: A SAT solver change reduces the in-bounds proof peak + +**Claim**: Kani's supported Kissat solver will complete +`verify_build_adjacency_indices_in_bounds` within the existing 8 GiB cap. + +**Prediction**: If this holds, the focused harness will produce a proof verdict +without exceeding the process-tree limit. + +**Result**: Falsified. On staged tree +`55e5eb56384fc921f47c3142f089cd8f74f08c9d`, Kissat generated the same 19,611 +VCCs (8,496 after simplification) and reached the 8 GiB cap after 98 seconds. +The default CaDiCaL run generated the same formula and reached the cap after 84 +seconds. The exact Kissat transcript is +`/tmp/kani-kissat-09fc3177-harden-lint-support-crates-55e5eb56.out`. + +### Edge-preservation gate result + +On staged tree `dc33ece956169654879391a524b58b6b9b63b2ed`, +`verify_build_adjacency_preserves_edges` generated 25,092 VCCs (11,366 after +simplification), then hit the 8 GiB process-tree cap after 99 seconds. The +captured transcript is +`/tmp/kani-09fc3177-61fb-4b01-8e82-4b2e49d936c7-harden-lint-support-crates-dc33ece9.out`. +The generated Proptest checks edge preservation by comparing every directed +output tuple and total output count with an independent oracle derived from the +input edges. + +### Row-count gate result + +On staged tree `b0a211004253f3f2429e44a24affa17df3af1120`, +`verify_build_adjacency_length` generated 16,317 VCCs (6,929 after +simplification), then hit the 8 GiB process-tree cap after 74 seconds. The +captured transcript is +`/tmp/kani-09fc3177-61fb-4b01-8e82-4b2e49d936c7-harden-lint-support-crates-b0a21100.out`. +The generated Proptest already asserts `adjacency.len() == node_count` for +each valid generated graph. + +### Resolution: Check adjacency invariants with Proptest + +The no-spurious, symmetry, bounds, edge-preservation, and row-count Kani +harnesses are not tractable within the repository's fixed 8 GiB process-tree +limit. Replacing no-spurious assertions, fixing its graph, preallocating rows, +isolating one symbolic row, changing the bounds solver, and reducing the +edge-preservation and row-count harnesses did not produce proof verdicts. Move +all adjacency invariants to generated property checks through the production +builder. Keep Kani enabled for the independent propagation and clone-detector +proofs. + +#### Resolution Validation + +1. Remove the remaining adjacency Kani model and its adjacency-only shared + helpers. +2. Extend the generated valid-graph property to compare exact directed edge + tuples and total output count, and to assert row-count, bounds, and + sorted-row properties. +3. Run `make kani` and the required sequential commit gates on the exact + candidate. + +[kani-unwind]: https://model-checking.github.io/kani/reference/attributes.html +[kani-overrides]: https://model-checking.github.io/kani/overrides.html + +## Recommended Execution Order + +1. **Resolution** — Replace resource-intractable adjacency checks with + generated production-builder properties, then run all gates. + +______________________________________________________________________ + +## Termination Criteria + +- **Root cause identified**: CBMC's generated Vec model exceeds the fixed + process-tree memory cap for every adjacency property; retain their generated + production-builder checks and keep Kani for the other proof groups. +- **Escalation trigger**: The limited runner is unavailable, a second file + must change to run the experiment, or the result is neither a proof verdict + nor a resource-cap/timeout failure. + +## Notes for Executing Agent + +The `alchemist` agent was unavailable because its selected model reported +capacity errors. Preserve the fixed runner resource limits and shared Cargo +cache. Do not treat a timeout or resource failure as a proof pass; the full +Kani gate must pass after intractable properties move to generated runtime +checks. Record the exact candidate tree and each gate's result. diff --git a/docs/debugging/debugging-plan-20260716T131509Z.md b/docs/debugging/debugging-plan-20260716T131509Z.md index db80425a4..3d64005e5 100644 --- a/docs/debugging/debugging-plan-20260716T131509Z.md +++ b/docs/debugging/debugging-plan-20260716T131509Z.md @@ -62,3 +62,13 @@ The targeted case then passed with `RUSTC_WRAPPER=sccache`. The repository formatting, lint, typecheck, test, Markdown, and Mermaid gates passed. Finally, `RUSTC_WRAPPER=sccache make coverage` passed all 1,475 executed tests and wrote the LCOV report. + +## Follow-up: preserve configured compiler wrappers + +The wrapper-clearing workaround above was superseded on 2026-10-12. The +workspace can also receive the wrapper through Cargo configuration, which +process-environment removal does not override; clearing an environment wrapper +also bypasses compiler admission for nested Cargo builds. Whitaker now patches +`dylint_testing` 6.0.1 to find the `rustc` executable after Cargo's wrapper +argument, and UI helpers preserve compiler-wrapper variables. This keeps +compiler admission active while example tests recover the flags they need. diff --git a/docs/developers-guide.md b/docs/developers-guide.md index f77f8df76..7bf1a2612 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -1209,12 +1209,15 @@ bill for days. A contract asserts it, which it did not before. This rule was documented and unenforced, and two matrix jobs reached a pull request on paid runners with no timeout at all before a reviewer caught it. The contract requires a declared -`timeout-minutes` on every lane in the Ubicloud list and bounds it to two -hours, which is an order of magnitude above the slowest leg this repository has -ever run and so catches a typo as readily as an omission. Values are sized from -observed runs rather than guessed: 30 minutes for `build-lints`, about eight -times its slowest leg, and 45 for `build-dependency-binaries`, which installs -every dependency crate from source and is the heavier of the two. +`timeout-minutes` on every lane in the Ubicloud list. It bounds ordinary build +lanes to two hours, which is an order of magnitude above the slowest leg this +repository has ever run and so catches a typo as readily as an omission. +Suite-running lanes use the separate four-tier timeout contract below, which +requires 180 minutes to leave room for the whole-run budget, setup and +reporting. Values for ordinary build lanes are sized from observed runs rather +than guessed: 30 minutes for `build-lints`, about eight times its slowest leg, +and 45 for `build-dependency-binaries`, which installs every dependency crate +from source and is the heavier of the two. Test and build concurrency is bounded by one named constant. `LINUX_RUNNER_VCPUS` is declared once per workflow and a single step derives @@ -1226,7 +1229,7 @@ worker count rather than `-n auto`. #### Coverage lane time limits -`coverage-check` and `coverage-upload` run under the 120-minute ceiling that +`coverage-check` and `coverage-upload` run under the 180-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 @@ -1248,11 +1251,11 @@ 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 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. +sits well inside the 180-minute ceiling, which leaves `coverage-upload` about +141 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. #### Placement inside a matrix @@ -1884,11 +1887,12 @@ tests and Kani: - The BDD harness in `crates/whitaker_clones_core/tests/min_hash_lsh_behaviour.rs` includes a duplicate-retained-hash candidate-generation scenario. -- The Kani harnesses in `crates/whitaker_clones_core/src/index/kani.rs` call - real `MinHasher::sketch` for the empty-input, deterministic-output, and - duplicate-hash properties. They use a private `cfg(kani)` seed fixture and - fixed-width signature builder so the proof focuses on sketch semantics rather - than seed-stream array construction. +- Kani checks the empty-input error through `MinHasher::sketch`, the lane + minimum for the fixed mixed hashes of `0` and `u64::MAX` through + `minimum_mixed_hash`, and sorting/deduplication of four fixed fingerprints + through `unique_hashes`. These bounded proofs do not establish non-empty + full-signature determinism or general duplicate-hash insensitivity; ordinary + tests retain those broader runtime obligations. The direct `LshIndex` invariant coverage is also split between ordinary tests and Kani: @@ -1899,11 +1903,18 @@ and Kani: - The BDD harness in `crates/whitaker_clones_core/tests/min_hash_lsh_behaviour.rs` exercises LSH candidate generation as part of the token-pass behaviour surface. -- The Kani harnesses in `crates/whitaker_clones_core/src/index/kani.rs` verify - bounded `LshIndex` states for the same invariants. Kani builds use a private - fixed-size insertion log and compact band keys so the proof checks the LSH - state transition and `CandidatePair::new` policy without modelling `BTreeMap` - and `BTreeSet` allocator internals. +- The Kani harnesses in `crates/whitaker_clones_core/src/index/kani.rs` use a + proof-specific candidate-generation model. In Kani builds, `insert` records + up to four fragments and their compact band keys; + `candidate_pair_summary_for_kani` compares each recorded fragment pair + directly, emits it when any recorded band matches, and applies + `CandidatePair::new`. This model does not execute production + `candidate_pairs` or `add_bucket_pairs`, nor the production B-tree bucket + enumeration and cross-bucket deduplication. Runtime tests and the BDD + scenario cover those production paths. The canonical-order harness uses + `CandidatePair::new` for both its modelled and expected pairs, so it checks + reversal invariance but is not an independent oracle for that constructor's + ordering rule. ### Clone-detector AST structure @@ -1937,6 +1948,14 @@ parent-child and parent-child-grandchild production counts, and Changing the parser pin, normalization rules, hash algorithm, or schema string must produce a reviewable snapshot change. +Parser-backed lowering enforces a 10,000-node budget over every emitted +`NormalizedNode`, including non-trivia token leaves, and a depth limit of 256. +Those bounds keep parser-produced per-kind counts within `u32` and depths within +`u16`, so the production saturating operations cannot saturate for lowered +parser trees. Public callers can construct larger `NormalizedTree` values +directly; the Verus sidecar's mathematical-`nat` accumulator proof does not +establish saturation behaviour for those arbitrary trees. + #### AST operational metrics The parser-agnostic `ast::metrics` module owns metric names and the bounded @@ -2033,7 +2052,11 @@ tracked source. `make nixie` validates the repository's Mermaid diagrams. Continuous Integration installs Nixie 1.1.0 and its Merman 0.7.0 dependency on the Linux -documentation leg through the SHA-pinned shared `install-nixie` action. +documentation leg through the SHA-pinned shared `install-nixie` action. When +`XDG_RUNTIME_DIR` exists and is writable, the target uses it for Nixie's +temporary working files; otherwise it falls back to the system temporary +directory. This keeps those files off the shared `/tmp` volume while nested +Cargo fixture builds may also be using it. ### Verus scope and trust boundary @@ -2063,27 +2086,47 @@ For the current clone-detector constructors, the split is: - Kani executes the real constructor with one concrete acceptance harness, one bounded symbolic harness over `[0, 128]²`, and one overflow harness that forces the `checked_mul(None)` branch. -- Kani executes real `MinHasher::sketch` calls for empty input, deterministic - output, and duplicate retained hashes. The non-empty harnesses keep their - symbolic domains intentionally bounded: determinism proves equality at an - arbitrary signature lane for a fixed retained hash, while duplicate-hash - insensitivity proves a symbolic bounded retained hash at the first signature - lane. -- Kani verifies bounded `LshIndex` states for no self-pairs, canonical pair - ordering, repeated-band deduplication, and insertion-order independence. In - `#[cfg(kani)]` builds, `LshIndex` records inserted fragments in a fixed - four-slot proof log with compact two-band keys; normal builds keep the - production `BTreeMap`/`BTreeSet` implementation and public API. +- Kani checks the empty-input error through `MinHasher::sketch`, the lane + minimum for fixed mixed hashes through `minimum_mixed_hash`, and sorting and + deduplication of fixed fingerprints through `unique_hashes`. These bounded + proofs do not establish non-empty full-signature determinism or general + duplicate-hash insensitivity; ordinary tests retain those broader runtime + obligations. +- Kani checks no self-pairs, canonical pair ordering, bounded shared-band + scenarios, and insertion-order independence through a proof-specific model. In + `#[cfg(kani)]` builds, `insert` records up to four fragments with at most + two compact band keys. `candidate_pair_summary_for_kani` checks each recorded + pair for any shared band, then uses `CandidatePair::new` and a fixed-array + pair summary. It does not run production `candidate_pairs` or + `add_bucket_pairs`, so the production B-tree bucket emissions and final + cross-band deduplication remain covered by runtime tests and BDD scenarios. - Kani verifies AST span-cover selection and bounded AST feature invariants - over synthetic `NormalizedTree` values. It calls the production - `select_smallest_covering` helper for covering-node minimality and root - fallback, and uses compact bounded tree fixtures for feature invariants so - the proof does not compile or model `ra_ap_syntax`. + over synthetic `NormalizedTree` values. The missing-cover harness checks that + empty and concrete non-covering candidate lists return `None`; it does not + execute parser-root fallback. The parser adapter's pruned tree walk and the + parser-independent `select_smallest_covering` helper share the same covering + and minimal-width decision predicate, which Kani checks without compiling or + modelling `ra_ap_syntax`. Compact bounded tree fixtures keep feature proofs + independent of parser internals. The full-range `u16` `KindId` round trip has + its own harness. The depth-only harness uses fixed kinds and checks the + shared preorder depth visitor with a four-slot array tally, so it proves the + five-node depth counts without entering production `BTreeMap` internals. Its + unwind bound of six covers five node visits and the loop's final termination + check; the child loop has at most two children plus exhaustion. Unwinding + assertions remain enabled. A separate scalar harness checks `KindId` equality + and ordering against raw `u16` comparisons for each pair among the root, two + child labels, and query label. Four values from two symbolic bits represent + every equality partition and relative ordering those four roles can have. It + has no loops to unwind and does not model map operations. Unit tests and + proptest cover the production traversal and `BTreeMap` count accumulator. A + generated-source property also varies identifier and integer-literal payloads + while checking normalized AST-hash equality. - Verus proves the AST feature-count accumulator algebra for supplied - `(kind, depth)` contributions. The proof establishes adjacent two-item order - independence for exact counts; ordinary tests and proptest remain responsible - for proving that production traversal supplies the intended contribution - multiset. + `(kind, depth)` contributions. `lemma_fold_count_is_permutation_invariant` + establishes that equal contribution multisets yield equal exact counts for + every queried bucket. It does not prove that production traversal emits those + multisets; Rust unit tests and `proptest` exercise that traversal + correspondence. - Ordinary unit tests and `rstest-bdd` scenarios pin the concrete lexical `FragmentId` ordering contract that the `CandidatePair` proof's bridge still trusts rather than proving from `String` internals, and they pin the AST @@ -2110,7 +2153,30 @@ The proof targets are thin wrappers over repository scripts: harness for `LshConfig::new`, the bounded `MinHasher::sketch` harnesses, and the bounded `LshIndex` candidate-pair invariant harnesses. Clone-detector Kani invocations use `--no-default-features` so parser-independent proofs do - not compile `ra_ap_syntax`. + not compile `ra_ap_syntax`. Every invocation runs through + `scripts/run-kani-limited.sh`, which applies an 8 GiB process-tree memory + cap, disables swap for that cgroup, and enforces a ten-minute timeout. It + records peak memory and duration. A timeout, resource failure, or missing + resource measurement is a failed or inconclusive proof, never a pass. The + `WHITAKER_KANI_MEMORY_MAX_BYTES` and `WHITAKER_KANI_TIMEOUT_SECONDS` + overrides can lower these limits but cannot raise them. + +The limited runner requires Linux, a running user systemd session, +`systemd-run`, `systemctl`, GNU `timeout`, and cgroup v2 with its memory +controller enabled. It stops before starting a proof when any prerequisite is +unavailable. Kani runs through this wrapper are unsupported on macOS; use a +Linux host for Kani proofs. On systemd 258 and later, the wrapper disables +systemd's command environment expansion so the child Bash receives its script +unchanged +([`systemd-run` manual](https://man7.org/linux/man-pages/man1/systemd-run.1.html)). + +The workflow contract tests keep preflight-failure inputs in the private +`_PreflightCase` rows in +[`kani_runner_contract_test.py`](../tests/workflow_contracts/kani_runner_contract_test.py). +The type is local to that module: each row pairs runner arguments, stubbed +prerequisites, environment overrides, and the expected diagnostic for one +parameterized test. Keep this case shape local unless another contract module +needs to exercise the same runner boundary. The installer scripts are idempotent. The first proof run may take longer while toolchains and verifier binaries are downloaded; later runs reuse the cached @@ -2132,12 +2198,18 @@ Run a Verus group directly through the wrapper: ./scripts/run-verus.sh all --time ``` +Run one clone-detector Kani harness while iterating: + +```sh +./scripts/run-kani.sh clone-detector-harness verify_kind_counts_stop_at_max_depth +``` + Run all Kani groups, a specific decomposition harness, or the clone-detector group directly: ```sh ./scripts/run-kani.sh -./scripts/run-kani.sh verify_build_adjacency_preserves_edges +./scripts/run-kani.sh decomposition verify_propagate_labels_preserves_label_indices ./scripts/run-kani.sh clone-detector ``` @@ -2199,11 +2271,11 @@ toolchain bump must update the channel written here as part of the runbook explicitly requires that trade-off. 2. Exact-pin the parser dependency in the workspace `Cargo.toml` under `[workspace.dependencies]`. The accepted outcome is an entry such as - `ra_ap_syntax = "=0.0.334"` with the `parser` feature wired through - `whitaker_clones_core`. Loose pins, invalid specifiers, or a missing - workspace dependency must fail the re-pin attempt. If more than three - transitive crates need manual `cargo update --precise` pins, stop and record - the mismatch. + `ra_ap_syntax = "=0.0.334"` with one equality comparator and a complete + major/minor/patch version. The build guard parses the version and rejects + partial versions, extra comparators, loose pins, and a missing workspace + dependency. If more than three transitive crates need manual + `cargo update --precise` pins, stop and record the mismatch. 3. Keep parser imports confined to `src/ast/lowering.rs`. If a parser API change tempts domain code to import `ra_ap_syntax`, update the lowered `NormalizedTree` boundary instead. @@ -2304,12 +2376,13 @@ to `pub(crate)` visibility: `SimilarityEdge` and `build_adjacency`. - **`build_adjacency` function** (`common/src/decomposition_advice/community.rs`): Promoted from `fn` to - `pub(crate) fn` so that colocated Kani harnesses and the test-support - adjacency report can call it directly without widening the crate's public API - surface. + `pub(crate) fn` so that unit tests and the test-support adjacency report can + call it directly without widening the crate's public API surface. Adjacency + invariants are checked through generated property tests; CBMC's Vec models + exceeded the configured process-tree memory cap. - **`SimilarityEdge::new(left, right, weight)`** (`common/src/decomposition_advice/community.rs`): A `pub(crate)` constructor - added to allow Kani harnesses and test-support modules to create edge values + added to allow unit tests and test-support modules to create edge values without exposing a public constructor on the production type. It is used internally by `adjacency_report` to convert validated `EdgeInput` values into `SimilarityEdge` instances before delegating to `build_adjacency`. External @@ -3384,17 +3457,19 @@ must coordinate through `installer/src/test_support.rs`. guard, create the temporary target directory, then run the env-mutating test body. +The guard is re-entrant, so a guarded callback can call another helper that +acquires it. It serializes the broader test protocol; it does not replace the +separate lock used by `temp_env`. + For a single variable or locale override, prefer `whitaker_common::test_support::{with_env_var, with_env_var_removed, with_locale}`. -Each helper holds the shared guard for its complete callback, including -restoration, so it also coordinates with `EnvVarGuard`. Do not acquire a -separate mutex around those helpers. `env_test_guard()` is the sole shared -synchronization mechanism and is reentrant: a callback may safely call a UI -runner or another helper that acquires it for nested setup. The common -test-support module owns this lock; callers must reuse it rather than adding -crate-local environment locks. `EnvTestGuard` is the corresponding façade type -when a test fixture must retain the guard beyond local scope. +Each scoped helper delegates to `temp_env`'s re-entrant global lock, scopes +restoration to its callback, and permits nested overrides from the same thread. +That lock is independent of `env_test_guard()`: participants using only one +lock do not serialize with participants using only the other. Use +`EnvTestGuard` when a fixture must retain the explicit guard beyond local +scope, and keep all participants in that protocol on the same guard. For example: @@ -3410,31 +3485,30 @@ the regression suite: `temp-env` provides scoped environment overrides, `tempfile` provides isolated target directories, and `rstest` powers the fixture-based test setup used by the staged-suite coverage. -#### Shared UI harness environment guards +#### Shared UI harness environment overrides Workspace-level UI harness tests that mutate process-wide environment variables -must use `whitaker_common::test_support::EnvVarGuard`. Use `EnvVarGuard::set` -to install a temporary value and `EnvVarGuard::remove` to make a variable -absent for the duration of a test. The guard retains `env_test_guard()` from -construction through restoration in `Drop`, preventing another thread from -interleaving a mutation or restoring stale state. Because this guard is -re-entrant, code on the same thread may still use nested helpers that acquire -the shared protocol. - -`whitaker::testing::ui::run_with_runner` applies a specialized guard before -invoking the Dylint UI runner. It preserves `RUSTC_WRAPPER` so nested Cargo -builds retain compiler admission; the workspace's patched `dylint_testing` -recognizes wrapped `rustc` commands when recovering example flags. The vendored -crate is a workspace member so Clippy checks its parser, but its -`deny_warnings` feature changes UI fixture diagnostics and must not be enabled -for the ordinary all-features test run. `make test` therefore excludes it from -Nextest and runs its command-decoding unit tests separately with default -features disabled. The decoder parses Cargo's quoted command line and selects -the compiler arguments after any wrapper chain; do not clear the configured -wrappers. On Windows the guard also sets `VCPKG_ROOT` to `C:\vcpkg` when that -directory exists and the variable is otherwise absent. Restoration uses the -same shared environment mutex, but the runner callback itself executes without -holding that mutex to avoid nested-lock deadlocks. +must use the scoped helpers in `whitaker_common::test_support`: `with_env_var` +installs a temporary value, `with_env_var_removed` makes a variable absent, and +`with_locale` overrides or clears `DYLINT_LOCALE` for the duration of a +callback. The helpers delegate to `temp_env`, whose re-entrant global lock +serializes scoped mutations while permitting nesting from the same thread. Each +prior value is restored when the callback returns or panics. The workspace +forbids `unsafe` code, so tests must not call `std::env::set_var` or +`std::env::remove_var` directly. + +`whitaker::testing::ui::run_with_runner` preserves `RUSTC_WRAPPER` so nested +Cargo builds retain compiler admission; the workspace's patched +`dylint_testing` recognizes wrapped `rustc` commands when recovering example +flags. The vendored crate is a workspace member so Clippy checks its parser, +but its `deny_warnings` feature changes UI fixture diagnostics and must not be +enabled for the ordinary all-features test run. `make test` therefore excludes +it from Nextest and runs its command-decoding unit tests separately with +default features disabled. The decoder parses Cargo's quoted command line and +selects the compiler arguments after any wrapper chain; do not clear the +configured wrappers. On Windows the UI runner sets `VCPKG_ROOT` to `C:\vcpkg` +when that directory exists and the variable is otherwise absent, then restores +the previous value after the runner completes. Any UI test that holds `env_test_guard()` while invoking Dylint must use `run_test_runner_with_env_guard()`. It catches a runner panic before releasing @@ -3869,6 +3943,16 @@ It can be promoted to standard by: `suite/Cargo.toml` 3. Updating documentation to reflect the change +### Suite entry point + +Dylint resolves a library's lints through an exported `register_lints` symbol, +which requires `#[unsafe(no_mangle)]`. The workspace forbids in-crate `unsafe` +code, so the suite declares its entry point through +`whitaker_common::declare_dylint_register_entry!`, which expands the unsafe +attribute from an external macro, just as `dylint_linting::impl_late_lint!` +does for single-lint crates. Reserve this macro for Dylint driver crates; do +not use it for other symbol exports. + ## Creating a New Lint ### Generating from the template @@ -4197,12 +4281,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 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 | +| 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 UI and no-default-feature builds; 30 m for isolated package checks; 60 m for parser-pin fixtures; 90 m for trybuild fixtures; 30 m for toolchain-installing behaviour tests | +| nextest `global-timeout` | the whole test run | `.config/nextest.toml`, both profiles | 140 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 | 180 m for every lane that runs the suite | *Table: the timers that can end a run, innermost first.* @@ -4264,17 +4348,33 @@ 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 +producing a result. A later run exhausted the 45-minute allowance while +`pass_normalize_settings.rs` was still compiling; the trybuild dev profile had +taken 30 minutes 35 seconds to build, with compiler requests queued up to 169 +ahead. On the main-synced candidate, the Bumpy Road API contract exhausted the +60-minute allowance while compiling the same pass fixture. Its generated +trybuild package finished its dev-profile build after 47 minutes 49 seconds, +with compiler requests queued as far as 160 ahead; the fixture compile was +still active at the timeout. All discovered compile contracts now share a +90-minute allowance and disable retries, leaving one measured cold build room +to finish beneath the 140-minute whole-run budget. + +The six parser-pin build-script integration cases run in the 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. +large queue. The 60-minute allowance leaves room for that observed queue, and +the group serializes these fixtures with the no-default-feature build and the +isolated package checks. + +The private `parser_pin_fixture_directory` helper allocates these fixtures +beneath `target/tests/build_script_integration`, keeping their nested Cargo +outputs inside the workspace while giving each fixture its own directory. +`build_fixture` owns manifest and source generation. Keep the allocation helper +local to this integration test; reuse it only for parser-pin fixtures with the +same workspace target requirement, rather than treating it as a general test +utility. ### The clocks do not start together @@ -4301,10 +4401,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 85 m + 65 s + 15 m + 15 m, or 116 minutes 5 seconds, which +actionable. That is 140 m + 65 s + 15 m + 15 m, or 171 minutes 5 seconds, which `required_ceiling` computes for both profiles. -The ceiling is not that requirement. It is pinned separately at 120 minutes, a +The ceiling is not that requirement. It is pinned separately at 180 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. @@ -4315,11 +4415,15 @@ written down, so it has been corrected rather than kept as a special case. ### What the values are sized against -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 latest main-synced candidate run reached the 120 m global timeout after +1,861 of 1,871 tests; 10 tests remained unrun. Its active UI case had first +spent six minutes waiting for a named cross-worktree harness lock. Nextest +retried it after the lock owner exited, but the global timeout stopped the +retry 337 seconds into its nested build. The 140 m budget adds 20 minutes for +that retry and the remaining suite tail. The earlier uncached full run reached +1,843 of 1,860 tests at its 85 m global timeout, with a nested no-unwrap +fixture build active and 17 tests unrun; the previous increase to 120 m was not +enough for the main-synced suite under the observed lock contention. The 15 m allowance for work outside nextest's window is measured, from the worst of several runs rather than one: @@ -4348,7 +4452,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 an 85 m nextest budget, which is exactly the inversion the canonical +underneath a 140 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. @@ -4520,9 +4624,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 85 m whole-run +`slow-timeout` on both profiles compared as a whole table, the 140 m whole-run budget, both overrides' whole `slow-timeout` including the grace period, and -the 120-minute ceiling on every suite lane. The ordering assertions hold for a +the 180-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/6-4-5-use-kani-to-verify-build-adjacency-preserves-similarity-edges.md b/docs/execplans/6-4-5-use-kani-to-verify-build-adjacency-preserves-similarity-edges.md index c41ccb086..80ef6fa94 100644 --- a/docs/execplans/6-4-5-use-kani-to-verify-build-adjacency-preserves-similarity-edges.md +++ b/docs/execplans/6-4-5-use-kani-to-verify-build-adjacency-preserves-similarity-edges.md @@ -1,4 +1,4 @@ -# Verify `build_adjacency` with Kani (roadmap 6.4.5) +# Verify `build_adjacency` properties (roadmap 6.4.5) This Execution Plan (ExecPlan) is a living document. The sections `Constraints`, `Tolerances`, `Risks`, `Progress`, `Surprises & Discoveries`, `Decision Log`, @@ -10,23 +10,21 @@ This document must be maintained in accordance with `AGENTS.md`. ## Purpose / big picture -Roadmap item 6.4.5 adds a machine-checked Kani verification pass for -`build_adjacency` in `common/src/decomposition_advice/community.rs`. After this -change, Whitaker will not rely only on local reasoning and example-driven tests -to justify the adjacency builder that feeds label propagation. It will also -ship a repeatable bounded model check showing that, for valid similarity edges -produced by `build_similarity_edges`, adjacency construction preserves every -edge, never emits out-of-bounds neighbour indices, and always produces -symmetric neighbour lists. +Roadmap item 6.4.5 verifies `build_adjacency` in +`common/src/decomposition_advice/community.rs` with generated tests. After this +change, Whitaker will not rely only on local reasoning and examples to justify +the adjacency builder that feeds label propagation. A generated property test +checks the production builder across valid graphs. Bounded Kani models were +also attempted, but every adjacency `Vec` proof exhausted the fixed 8 GiB +process-tree cap; the exact evidence and test boundary are recorded below. Observable success after implementation: -1. `make kani` installs or reuses a pinned Kani toolchain and completes full - Kani/C Bounded Model Checker (CBMC) verification of the new adjacency - harnesses with zero failures. -2. Unit tests exercise the shipped Rust adjacency construction directly, - covering happy paths, absence-of-edge paths, and edge cases such as isolated - nodes and sorted neighbour order. +1. `make kani` installs or reuses a pinned Kani toolchain and completes the + remaining Kani proof groups. It does not model adjacency `Vec` invariants. +2. Unit and property tests exercise the shipped Rust adjacency construction + directly, covering edge preservation, absence of spurious edges, symmetry, + row bounds, isolated nodes, and sorted neighbour order. 3. Behaviour tests using `rstest-bdd` `0.5.0` cover the same observable adjacency outcomes through a narrow `common::test_support::decomposition` seam, including an unhappy-path validation case in the test helper. @@ -150,13 +148,33 @@ Observable success after implementation: limitation on `verify_build_adjacency_sorted_neighbours`, document `kani::assume(node_count > 0)` intent in harness doc-comments, and update design and developer documentation. +- [x] 2026-10-26: Reconcile the no-spurious Kani proof with review-gate + resource limits. Repeated focused model reductions still exceeded the 8 GiB + process-tree cap. Move no-spurious and exact-multiplicity coverage into the + generated production-builder Proptest; keep the other three Kani harnesses + and require the full Kani gate to pass. +- [x] 2026-10-26: The full Kani gate then reached the symmetry harness, which + generated 48,955 VCCs and hit the same 8 GiB cap. Move symmetry coverage into + the generated property as well; keep the then-remaining Kani row-count, + edge-preservation, and in-bounds harnesses pending the focused bounds run. +- [x] 2026-10-26: The remaining in-bounds Kani harness generated 19,611 VCCs + and exhausted the 8 GiB cap with both CaDiCaL and Kissat. Move this property + into the generated production-builder Proptest, whose exact directed-edge + comparison also verifies that every emitted neighbour index is below + `node_count`. +- [x] 2026-10-26: The edge-preservation Kani harness generated 25,092 VCCs + and exhausted the same cap after 99 seconds. Move edge-preservation coverage + into the generated production-builder Proptest. +- [x] 2026-10-26: The remaining row-count Kani harness generated 16,317 VCCs + and exhausted the cap after 74 seconds. Move row-count coverage to the same + Proptest and remove the adjacency-only Kani model. Kani remains enabled for + the propagation and clone-detector proof groups. ## Surprises & Discoveries -- `build_adjacency` is a private helper inside - `common/src/decomposition_advice/community.rs`. It is currently only reached - from `detect_communities`, which makes colocated verification harnesses the - least invasive way to call it. +- `build_adjacency` is crate-internal inside + `common/src/decomposition_advice/community.rs`; unit and property tests reach + it through the narrow test-support seam without widening the public API. - `build_similarity_edges` already enforces the key input invariants that make 6.4.5 tractable: each edge is unique, `left < right`, the endpoints are within `vectors.len()`, and `weight > 0`. @@ -198,6 +216,22 @@ Observable success after implementation: to `MAX_NODES=3` / `MAX_EDGES=3` / `unwind(7)` makes the model tractable at the cost of a smaller verification envelope. Full CBMC runs should target CI runners with dedicated resources. +- The no-spurious property remained intractable even after replacing symbolic + graphs with a fixed triangle, checking one symbolic row, and reducing the + assertions to direct expected-row comparisons. Each focused run reached the 8 + GiB process-tree cap during propositional reduction. Generated property tests + now cover this relation and exact edge multiplicity through the production + builder. The symmetry harness also reached the 8 GiB cap after generating + 48,955 VCCs (20,710 after simplification) and 289 seconds. The property test + covers symmetry; the full Kani gate no longer contains adjacency proofs. The + in-bounds Kani harness reached the cap with both CaDiCaL and Kissat after + generating 19,611 VCCs (8,496 after simplification). The edge-preservation + harness generated 25,092 VCCs (11,366 after simplification) and reached the + cap after 99 seconds. The row-count harness generated 16,317 VCCs (6,929 + after simplification) and hit the cap after 74 seconds. The generated + property compares every emitted tuple with a set derived from valid endpoints + and checks total entry count, so out-of-range, missing, or duplicate entries + fail independently of the Kani model. ## Decision Log @@ -229,6 +263,16 @@ Observable success after implementation: runtime helper. Rationale: the user asked for unhappy-path coverage, and a validated test helper can provide that without changing production semantics. Date/Author: 2026-04-02 / Codex. +- Decision: move no-spurious-edge and symmetry properties from Kani to + generated Proptest checks after bounded proofs reached the fixed 8 GiB memory + cap. Rationale: the property test exercises the shipped builder over + generated valid graphs and compares exact directed edge tuples. The + in-bounds, edge-preservation, and row-count Kani harnesses all exhausted the + cap; the bounds run also failed with Kissat. Their properties are covered by + tuple-set and count checks against valid endpoints. No adjacency Kani model + remains; the Kani suite still checks label propagation and clone detection. + Failed resource-limited proofs are not reported as successful verification. + Date/Author: 2026-10-26 / Codex. ## Context and orientation @@ -466,12 +510,16 @@ The repository now ships: - A pinned `make kani` workflow (`scripts/install-kani.sh`, `scripts/run-kani.sh`) that downloads Kani 0.67.0, configures the required nightly toolchain, and runs bounded model checking against the `common` crate. -- Six bounded Kani harnesses (`MAX_NODES=3`, `MAX_EDGES=3`, - `unwind(7)`) verifying that `build_adjacency` preserves valid similarity - edges, keeps neighbour indices in bounds, produces symmetric adjacency lists, - emits no spurious edges, and sorts neighbours. `make kani` runs the shipped - full Kani/CBMC verification flow for these harnesses and succeeds with zero - failing proofs at the documented bounds. +- No adjacency Kani harness remains. The generated Proptest checks row count, + edge preservation, bounds, symmetry, exact edge tuples, absence of spurious + entries, and sorted order through the production builder. The row-count, + edge-preservation, and bounds harnesses exceeded the fixed 8 GiB cap; Kissat + also exhausted the cap on the bounds harness. The Kani sorted-neighbour + harness was removed after two fixed, three-node models still exhausted the + ten-minute proof budget unwinding Rust's stable-sort internals before + reaching the assertion; the property test retains the production call and + checks every generated row. Kani remains enabled for label-propagation and + clone-detector proofs. - Five focused unit tests for `build_adjacency` covering empty edges, single-edge both-directions, multiple edges sorted, sparse graph isolated nodes, and multi-edge symmetry. diff --git a/docs/execplans/6-4-6-kani-verification-propagate-labels-preserves-indices.md b/docs/execplans/6-4-6-kani-verification-propagate-labels-preserves-indices.md index 421afe9e6..8ecf3725c 100644 --- a/docs/execplans/6-4-6-kani-verification-propagate-labels-preserves-indices.md +++ b/docs/execplans/6-4-6-kani-verification-propagate-labels-preserves-indices.md @@ -268,17 +268,16 @@ Repository areas likely to change during implementation: 1. `common/src/decomposition_advice/community.rs` 2. `common/src/decomposition_advice/community_kani/mod.rs` -3. `common/src/decomposition_advice/community_kani/adjacency.rs` -4. `common/src/decomposition_advice/community_kani/shared.rs` -5. `common/src/decomposition_advice/community_kani/propagate_labels.rs` -6. `common/src/decomposition_advice/tests.rs` and a new or expanded +3. `common/src/decomposition_advice/community_kani/shared.rs` +4. `common/src/decomposition_advice/community_kani/propagate_labels.rs` +5. `common/src/decomposition_advice/tests.rs` and a new or expanded label-propagation-focused unit-test file -7. `common/src/test_support/decomposition_label_propagation.rs` -8. `common/tests/decomposition_label_propagation_behaviour.rs` -9. `common/tests/features/decomposition_label_propagation.feature` -10. `docs/brain-trust-lints-design.md` -11. `docs/roadmap.md` -12. this ExecPlan +6. `common/src/test_support/decomposition_label_propagation.rs` +7. `common/tests/decomposition_label_propagation_behaviour.rs` +8. `common/tests/features/decomposition_label_propagation.feature` +9. `docs/brain-trust-lints-design.md` +10. `docs/roadmap.md` +11. this ExecPlan Relevant local documentation to consult while implementing: diff --git a/docs/execplans/7-2-7-kani-verification-of-bounded-min-hasher-sketch-invariants.md b/docs/execplans/7-2-7-kani-verification-of-bounded-min-hasher-sketch-invariants.md index b84c50152..ac6bd884e 100644 --- a/docs/execplans/7-2-7-kani-verification-of-bounded-min-hasher-sketch-invariants.md +++ b/docs/execplans/7-2-7-kani-verification-of-bounded-min-hasher-sketch-invariants.md @@ -8,11 +8,10 @@ Status: COMPLETE ## Purpose / big picture -Roadmap item 7.2.7 adds bounded Kani verification for `MinHasher::sketch`, the -clone-detector token-pass function that turns retained token fingerprints into -a fixed-width MinHash signature. After this work is approved and implemented, -maintainers can run `make kani-clone-detector` and observe machine-checked -coverage for three implementation invariants: +Roadmap item 7.2.7 originally added bounded Kani verification around +`MinHasher::sketch`, the clone-detector token-pass function that turns retained +token fingerprints into a fixed-width MinHash signature. Its initial planned +coverage was three implementation invariants: - Sketching the same bounded retained fingerprints is deterministic. - Repeating an already present fingerprint hash does not change the sketch. @@ -24,14 +23,26 @@ collision probability, or unbounded set behaviour. It proves bounded implementation behaviour over the real Rust code, in line with `docs/adr-003-formal-proof-strategy-for-clone-detector-pipeline.md`. +**Current proof scope (2026-10-19):** Later boundedness review replaced the +non-empty full-signature harnesses. The current Kani group calls +`MinHasher::sketch` only for empty-input rejection; it separately verifies the +production lane-minimum and sorted/deduplicated-hash helpers on fixed inputs. +It does not prove full-signature determinism, duplicate-equivalent signatures, +or identical-set order independence. Runtime tests cover those behaviours. The +historical implementation and gate results below describe the earlier proof +shape, not the current claims; see the current scope in +`docs/developers-guide.md` and the proof log in +`docs/execplans/7-3-1-map-candidate-spans-and-extract-ast-feature-vectors.md`. + The implementation began after explicit user approval on 2026-05-18. ## Constraints - Work must implement roadmap item 7.2.7 only. Do not start roadmap item 7.2.8 for `LshIndex`. -- Kani harnesses must call the real `MinHasher::sketch` implementation in - `crates/whitaker_clones_core/src/index/minhash.rs`, not a clean-room model. +- The empty-input Kani harness calls the real `MinHasher::sketch` + implementation. Other MinHash harnesses must call the exact production helper + they claim to verify and must not be described as proving complete signatures. - Kani proof code must stay behind `#[cfg(kani)]` in `crates/whitaker_clones_core/src/index/kani.rs` or a child module reached from there. @@ -90,11 +101,10 @@ The implementation began after explicit user approval on 2026-05-18. the shipped `cfg(kani)` seam, and keep the fixed-width signature path explicit under Kani. -- Risk: An over-broad proof proves a helper model rather than production code. - Severity: high. Likelihood: low. Mitigation: every harness must construct the - hasher through the shipped `MinHasher::from_seed_for_kani` seam, call the - Vec-backed `MinHasher::sketch(...)` entrypoint, and inspect the returned - `MinHashSignature` or `IndexError`. +- Risk: An over-broad proof claim exceeds the production code reached by a + harness. Severity: high. Likelihood: medium. Mitigation: document each + harness's exact production call path; the current non-empty proofs cover a + lane-minimum helper and hash normalization, not a complete signature. - Risk: Empty-input verification is trivial if it only checks `sketch(&[])` with no symbolic path. Severity: low. Likelihood: medium. Mitigation: include @@ -199,9 +209,17 @@ The implementation began after explicit user approval on 2026-05-18. coverage split; `docs/users-guide.md` remains unchanged because the work does not change user-visible behaviour. - [x] 2026-05-19: Marked roadmap item 7.2.7 done. +- [x] 2026-10-19: Corrected this plan's current-scope summary after assessment + found that later Kani harnesses no longer prove non-empty full signatures. + Historical gate results remain recorded as evidence for the earlier harness + implementation only. ## Surprises & Discoveries +The notes below record the original implementation. Later harness changes +supersede the MinHash proof claims about complete signatures; the current scope +is stated at the start of this plan. + - `MinHasher::sketch` already rejects empty input, deduplicates hashes through `BTreeSet`, and has ordinary unit coverage for determinism, duplicate hash insensitivity, empty input, and set-order independence in @@ -278,6 +296,10 @@ The implementation began after explicit user approval on 2026-05-18. ## Decision Log +These decisions describe the original implementation. Decisions concerning +full-signature Kani proofs were superseded by the later bounded harness split; +the current scope is stated at the start of this plan. + - Decision: keep item 7.2.7 as a pre-implementation plan until the user approves it. Rationale: the user explicitly stated that the plan must be approved before implementation. Date/Author: 2026-05-18 / Codex. @@ -368,14 +390,13 @@ The implementation began after explicit user approval on 2026-05-18. ## Outcomes & Retrospective -Implementation is complete. `make kani-clone-detector` verifies the -clone-detector Kani harness group, including the new `MinHasher::sketch` -empty-input, deterministic-output, and duplicate-hash insensitivity harnesses. -The proof shape uses a private `cfg(kani)` seed fixture and fixed-width -signature builder while still calling real `MinHasher::sketch`. Ordinary unit -tests and `rstest-bdd` scenarios continue to cover the broader runtime -regression surface, including full-signature duplicate equality and -representative full-width `u64` hashes. +The roadmap item was completed on 2026-05-19 with a broader Kani harness set. +That implementation and its validation results below are historical: the +current Kani group has since narrowed the non-empty MinHash claims to the +production lane-minimum and hash-normalization helpers. Only the empty-input +harness currently calls `MinHasher::sketch`; no current Kani proof establishes +complete signature determinism or duplicate-equivalent signatures. Ordinary +unit and behaviour tests cover those runtime behaviours. Validation completed on 2026-05-19: @@ -397,10 +418,9 @@ The relevant implementation lives in the `whitaker_clones_core` crate. `MinHasher::sketch` accepts a slice of `Fingerprint` values, applies Vec-backed set semantics by sorting and deduplicating `Fingerprint.hash` values, mixes each unique hash with each seed, and returns a `MinHashSignature`. Kani -harnesses construct proof fixtures through the shipped -`MinHasher::from_seed_for_kani` seam and still call the real -`MinHasher::sketch(...)` entrypoint, validating the returned `MinHashSignature` -or `IndexError`. +harnesses call `MinHasher::sketch(...)` for empty-input rejection and call the +production lane-minimum and hash-normalization helpers directly for their +bounded non-empty obligations. Runtime tests cover full-signature behaviour. The surrounding index module is exposed through `crates/whitaker_clones_core/src/index/mod.rs` and re-exported from 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 bf8e223f8..4173c4e3d 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 @@ -4,7 +4,7 @@ This ExecPlan (execution plan) is a living document. The sections `Constraints`, `Tolerances`, `Risks`, `Progress`, `Surprises & Discoveries`, `Decision Log`, and `Outcomes & Retrospective` must be kept up to date as work proceeds. -Status: COMPLETED +Status: IN PROGRESS ## Purpose / big picture @@ -82,12 +82,13 @@ escalation, not a workaround. (7.6.x) cannot accidentally compare raw discriminants across parser pins. - **Bounded per-candidate cost.** Lowering touches one candidate subtree; the upstream `min_nodes`/node-count bound from the Pass A config governs subtree - size. 7.3.1 avoids indiscriminate full-file lowering — a candidate that spans - the source still legitimately selects `SOURCE_FILE` and intentionally lowers - the complete file through `lower_span`. The smallest-covering selection - descends from the root via a bounded pending-stack worklist over covering - children, enforcing the `MAX_AST_NODES` node budget and `MAX_AST_DEPTH` depth - budget. + size, and `MAX_AST_NODES` caps every emitted normalized node, including + non-trivia token leaves. 7.3.1 avoids indiscriminate full-file lowering — a + candidate that spans the source still legitimately selects `SOURCE_FILE` and + intentionally lowers the complete file through `lower_span`. The + smallest-covering selection descends from the root via a bounded + pending-stack worklist over covering children, enforcing the `MAX_AST_NODES` + node budget and `MAX_AST_DEPTH` depth budget. - **Toolchain bump (Stage 0).** This item bumps `rust-toolchain.toml` from `nightly-2025-09-18` (rustc 1.92.0-nightly) to **`nightly-2026-05-28`** (rustc ≈ 1.9x-nightly, comfortably ≥ 1.95) as a prerequisite Stage 0, landing @@ -253,15 +254,96 @@ Thresholds that trigger escalation rather than autonomous continuation. (prototyping milestone; go/no-go). - [x] Stage C — Domain IR and pure feature math (`tree`, `features`, `hash`), red-green-refactor. -- [x] Stage D — `ra_ap_syntax` adapter (`lowering.rs`) and span→node mapping. +- [x] Stage D — `ra_ap_syntax` adapter (`lowering.rs`) and span→node mapping; + follow-up enforces the node budget for token leaves and shares the + parser-independent covering decision with production traversal. - [x] Stage E — Behavioural (`rstest-bdd`), snapshot (`insta`), and property (`proptest`) coverage. -- [x] Stage F — Verus lemma and Kani harnesses; proof-script wiring. -- [x] Stage G — Documentation, final gates, CodeRabbit review, roadmap tick. +- [x] Stage F — Verus lemma and Kani harnesses; proof-script wiring. The + complete bounded Kani group passes after splitting the expensive MinHash + signature checks into lane and normalization obligations. +- [ ] Stage G — Documentation, final gates, formal review, and reconciliation. Each stage records a timestamp here when complete and splits into done/remaining if interrupted. +- [x] 2026-10-19: Revalidated the bounded Kani group after separating the + full-domain `KindId` round trip from depth-only traversal, using fixed + kinds in the depth proof, and documenting the four-value symbolic + comparison alphabet. All 15 harnesses passed under per-process-tree + limits; the unwind-5 negative control failed as expected. See Artefacts. +- [x] 2026-10-19: Revalidated the clone-detector Verus group (9, 10, and 8 + verified obligations; zero errors) and the local workspace gates. Stage + D, Stage E, and Stage G remain open pending fresh current-head review and + hosted checks. +- [x] 2026-10-19: Candidate-bound plan assessment on head + `d3e62ec1966b1eae87f46a3ece325955064e401a` accepted the token-leaf budget, + exact parser pin, selector correspondence, generated properties, and + Verus arithmetic-boundary corrections. It found one stale MinHash/Verus + summary in this guide and requested an explicit disposition for the + unavailable historical red-failure transcripts. The follow-up on head + `eabfe1ba0306ac9eee1cc7cd29f1fe38bc84ca20` closed the proof-scope finding + and accepted the exception paragraph as a transparent evidence record; + see [CodeRabbit's response](https://github.com/leynos/whitaker/pull/365#issuecomment-6257839052). +- [x] 2026-10-19: The repository owner's instruction to merge PR #365 once it + reaches equilibrium is recorded as acceptance of the disclosed + historical-evidence exception for this delivery. The missing transcripts + remain unavailable and the corresponding acceptance item remains + excepted, not satisfied. The instruction is in the active delivery task; + CodeRabbit's request for this owner disposition is linked above. +- [x] 2026-10-19: Replaced stale candidate attribution with current-head + evidence. [CI run 39184288492](https://github.com/leynos/whitaker/actions/runs/39184288492) + passed `linux-full`, `coverage-check`, and `windows-compat` on head + `eabfe1ba0306ac9eee1cc7cd29f1fe38bc84ca20`; [CodeScene result 8008849](https://codescene.io/projects/71836/delta/results/8008849) + passed on that same head. Formal review and Stage G remain open. +- [x] 2026-10-19: Applied the three scope corrections from the proof + assessment. Commit `6f57fc37` renamed the missing-cover harness to + `verify_missing_cover_returns_none` and reworded its assertions so the + proof is described as concrete `None` checks rather than parser-root + fallback; it states the `MINHASH_SIZE` and `usize::MAX / 2` assumption + domains for the index constructor proofs; and it records that the four + LSH harnesses run a separate bounded model, not production + `candidate_pairs` or `add_bucket_pairs`. That commit also added the + durable proof logs under + [the proof-run record](../verification/clone-detector/pr365-2026-10-19/README.md), + addressing the assessment's request for copies outside `/tmp`. +- [x] 2026-10-19: Merged the updated support-lint candidate as `15a29bce`. On + that head, [CI run 39209042444](https://github.com/leynos/whitaker/actions/runs/39209042444) + passed `linux-full`, `coverage-check`, and `windows-compat`, and + [CodeScene result 8010894](https://codescene.io/projects/71836/delta/results/8010894) + succeeded. Local sequential gates on the same tree passed `make + check-fmt`, `make lint`, and `make typecheck`. The first `make test` + invocation was stopped by its wrapper timeout at 1,836 of 1,871 tests, + with only `SIGTERM` aborts and no assertion failures. The longer rerun + passed all 1,871 workspace tests, with 5 tests and 11 binaries skipped; + the additional `dylint_testing` suite passed 11/11, as recorded under + Artefacts. +- [x] 2026-10-25: On the main-synced merge candidate tree + `df484e84ccb546d19d4fcf9ed9d3e5b901f0e4b9`, `make check-fmt`, `make lint`, + and `make typecheck` passed. `make test` reached 1,834 of 1,871 tests, then + timed out the Bumpy Road API trybuild contract at its 60-minute allowance + while the pass fixture was compiling. The generated dev-profile build had + taken 47 minutes 49 seconds under compiler queueing. Raising that + allowance to 90 minutes made the API contract pass in 1,029.903 seconds, + but the full run then reached its 120-minute global timeout at 1,861 of + 1,871 tests. A retry of a UI case had spent six minutes waiting for a + cross-worktree harness lock and was rebuilding after the lock cleared + when the global timeout ended the run. The global allowance was raised to + 140 minutes. The subsequent full local gate run passed on tree + `e68c2eb57e2ed7ff0f32e73113e359c4f44720ab`, recorded as commit + `bf9aa109ca8157d70fc7aa030e9e8dc04fd5909d`: 1,871/1,871 workspace tests + passed (5 tests and 11 binaries skipped), `dylint_testing` passed 11/11, + all 15 Kani harnesses passed, and 27 Verus proofs verified with zero + errors. The exact-head Kani transcript and source patch are in the + [2026-10-25 proof record](../verification/clone-detector/pr365-2026-10-25/README.md). + [CI run 40187412572](https://github.com/leynos/whitaker/actions/runs/40187412572/attempts/1) + passed coverage, Linux, and Windows checks on head `bf9aa109`; the hosted + synthetic merge used main commit `9214b274`. [CodeScene result 8117568](https://codescene.io/projects/71836/delta/results/8117568) + passed. The earlier timeouts above remain historical evidence. +- [ ] Stage G — Complete the formal CodeRabbit review and reconcile any findings + after the candidate-bound assessments and readiness gates clear. Roadmap + item 7.3.1 is already marked complete. + Stage 0 completed on 2026-06-16. Green gates: - `make check-fmt` @@ -328,8 +410,10 @@ Stage E completed on 2026-06-16. Green gates: - `make markdownlint` - `coderabbit review --agent --type uncommitted --fast` (`0` findings) -Stage F implementation and deterministic validation completed on 2026-06-16. -Green gates: +Historical Stage F implementation and deterministic validation completed on +2026-06-16. Its Kani harness inventory was revised on 2026-10-18; the current +candidate's verification state is recorded under Artefacts and is not covered +by this historical green-gate list. - `cargo test -p whitaker_clones_core ast` (`28` AST unit tests plus the parser-boundary guard and AST behavioural scenarios) @@ -475,6 +559,20 @@ invariance over equal contribution multisets. Green follow-up gates: `--no-default-features`. Normal library builds keep the parser feature on by default; Kani verifies only parser-independent AST domain code and bounded synthetic `NormalizedTree` values. +- Observation (2026-10-18): the five-node depth harness failed its unwinding + assertion at the child loop (`features.rs:216`) with `#[kani::unwind(5)]`; + unwinding assertions stayed enabled. With the correct bound of six, all 569 + checks passed (9 unreachable), verification took 2.34 seconds, wall time was + 6 seconds, and peak memory was 151 MiB under a 4 GiB cap. The first run's + peak was 159 MiB over 20 seconds. Logs: + `/tmp/kani-depth-five-whitaker-pr365.out` and + `/tmp/kani-depth-six-whitaker-pr365.out`. +- Observation (2026-10-18): the full clone-detector Kani run was also bounded + per proof (8 GiB, 600 seconds). Its first four harnesses passed, then + `verify_min_hasher_sketch_is_deterministic` reached the 8 GiB cap and was + killed after 357 seconds. The limited runner reported `oom-kill` and marked + the proof inconclusive; no pass was recorded. Log: + `/tmp/kani-clone-detector-whitaker-pr365-limited.out`. - Observation: an intermediate Stage C CodeRabbit follow-up raised two documentation concerns about the build-script module purpose and the test-only visibility of `FNV_PRIME`. The next completed review raised five @@ -744,8 +842,30 @@ the default unless Stage findings contradict it, then escalate): ## Outcomes & retrospective -Completed at the end of the milestone sequence. Compare delivered behaviour -against the Purpose section; note any deltas, trade-offs, or follow-up work. +The initial implementation delivered the parser-agnostic tree, AST feature +extraction, bounded Kani/Verus obligations, and the planned runtime and +behavioural tests. The 2026-10-18 plan assessment at candidate +`c0ef8c2074127a3083b6907b16474f819e77ddea` found that Stage D's node budget +counted syntax nodes but not emitted token leaves, Stage E lacked generated +source-payload normalization coverage, and the parser adapter duplicated the +pure selector's covering/minimal-width decision. It also found that the parser +pin guard accepted partial exact requirements, the MinHash Kani documentation +overstated current harness scope, and the Verus-to-Rust arithmetic boundary +needed an explicit statement. A candidate-bound assessment on +`d3e62ec1966b1eae87f46a3ece325955064e401a` accepted the first five repairs and +identified the stale MinHash/Verus summary in this guide; the current bounded +Kani and Verus results remain evidence only for their named propositions. + +**Historical evidence exception (2026-10-19):** the original per-function +red-failure transcripts required by the Red-Green-Refactor acceptance item are +not present in the tracked plan, the retained candidate worktree logs, or the +current run's CI artefacts. This historical-evidence item is explicitly +excepted for this revision and is not reported as satisfied. The available +passing tests, mutation negative controls, and candidate-bound verification +validate present behaviour, but do not recreate the missing original red runs. +This check does not establish whether an expired earlier CI run or another +unretained workspace still has those transcripts. The retained negative +controls and their limits are listed in Artefacts. ## Context and orientation @@ -1128,7 +1248,9 @@ dependency to `[workspace.dependencies]` with the documented-reason comment, and `{ workspace = true }` to `whitaker_clones_core`. Run `cargo build -p whitaker_clones_core` under `-D warnings`, pinning at most three offending transitive crates with `--precise` (escalate if more are -needed). Commit `Cargo.toml` and `Cargo.lock` together; record the resolved +needed). The build guard must parse one full major/minor/patch version after +one equality comparator; partial versions and additional comparators are not +exact pins. Commit `Cargo.toml` and `Cargo.lock` together; record the resolved transitive set (including exact `rowan`/`ra_ap_parser` versions) in Surprises & Discoveries. @@ -1165,16 +1287,18 @@ the 400-line file budget; re-run. ### Stage D — Adapter and span→node mapping Implement `lowering.rs`: parse with `Edition::CURRENT`; validate the `ByteSpan` -against the root range; build `TextRange`; call the domain -`cover::select_smallest_covering` over the `descendants()` ranges (the pure -index math lives in `cover.rs` so Kani can verify it without parsing); lower -the chosen subtree via a private recursive `lower_node` that maps -`kind() as u16 → KindId`, classifies leaf tokens via a single private +against the root range; build `TextRange`; retain the pruned covering-path +worklist instead of collecting every descendant; share its covering and +minimum-width decision predicate with `cover::select_smallest_covering` (the +pure predicate lives in `cover.rs` so Kani can verify the same decision without +parsing); lower the chosen subtree via a private recursive `lower_node` that +maps `kind() as u16 → KindId`, classifies leaf tokens via a single private `leaf_class` function (the only place encoding which `SyntaxKind`s are ``/ ``), recurses over children in document order skipping trivia per OQ4, and applies the OQ1 `ERROR`-node policy (return `AstError::UnparsableSpan` when the covering node is/contains an `ERROR` subtree; `tracing::warn` when a lowered -span had parse errors). +span had parse errors). Charge the `MAX_AST_NODES` budget for every emitted +normalized node, including each non-trivia token leaf. Drive with red `rstest` mapping tests: exact node, smallest covering inner expression, two-sibling common ancestor, whole-file root, and the unhappy paths @@ -1202,12 +1326,14 @@ forces the bumper to confront cache invalidation. Render `KindId` as its but the `KindId → "BIN_EXPR"` renderer is a `#[cfg(test)]` helper in the adapter (`lowering.rs`), **not** a `Display` impl on the domain `KindId` (which would re-couple the domain to parser vocabulary). Add `proptest` invariants over an -`Arbitrary` `NormalizedTree` strategy: determinism; `kind_counts`/ -`production_multiset` accumulation order-independence (sibling-visit -permutation); leaf-normalization hash equality. State explicitly that the -order-independence property excludes `canonical_hash`, which is deliberately -order-*sensitive*; proptest uses the opaque `KindId(u16)`, never the rendered -name. Keep a checked-in `proptest-regressions/` file. Validation: +`Arbitrary` `NormalizedTree` strategy: repeatable feature outputs; +`kind_counts`/`production_multiset` accumulation order-independence +(sibling-visit permutation); leaf-normalization hash equality. Add a generated +source-payload property that varies identifier and integer-literal spellings +while requiring the normalized AST hash to remain equal. State explicitly that +the order-independence property excludes `canonical_hash`, which is +deliberately order-*sensitive*; proptest uses the opaque `KindId(u16)`, never +the rendered name. Keep a checked-in `proptest-regressions/` file. Validation: `cargo test -p whitaker_clones_core` green; `cargo insta` review accepted. ### Stage F — Verus lemma and Kani harnesses @@ -1215,35 +1341,51 @@ name. Keep a checked-in `proptest-regressions/` file. Validation: Verus (`verus/clone_detector_ast_features.rs`): prove that count accumulation is a permutation-invariant fold over the multiset of per-node `(kind, depth)` contributions — folding **exact `u32` counts**, not scaled rationals (the -count-substrate hybrid makes this a clean, decidable statement with no -overflow/rounding obligation). State, in one falsifiable sentence in Artefacts, -**exactly** what the trust bridge assumes versus proves. Be honest about the -division of labour (review finding 🟡-3): if the property that actually breaks -operationally — sibling visit order in the feature walk — is carried by -**proptest**, and Verus proves only the algebraic fold over a given multiset, -then say so plainly and do **not** claim Verus is “the unbounded root proptest -samples”; the two check different things. Register the file in both the -`clone-detector` and `all` groups of `scripts/run-verus.sh`. If the lemma -cannot be made substantive (not a restatement) and well-founded in two -attempts, escalate (Tolerances) — the fallback is Kani + proptest only, with a -Decision Log entry, and the bounded Kani order-independence harness must then -stand alone as the order-independence evidence (not a coverage hole). - -Kani (`src/ast/kani.rs`, `#[cfg(kani)]`): harnesses over a bounded synthetic -`NormalizedTree`/candidate set, never the parser. Pin the bounded tree shape -(depth ≤ 3, ≤ 2 children) with a `const _` assertion tying the unwind bound to -it — note the recursive state space is `branching^depth`, unlike the existing -flat `LshIndex` harnesses; the resolved bound is `KANI_AST_UNWIND = 5` -(`KANI_AST_MAX_DEPTH + 2`), applied per-harness via `#[kani::unwind(5)]`. -Harnesses: `verify_smallest_covering_node_selects_minimal_range` (over the -factored `cover::select_smallest_covering`, with `kani::assume(n >= 2)` so the -minimality postcondition has something to bite on: result covers the target and -no covering candidate is strictly smaller); a **separate** -`verify_smallest_covering_root_fallback` for the `n == 0`/no-cover path (do not -fold it into the minimality harness, where it would be vacuous); -`verify_kind_index_is_bounded`; and a cheap -`verify_count_accumulation_is_order_independent_bounded`. Register the harness -names in `run_clone_detector_harnesses` in `scripts/run-kani.sh`. +count-substrate hybrid makes this a clean, decidable statement without a +fixed-point rounding obligation). Parser lowering caps output at 10,000 +normalized nodes and depth 256, so parser-produced counts and depths are +representable in production `u32` and `u16` fields. Public callers can +construct larger `NormalizedTree` values directly; do not extend that +representability claim to those arbitrary trees. State, in one falsifiable +sentence in Artefacts, **exactly** what the trust bridge assumes versus proves. +Be honest about the division of labour (review finding 🟡-3): if the property +that actually breaks operationally — sibling visit order in the feature walk — +is carried by **proptest**, and Verus proves only the algebraic fold over a +given multiset, then say so plainly and do **not** claim Verus is “the +unbounded root proptest samples”; the two check different things. Register the +file in both the `clone-detector` and `all` groups of `scripts/run-verus.sh`. +If the lemma cannot be made substantive (not a restatement) and well-founded in +two attempts, escalate (Tolerances) — the fallback is Kani + proptest only, +with a Decision Log entry, and the bounded Kani order-independence harness must +then stand alone as the order-independence evidence (not a coverage hole). + +Kani (`crates/whitaker_clones_core/src/ast/kani.rs`, `#[cfg(kani)]`): harnesses +over bounded synthetic `NormalizedTree`/candidate values, never the parser. The +five-node tree has at most two children per node. Its depth-traversal bound is +six: five visits plus the terminating loop check. The depth-only proof uses +`#[kani::unwind(6)]`, with unwinding assertions enabled. The full-range +`KindId` round trip has its own `#[kani::unwind(1)]` proof. The depth-only +proof uses fixed kinds and a fixed-size tally, avoiding map internals. A scalar +equality/order proof uses four independent two-bit labels for the root, left +subtree, right subtree, and query; this alphabet covers every equality +partition and relative ordering of those four roles without making map +operations symbolic. Experiments that called production `kind_counts` on a +symbolic and then a fixed-key five-node tree both timed out at ten minutes, at +3,381 MiB and 3,915 MiB peak memory respectively. They are inconclusive, not +passes, so Kani leaves production-map internals to runtime tests and proptest. +The index's MinHash harnesses split three independent obligations: empty-input +rejection still exercises `MinHasher::sketch`; a lane-minimum harness sends the +fixed full-width boundary hashes `0` and `u64::MAX` through the production +minimum and mixer; and a normalization harness checks a fixed unsorted list +with a duplicate through production `unique_hashes`. These deliberately do not +construct complete 128-lane signatures. Runtime tests remain responsible for +full-signature determinism, duplicate-equivalent signatures, and identical-set +order independence. The lane proof uses `#[kani::unwind(3)]` for two visits +plus loop termination; the fixed four-fingerprint normalization proof uses +`#[kani::unwind(8)]`, with unwinding assertions retained. Every harness runs +sequentially in an 8 GiB process-tree scope with a ten-minute timeout; timeout, +resource exhaustion, or missing peak measurement is unsuccessful or +inconclusive. Register the harness names in `scripts/run-kani.sh`. **Mutation-validate as a matrix** (review finding 🟡-3): each deliberate mutation — `<=`→`<` in the minimality compare, *and* dropping the covering check — must be shown to fail **at least one named harness**, recording which @@ -1252,7 +1394,31 @@ insufficient. Restore the production code before committing. Validation: `make verus-clone-detector` and `make kani-clone-detector` pass; the mutation matrix is recorded. -### Stage G — Documentation, gates, review, roadmap +The covering proofs have different domains. The minimal-selection harness uses +symbolic ranges constrained to valid, non-empty offsets in `0..=16` and assumes +at least one candidate covers the target. The harness named +`verify_missing_cover_returns_none` uses an empty array and one fixed +non-covering array, and asserts that `select_smallest_covering` returns `None`. +It does not execute the parser adapter's root fallback. The index constructor +proofs also use explicit domains: the symbolic constructor constrains both +`bands` and `rows` to at most `MINHASH_SIZE`; the overflow harness fixes +`rows = 2` and assumes a non-zero `bands` value greater than `usize::MAX / 2`. + +The four LSH candidate-pair harnesses use a separate `#[cfg(kani)]` model. +`insert` records up to four fragments and two compact band keys, then +`candidate_pair_summary_for_kani` compares recorded fragment pairs directly and +emits a pair when any recorded band matches. It uses `CandidatePair::new` and a +fixed-size pair summary, but does not execute production `candidate_pairs`, +`add_bucket_pairs`, B-tree bucket enumeration, or the production set's +cross-band deduplication. The repeated-band harness therefore checks that one +model pair is summarized once; runtime tests and BDD scenarios remain the +evidence for repeated production bucket emissions and deduplication. The +canonical-order harness uses `CandidatePair::new` for both the modelled pair +and expected pair, so it checks reversal invariance but is not an independent +oracle for that constructor's ordering rule. Runtime tests cover canonical +ordering through the public production path. + +### Stage G — Documentation, gates, review, and reconciliation Record the realized design decisions in `docs/whitaker-clone-detector-design.md` under a new “Implementation decisions @@ -1276,8 +1442,8 @@ lowered-IR boundary or the proof strategy warrants a new ADR; if so, author (record the decision either way). Run `make check-fmt`, `make lint`, `make test`, then the proof targets, then `make markdownlint` for the docs. Request `coderabbit review --agent` only after all deterministic gates pass; -clear every concern. Tick roadmap item 7.3.1 to done. Commit, push, and ensure -the draft PR references this ExecPlan. +clear every concern. Roadmap item 7.3.1 is already marked complete. Commit, +push, and ensure the PR references this ExecPlan. ## Concrete steps @@ -1392,22 +1558,218 @@ proves success. - Stage 0 rustc: `rustc 1.98.0-nightly (57d06900f 2026-05-27)`. - Stage F Kani bounds: the AST harnesses use a synthetic tree with - `KANI_AST_MAX_DEPTH = 3`, `KANI_AST_MAX_CHILDREN = 2`, and - `KANI_AST_UNWIND = 5`; a `const _` assertion ties the unwind bound to - `KANI_AST_MAX_DEPTH + 2`. + `KANI_AST_MAX_DEPTH = 3`, `KANI_AST_MAX_CHILDREN = 2`, and five nodes. The + depth traversal uses `KANI_AST_UNWIND = 6` (five visits plus termination); a + `const _` assertion ties this bound to `KANI_AST_NODE_COUNT + 1`. The full + `u16` round trip is a separate unwind-1 proof, while equality/order checks + use a four-value scalar alphabet. Production `BTreeMap` construction stays + outside Kani after bounded symbolic and fixed-key map proofs each timed out; + runtime unit tests and `proptest` cover that accumulator. +- Stage F candidate-bound Kani evidence (2026-10-18, base + `61a101dd2eaa62059ed0152249bceda85ece3ac1`, head + `c59826254eccb56bce0c7290cc62ec5910d27dad`): Kani 0.67.0 used its pinned + `nightly-2025-11-21` / rustc 1.93 toolchain, CBMC 6.8.0, and CaDiCaL 2.0.0. + The Kani results trust Kani's Rust translation and library models, CBMC, and + CaDiCaL; they are not independently checked proof certificates. Each harness + ran separately through `./scripts/run-kani.sh clone-detector-harness ` + with `--no-default-features`, `--default-unwind 4`, its named harness, and a + 4 GiB process-tree memory cap, swap disabled, and a 600-second timeout. The + harness-specific unwind attribute overrides the default where present. + Captured outcomes: + - `verify_kind_id_round_trips_full_u16_domain`: passed, 1/1 checks; + 14-second wrapper duration, 69 MiB peak. + - `verify_kind_counts_stop_at_max_depth`: passed, 569 checks, zero failed, + nine unreachable; 2.34 seconds of verification, 6-second wrapper duration, + 151 MiB peak. CBMC check IDs 68, 69, 96, 97, 110, 112, 114, 118, and 123 + are the nine unreachable checks. They are standard-library safety guards + for `usize::abs_diff`, `vec!` pointer alignment/null validity, + `is_aligned_to`, allocation-size division, formatting shifts, signed + negation, and allocator growth arithmetic. None is a harness assertion: + the custom no-beyond-depth and five-node tally assertions (checks 26 and + 31) both report `SUCCESS`. No harness assertion is unreachable. + - `verify_kind_id_equality_and_order_cover_small_alphabet`: passed, 39/39 + checks; 12-second wrapper duration, 68 MiB peak. + - Negative control with the depth harness at unwind 5 failed its unwinding + assertion at `for_each_node_at_depth`; 20-second wrapper duration, + 159 MiB peak. Unwinding assertions remained enabled in all runs. + These bounded results verify the three stated propositions only. In + particular, this is narrower Kani coverage than the removed symbolic + `kind_counts` accumulation-order harness; it does not prove the production + `BTreeMap` sink or general traversal fidelity. +- Historical full-group result on the `c5982625` source candidate: the + 8 GiB-capped, 600-second run reached the former full-signature + `verify_min_hasher_sketch_is_deterministic` harness at the memory cap after + 357 seconds (`oom-kill`). That result was unsuccessful/inconclusive; it was + not treated as a pass, and no unrestricted run was made. +- MinHash Kani reduction on the candidate based on `17bd3398`: replace the two + full-signature MinHash harnesses with + `verify_min_hasher_lane_minimum_for_boundary_hashes` and + `verify_min_hasher_unique_hashes_sorts_and_deduplicates`. The first proof + uses a fixed seed and the distinct mixed values of `0` and `u64::MAX`; it + proves only that this production lane returns their minimum. The second uses + four fixed fingerprints and proves sorted unique output from `unique_hashes`. + Full-signature behaviour remains covered by the runtime tests cited above. An + exploratory symbolic full-width lane proof timed out at 600 seconds and 237 + MiB; it was inconclusive and replaced by the fixed boundary case. The first + normalization proof also exposed a `memcmp` unwinding failure in a + slice-equality assertion at unwind 8. Replacing that comparison with scalar + length and element assertions kept unwinding assertions enabled and passed. +- Follow-up seam correction after local CodeScene review: the lane harness now + passes the existing `Seed` domain type and calls production + `minimum_mixed_hash` directly for the two singleton values and their pair. + This removes proof-only raw-`u64` helper parameters that triggered a new + Primitive Obsession signal. The focused lane proof passed again with 81 + checks, zero failures, one unreachable safety guard, a 3-second wrapper + duration, and 67 MiB peak under the 8 GiB cap; see + `/tmp/kani-minhash-lane-boundary-pr365-codescene-fix.out`. The follow-up + executable-source diff from `d32558150b3200c17a9ae7e3c8c5d99d6d0853e8` at + this run has SHA-256 + `36b4dc156344b4c259f16cdc4af8610a5fbcfe3324a1f0af310dc767123f4864`. CodeScene + CLI 1.0.52's base-to-working-tree delta then reported no new issue; its only + result was an unrelated pre-existing Complex Method improvement in + `module_must_have_inner_docs/src/driver/inner_attr.rs`, recorded in + `/tmp/codescene-delta-pr365-lane-seam-fix.out`. +- Current complete-group result on the candidate worktree (base + `61a101dd2eaa62059ed0152249bceda85ece3ac1`, committed parent + `d32558150b3200c17a9ae7e3c8c5d99d6d0853e8`, executable-source follow-up diff + SHA-256 `36b4dc156344b4c259f16cdc4af8610a5fbcfe3324a1f0af310dc767123f4864`): + `make kani-clone-detector` passed all 15 harnesses, with zero failed checks + and zero unwinding failures. Kani 0.67.0 used its pinned `nightly-2025-11-21` + / rustc 1.93 toolchain, CBMC 6.8.0, and CaDiCaL 2.0.0. Each harness ran in + its own 8 GiB process-tree cgroup, with swap disabled and a 600-second + timeout; missing peak-memory evidence, timeout, OOM, or any non-zero result + fails the wrapper and records no proof pass. The wrapper durations sum to 362 + seconds, and the highest peak was 4,842 MiB for + `verify_lsh_index_is_insertion_order_independent` (124 seconds). The complete + per-harness record follows: + + | Harness | Checks | Unreachable | Wall (s) | Peak (MiB) | + | -------------------------------------------------------- | --------------: | ----------: | -------: | ---------: | + | `verify_lsh_config_new_smoke` | 15, 0 failed | 0 | 2 | 67 | + | `verify_lsh_config_new_symbolic` | 59, 0 failed | 1 | 2 | 67 | + | `verify_lsh_config_new_overflow_product` | 30, 0 failed | 4 | 2 | 67 | + | `verify_min_hasher_sketch_rejects_empty_input` | 2,208, 0 failed | 223 | 8 | 231 | + | `verify_min_hasher_lane_minimum_for_boundary_hashes` | 81, 0 failed | 1 | 1 | 67 | + | `verify_min_hasher_unique_hashes_sorts_and_deduplicates` | 1,161, 0 failed | 80 | 7 | 160 | + | `verify_lsh_index_rejects_self_pairs` | 3,590, 0 failed | 11 | 41 | 1,939 | + | `verify_lsh_index_canonicalizes_pair_order` | 3,612, 0 failed | 8 | 57 | 2,879 | + | `verify_lsh_index_deduplicates_repeated_band_collisions` | 3,615, 0 failed | 136 | 73 | 3,581 | + | `verify_lsh_index_is_insertion_order_independent` | 3,628, 0 failed | 8 | 124 | 4,842 | + | `verify_smallest_covering_node_selects_minimal_range` | 342, 0 failed | 2 | 18 | 164 | + | `verify_smallest_covering_root_fallback` | 319, 0 failed | 8 | 15 | 360 | + | `verify_kind_id_round_trips_full_u16_domain` | 1, 0 failed | 0 | 2 | 67 | + | `verify_kind_counts_stop_at_max_depth` | 569, 0 failed | 9 | 6 | 151 | + | `verify_kind_id_equality_and_order_cover_small_alphabet` | 39, 0 failed | 0 | 4 | 67 | + + The depth-only harness's nine unreachable checks are all compiler/library + safety guards, not harness assertions: checks 68 and 69 are the two + `usize::abs_diff` subtraction guards for concrete valid ranges; checks 96 and + 97 are Kani's fixed-fixture pointer-validity guards for the `vec!` + allocation; check 110 is `is_aligned_to` pointer-alignment arithmetic; check + 112 is allocation-size validity; check 114 is the formatting bit-shift guard; + check 118 is signed-negation overflow; and check 123 is allocator growth + arithmetic. Both explicit harness assertions (checks 26 and 31) report + `SUCCESS`, including the no-beyond-depth and five-node tally assertions; the + four associated array-index bounds checks (27–30) also report `SUCCESS`. The + depth proof therefore checks the fixed five-node traversal with unwind 6 + (five visits plus the terminal loop check); unwinding assertions stayed + enabled. This scoped property still does not prove the production `BTreeMap` + sink or general traversal fidelity. The complete run log is + `/tmp/kani-pr365-equilibrium-codescene-fix.out`. + + A preliminary full-group attempt at a stricter 4 GiB cap reached the + insertion-order harness and was killed at 4,096 MiB after 133 seconds. This + remains a resource-limited, inconclusive result rather than a pass; the final + run stayed bounded at 8 GiB per process tree. +- Current bounded Kani result (2026-10-19, base commit + `a20569eabd350954f639bed5b8307a2435e16070`, proof-source patch SHA-256 + `e3c44ae90da6fe34bb179af1803ab4d5adc636df34f8cb73d20ac7c2d270b593`): + `make kani-clone-detector` passed all 15 registered harnesses with zero + failed checks. Each invocation used its own process-tree cgroup with an 8 GiB + memory cap, swap disabled, and a 600-second timeout. The wrapper fails on + timeout, OOM, non-zero exit, or missing peak-memory evidence, so none of + those outcomes can be reported as a proof pass. Durations sum to 652 seconds; + the highest peak was 4,835 MiB for + `verify_lsh_index_is_insertion_order_independent` (159 seconds). The complete + transcript and focused proof output are preserved in + [the PR 365 proof-run record](../verification/clone-detector/pr365-2026-10-19/README.md). + + Before the full group, the standalone focused run of + `verify_missing_cover_returns_none` passed in 68 seconds and peaked at 124 + MiB under the same 8 GiB cap. In the later full-group run, that harness took + 2 seconds and peaked at 67 MiB. The proof-run record links both transcripts. + + | Harness | Checks | Unreachable | Wall (s) | Peak (MiB) | + | -------------------------------------------------------- | --------------: | ----------: | -------: | ---------: | + | `verify_lsh_config_new_smoke` | 15, 0 failed | 0 | 116 | 94 | + | `verify_lsh_config_new_symbolic` | 59, 0 failed | 1 | 14 | 67 | + | `verify_lsh_config_new_overflow_product` | 30, 0 failed | 4 | 38 | 67 | + | `verify_min_hasher_sketch_rejects_empty_input` | 2,208, 0 failed | 223 | 17 | 232 | + | `verify_min_hasher_lane_minimum_for_boundary_hashes` | 81, 0 failed | 1 | 9 | 67 | + | `verify_min_hasher_unique_hashes_sorts_and_deduplicates` | 1,161, 0 failed | 80 | 15 | 160 | + | `verify_lsh_index_rejects_self_pairs` | 3,590, 0 failed | 11 | 72 | 1,939 | + | `verify_lsh_index_canonicalizes_pair_order` | 3,612, 0 failed | 8 | 78 | 2,878 | + | `verify_lsh_index_deduplicates_repeated_band_collisions` | 3,615, 0 failed | 136 | 107 | 3,579 | + | `verify_lsh_index_is_insertion_order_independent` | 3,628, 0 failed | 8 | 159 | 4,835 | + | `verify_smallest_covering_node_selects_minimal_range` | 221, 0 failed | 2 | 6 | 74 | + | `verify_missing_cover_returns_none` | 198, 0 failed | 4 | 2 | 67 | + | `verify_kind_id_round_trips_full_u16_domain` | 1, 0 failed | 0 | 5 | 67 | + | `verify_kind_counts_stop_at_max_depth` | 569, 0 failed | 9 | 10 | 151 | + | `verify_kind_id_equality_and_order_cover_small_alphabet` | 39, 0 failed | 0 | 4 | 67 | + + The depth-only harness uses fixed kinds and a five-node tree, so symbolic + kind variation and production `BTreeMap` operations are outside that + obligation. The full-range round trip is proved independently; equality and + ordering are checked over the documented four-value alphabet. A focused + negative-control run at unwind 5 failed the loop-termination unwinding + assertion in `for_each_node_at_depth` (`features.rs:216`): 1 of 570 checks + failed, with 569 undetermined, after 43 seconds and a 100 MiB peak. The + unwinding assertion remained enabled. At unwind 6 the depth proof passed with + 569 checks, nine unreachable library/safety checks, a 10-second duration, and + a 151 MiB peak. This confirms that five node visits plus the terminal loop + check require bound 6; bound 5 is insufficient. The negative control + transcript is preserved alongside the full run in the proof-run record. - Stage F Verus trust bridge: `verus/clone_detector_ast_features.rs` proves the exact count accumulator algebra for a supplied multiset of `(kind, depth)` contributions; Rust unit tests and `proptest` cover the - production `NormalizedTree` traversal that produces those contributions. + production `NormalizedTree` traversal that produces those contributions. For + the current candidate worktree (base + `61a101dd2eaa62059ed0152249bceda85ece3ac1`, committed parent + `d32558150b3200c17a9ae7e3c8c5d99d6d0853e8`, executable-source follow-up diff + SHA-256 `36b4dc156344b4c259f16cdc4af8610a5fbcfe3324a1f0af310dc767123f4864`), + `make verus-clone-detector` passed against Verus `0.2026.03.17.a96bad0` + (toolchain `1.94.0-x86_64-unknown-linux-gnu`): the three proof groups + reported 9, 10, and 8 verified, each with zero errors. The Verus lemma + assumes equal per-`(kind, depth)` contribution multisets and proves equal + exact counts for every queried bucket; it does not establish that Rust + traversal emits the same multiset. + `count_and_production_features_ignore_sibling_visit_order` supplies the + complementary property test by reversing sibling order on generated trees and + comparing production `kind_counts` and `production_multiset` results. The + full bounded Kani group passed on the same candidate worktree as recorded + above. The exact Verus log is + `/tmp/verus-pr365-equilibrium-codescene-fix.out`. +- Earlier Verus revalidation (2026-10-19, commit + `c59826254eccb56bce0c7290cc62ec5910d27dad`): `make verus-clone-detector` + passed with 9, 10, and 8 verified obligations and zero errors. This result is + separate from the current Kani run; its concise output and tool version are + preserved in the proof-run record. +- Current local gates (2026-10-19, candidate source; run sequentially): + `make check-fmt`, `make lint`, `make typecheck`, `make test`, + `make markdownlint`, `make nixie`, and `make spelling` passed sequentially. + The workspace test run reports 1,871 passed and 5 skipped; the additional + `dylint_testing` suite reports 11 passed. Captured logs use the + `/tmp/*-pr365-equilibrium-scope-fix.out` names. The proof-evidence directory + and its index entry require documentation-only gates before commit. - Stage F mutation matrix: - `<=` -> `<` in the covering lower-bound predicate failed `verify_smallest_covering_node_selects_minimal_range` with `covering candidate must be selected` and `no covering candidate may be strictly smaller than the selected candidate` (captured in `/tmp/kani-mutation-start-bound-*.out`). - - Dropping the covering predicate failed - `verify_smallest_covering_root_fallback` with - `non-covering candidate sets must fall back to the parser root` + - Dropping the covering predicate failed `verify_missing_cover_returns_none` + with the assertion that selection remains empty; the mutation log retains + the earlier assertion message claiming parser-root fallback. (captured in `/tmp/kani-mutation-drop-cover-*.out`). - After restoring `select_smallest_covering`, both affected harnesses passed (captured in `/tmp/kani-restored-cover-*.out`). diff --git a/docs/repository-layout.md b/docs/repository-layout.md index ac35dfd86..6d8fafbd5 100644 --- a/docs/repository-layout.md +++ b/docs/repository-layout.md @@ -93,6 +93,12 @@ The repository root `src/` directory holds the top-level library entry points and shared lint-registration glue. The `suite/` crate packages the lint set as an integrated suite and carries its own source and tests. +### `vendor/` + +The `vendor/` directory contains a local patch to `dylint_testing` 6.0.1. Its +example-target parser recognizes compiler commands with Cargo wrappers, so UI +tests can preserve configured wrappers and compiler admission. + ## Testing and verification assets - `tests/` holds workspace-level integration tests, behaviour-driven tests, diff --git a/docs/roadmap.md b/docs/roadmap.md index 66419a920..d99411a41 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -385,8 +385,10 @@ published-GPUI workspace invocation. behaviour when vectors have no overlapping positive features. See [brain trust lints design](brain-trust-lints-design.md) §Decomposition advice. Requires 6.4.1. -- [x] 6.4.5. Use Kani to verify `build_adjacency` preserves similarity edges, - keeps neighbour indices in bounds, and produces symmetric adjacency lists. See +- [x] 6.4.5. Verify `build_adjacency` preserves similarity edges, keeps + neighbour indices in bounds, and produces symmetric, sorted adjacency lists. + A generated Proptest checks these invariants through the production builder; + adjacency Kani models exceeded the 8 GiB proof cap. See [brain trust lints design](brain-trust-lints-design.md) §Decomposition advice. Requires 6.4.1. - [x] 6.4.6. Use Kani to verify `propagate_labels` preserves valid label diff --git a/docs/users-guide.md b/docs/users-guide.md index 64a0b8244..d6654be86 100644 --- a/docs/users-guide.md +++ b/docs/users-guide.md @@ -896,7 +896,11 @@ the Pass B substrate only — it does not yet report clones (see below). supplied source, and lowers the smallest syntax subtree that covers the span into a parser-independent `NormalizedTree`. Working from the smallest covering subtree keeps the representation tied to the candidate rather than the whole -file. +file. Lowering stops when the selected subtree exceeds 256 levels of depth or +10,000 normalized nodes. Non-trivia token leaves count towards the node budget. +The operation returns `AstError::TreeTooDeep` or `AstError::TreeTooLarge` when +it exceeds either limit, so callers can handle oversized candidates without +accepting a partial tree. **Feature vectors.** From a `NormalizedTree`, extraction derives a deterministic set of features: diff --git a/docs/verification/clone-detector/pr365-2026-10-19/README.md b/docs/verification/clone-detector/pr365-2026-10-19/README.md new file mode 100644 index 000000000..bb275e522 --- /dev/null +++ b/docs/verification/clone-detector/pr365-2026-10-19/README.md @@ -0,0 +1,87 @@ +# Clone-detector proof run for PR 365 + +This directory preserves the bounded model-checking output used to assess the +clone-detector proofs in PR 365. The complete Kani output includes every +harness transcript and its per-process-tree resource result. + +These are historical runs. The exact-head Kani run for candidate +`bf9aa109ca8157d70fc7aa030e9e8dc04fd5909d` is recorded in +[`pr365-2026-10-25`](../pr365-2026-10-25/README.md), including its transcript +and reproducible proof-source patch. + +## Kani + +The full run used Kani Rust Verifier 0.67.0 and CBMC 6.8.0 on 2026-10-19. It ran +`make kani-clone-detector`, which invokes +`scripts/run-kani.sh clone-detector`. All 15 harnesses reported +`scope_result=success` and `VERIFICATION:- SUCCESSFUL`. + +Each proof process had an 8 GiB memory limit with swap disabled and a +600-second timeout. A memory or timeout limit produces an unsuccessful, +inconclusive run; it cannot be reported as a pass. The largest peak was 4,835 +MiB for `verify_lsh_index_is_insertion_order_independent`, which took 159 +seconds. `verify_kind_counts_stop_at_max_depth` took 10 seconds and used 151 +MiB. `verify_kind_id_round_trips_full_u16_domain` took 5 seconds and used 67 +MiB. In this full-group run, `verify_missing_cover_returns_none` took 2 seconds +and used 67 MiB. + +The full transcript is in +[`kani-clone-detector.log.gz`](kani-clone-detector.log.gz). The focused +transcript is in +[`kani-missing-cover-focused.log.gz`](kani-missing-cover-focused.log.gz); it +was the standalone run completed before the full group. It passed in 68 seconds +and peaked at 124 MiB. This is the pre-suite measurement; the 2-second result +above is the later run within the full group. The focused invocation was +produced by +`./scripts/run-kani.sh clone-detector-harness verify_missing_cover_returns_none` + +The unwind-bound negative control is in +[`kani-depth-unwind-5-negative-control.log.gz`](kani-depth-unwind-5-negative-control.log.gz). +At `#[kani::unwind(5)]`, Kani failed the loop-termination unwinding assertion +after five visits: 1 of 570 checks failed and 569 were undetermined. The run +took 43 seconds and peaked at 100 MiB. The production harness therefore uses +unwind 6; unwinding assertions remain enabled. + +The runs used base commit `a20569eabd350954f639bed5b8307a2435e16070` with the +following proof-source patch applied to +`crates/whitaker_clones_core/src/ast/kani.rs` and `scripts/run-kani.sh`: + +```text +SHA-256: e3c44ae90da6fe34bb179af1803ab4d5adc636df34f8cb73d20ac7c2d270b593 +``` + +This identifies the exact source changes used by the local proof runs; the +documentation edits do not affect the compiled harnesses. + +### Focused KindId and depth rerun + +After merging the PR candidate changes, the three separated obligations were +rerun individually before starting broader gates. Each invocation used the 8 +GiB process-tree memory cap, disabled swap, and 600-second timeout from +`scripts/run-kani-limited.sh`; all three returned `VERIFICATION:- SUCCESSFUL` +with unwinding assertions enabled. + +| Harness | Duration | Peak memory | +| -------------------------------------------------------- | ---------: | ----------: | +| `verify_kind_counts_stop_at_max_depth` | 23 seconds | 299 MiB | +| `verify_kind_id_round_trips_full_u16_domain` | 8 seconds | 67 MiB | +| `verify_kind_id_equality_and_order_cover_small_alphabet` | 3 seconds | 67 MiB | + +These focused runs used Kani 0.67.0 and CBMC 6.8.0 on 2026-10-19. Their +complete outputs, including the per-process-tree resource records, are retained +in `/tmp/kani-depth-harness-20261019-rerun.out`, +`/tmp/kani-kind-id-roundtrip-20261019.out`, and +`/tmp/kani-kind-id-small-alphabet-20261019.out` in the originating workspace. +An earlier invocation exited before proof generation because the merged +`common/Cargo.toml` contained a duplicate `temp-env` entry; that manifest error +was removed before these successful runs. + +## Verus + +The related Verus run record is preserved in +[`verus-clone-detector.log`](verus-clone-detector.log), with the tool version in +[`verus-version.txt`](verus-version.txt). It records 9, 10, and 8 verified +proofs with zero errors, using Verus 0.2026.03.17.a96bad0 and toolchain 1.94.0. +This earlier run was performed on commit +`c59826254eccb56bce0c7290cc62ec5910d27dad`; it is identified separately from +the current Kani run. diff --git a/docs/verification/clone-detector/pr365-2026-10-19/kani-clone-detector.log.gz b/docs/verification/clone-detector/pr365-2026-10-19/kani-clone-detector.log.gz new file mode 100644 index 000000000..fbad13577 Binary files /dev/null and b/docs/verification/clone-detector/pr365-2026-10-19/kani-clone-detector.log.gz differ diff --git a/docs/verification/clone-detector/pr365-2026-10-19/kani-depth-unwind-5-negative-control.log.gz b/docs/verification/clone-detector/pr365-2026-10-19/kani-depth-unwind-5-negative-control.log.gz new file mode 100644 index 000000000..18b336bb5 Binary files /dev/null and b/docs/verification/clone-detector/pr365-2026-10-19/kani-depth-unwind-5-negative-control.log.gz differ diff --git a/docs/verification/clone-detector/pr365-2026-10-19/kani-missing-cover-focused.log.gz b/docs/verification/clone-detector/pr365-2026-10-19/kani-missing-cover-focused.log.gz new file mode 100644 index 000000000..6a182d02c Binary files /dev/null and b/docs/verification/clone-detector/pr365-2026-10-19/kani-missing-cover-focused.log.gz differ diff --git a/docs/verification/clone-detector/pr365-2026-10-19/verus-clone-detector.log b/docs/verification/clone-detector/pr365-2026-10-19/verus-clone-detector.log new file mode 100644 index 000000000..cde8386bf --- /dev/null +++ b/docs/verification/clone-detector/pr365-2026-10-19/verus-clone-detector.log @@ -0,0 +1,4 @@ +./scripts/run-verus.sh clone-detector +verification results:: 9 verified, 0 errors +verification results:: 10 verified, 0 errors +verification results:: 8 verified, 0 errors diff --git a/docs/verification/clone-detector/pr365-2026-10-19/verus-version.txt b/docs/verification/clone-detector/pr365-2026-10-19/verus-version.txt new file mode 100644 index 000000000..d18de320d --- /dev/null +++ b/docs/verification/clone-detector/pr365-2026-10-19/verus-version.txt @@ -0,0 +1,4 @@ +Verus Version: 0.2026.03.17.a96bad0 +Profile: release +Platform: linux_x86_64 +Toolchain: 1.94.0-x86_64-unknown-linux-gnu diff --git a/docs/verification/clone-detector/pr365-2026-10-25/README.md b/docs/verification/clone-detector/pr365-2026-10-25/README.md new file mode 100644 index 000000000..95a9b3aa0 --- /dev/null +++ b/docs/verification/clone-detector/pr365-2026-10-25/README.md @@ -0,0 +1,123 @@ +# Clone-detector proof run for PR 365 + +This record captures exact-head Kani runs for PR 365: the published `bf9aa109` +candidate and the documentation-only `e100b422` candidate. The historical runs +and unwind-5 negative control from 2026-10-19 remain in +[`pr365-2026-10-19`](../pr365-2026-10-19/README.md); they are separate evidence. + +## Follow-up execution on candidate e100b422 + +| Field | Value | +| :--------------- | :----------------------------------------- | +| Candidate commit | `e100b4227d815263f1abc5c769454a99bcb6b881` | +| Candidate tree | `c67a76716874c072ce5c2ea4ee5de6b4dbee4f7b` | +| Worktree state | Clean before and after | +| Command | `make kani-clone-detector` | +| Tool versions | Kani 0.67.0, CBMC 6.8.0, CaDiCaL 2.0.0 | +| Run duration | 540 seconds | +| Harness result | 15 passed; 0 failed or inconclusive | + +The run used the committed HEAD and tree above. Its complete transcript is +[`kani-clone-detector-e100.log.gz`](kani-clone-detector-e100.log.gz). The +uncompressed transcript SHA-256 is +`99766e3c8d20142bf114e30588076ba2bea85049a9b480eeba8022976fcc2923`; the +compressed archive SHA-256 is +`7cafc4a98415c71c0e73d783c4c84313f1deb0d40cb3ae58e65f49055bc4d9c3`. The largest +proof was insertion-order independence at 187 seconds and 4,835 MiB. + +## Candidate and execution + +| Field | Value | +| :--------------- | :----------------------------------------- | +| Candidate commit | `bf9aa109ca8157d70fc7aa030e9e8dc04fd5909d` | +| Candidate tree | `e68c2eb57e2ed7ff0f32e73113e359c4f44720ab` | +| Comparison base | `45b06add7c8f1fd6334377ff5c4e07931c95e38b` | +| Command | `make kani-clone-detector` | +| Expanded command | `./scripts/run-kani.sh clone-detector` | +| Tool versions | Kani 0.67.0, CBMC 6.8.0, CaDiCaL 2.0.0 | +| Run duration | 364 seconds | +| Harness result | 15 passed; 0 failed or inconclusive | + +The worktree was clean before and after the run. Its HEAD and tree matched the +values above on both checks. The archived transcript is +[`kani-clone-detector.log.gz`](kani-clone-detector.log.gz). Its uncompressed +SHA-256 is `c8b0c7e720df50f30ef6b9ff0c1c5abf7ea85ca3fc265466951499e31d3d6bae`; +the compressed file SHA-256 is +`f07f73c807e18284ae1618e82fae60d3b5f1404fc43862e4ce4a2e2a3b7cf692`. + +Each proof ran sequentially with an 8 GiB process-tree memory cap, swap +disabled, and a 600-second timeout. A timeout, out-of-memory result, non-zero +exit, or missing peak measurement is unsuccessful or inconclusive. The largest +run was insertion-order independence at 117 seconds and 4,857 MiB. + +The proof-source patch from the previously assessed base is reproducible with +this exact generation command. Explicitly set `--abbrev=8` so Git emits stable +index abbreviations across environments: + +```sh +git diff --binary --no-ext-diff --no-textconv --abbrev=8 \ + a20569eabd350954f639bed5b8307a2435e16070 \ + bf9aa109ca8157d70fc7aa030e9e8dc04fd5909d -- \ + crates/whitaker_clones_core/src/ast/kani.rs \ + scripts/run-kani.sh > /tmp/kani-proof-source-a205-to-bf9.patch +sha256sum /tmp/kani-proof-source-a205-to-bf9.patch +``` + +The patch SHA-256 is +`e3c44ae90da6fe34bb179af1803ab4d5adc636df34f8cb73d20ac7c2d270b593`. This +command and patch identify the exact AST proof and runner changes used by both +candidates; the index harness source was already present at the comparison +base. The `e100b422` candidate leaves those proof sources unchanged. + +## Harness results + +| Harness | Result | Duration | Peak memory | +| :------------------------------------------------------- | :----- | -------: | ----------: | +| `verify_lsh_config_new_smoke` | passed | 2 s | 95 MiB | +| `verify_lsh_config_new_symbolic` | passed | 1 s | 68 MiB | +| `verify_lsh_config_new_overflow_product` | passed | 1 s | 68 MiB | +| `verify_min_hasher_sketch_rejects_empty_input` | passed | 9 s | 236 MiB | +| `verify_min_hasher_lane_minimum_for_boundary_hashes` | passed | 2 s | 68 MiB | +| `verify_min_hasher_unique_hashes_sorts_and_deduplicates` | passed | 11 s | 163 MiB | +| `verify_lsh_index_rejects_self_pairs` | passed | 71 s | 1,941 MiB | +| `verify_lsh_index_canonicalizes_pair_order` | passed | 65 s | 2,884 MiB | +| `verify_lsh_index_deduplicates_repeated_band_collisions` | passed | 77 s | 3,584 MiB | +| `verify_lsh_index_is_insertion_order_independent` | passed | 117 s | 4,857 MiB | +| `verify_smallest_covering_node_selects_minimal_range` | passed | 2 s | 158 MiB | +| `verify_missing_cover_returns_none` | passed | 2 s | 68 MiB | +| `verify_kind_id_round_trips_full_u16_domain` | passed | 0 s | 68 MiB | +| `verify_kind_counts_stop_at_max_depth` | passed | 4 s | 154 MiB | +| `verify_kind_id_equality_and_order_cover_small_alphabet` | passed | 0 s | 68 MiB | + +## Interpretation limits + +- The covering harness checks three symbolic candidate ranges in offsets + `0..=16`, assuming at least one covers the target. It checks shared + production selection predicates, not parser candidate enumeration. The + missing-cover harness checks an empty array and one fixed non-covering array. +- The `KindId` round trip covers all 65,536 `u16` values. The depth harness + checks one five-node fixed-kind tree with a fixed-array tally. The four-value + ordering harness covers its small symbolic alphabet only. These checks do not + prove general traversal fidelity or production `BTreeMap` accumulation; + runtime and property tests cover production accumulation and ordering. +- The configuration harnesses call production `LshConfig::new`. The symbolic + case bounds both inputs to `[0, 128]`; the overflow case fixes `rows = 2` and + assumes `bands > usize::MAX / 2`. The smoke case checks one accepted input. +- The MinHash checks cover empty-input rejection, a lane minimum for fixed + mixed hashes `0` and `u64::MAX`, and normalization of four fixed + fingerprints. The final harness checks sorted unique **fingerprint hashes** + through `unique_hashes`; it does not check complete sketches or signatures. +- The four LSH pair proofs use a separate bounded Kani model with at most four + fragments, two bands, and six recorded pairs. It does not execute production + bucket enumeration or the production cross-band set deduplication. The + canonical-order harness uses `CandidatePair::new` for both the modelled pair + and expected pair, so it checks reversal invariance but is not an independent + oracle for that constructor's ordering rule. The repeated-band harness + summarizes a pair once when any modelled band matches; it does not simulate + repeated production bucket emissions. Runtime tests and BDD scenarios cover + the public production path. + +The 2026-10-19 unwind-5 negative control remains in the historical record. It +failed the loop-termination unwinding assertion; the current depth harness uses +unwind 6 with unwinding assertions enabled. That control demonstrates the +importance of the loop bound, not a mutation test of the depth tally. diff --git a/docs/verification/clone-detector/pr365-2026-10-25/kani-clone-detector-e100.log.gz b/docs/verification/clone-detector/pr365-2026-10-25/kani-clone-detector-e100.log.gz new file mode 100644 index 000000000..ecec1bec3 Binary files /dev/null and b/docs/verification/clone-detector/pr365-2026-10-25/kani-clone-detector-e100.log.gz differ diff --git a/docs/verification/clone-detector/pr365-2026-10-25/kani-clone-detector.log.gz b/docs/verification/clone-detector/pr365-2026-10-25/kani-clone-detector.log.gz new file mode 100644 index 000000000..9ecfca59c Binary files /dev/null and b/docs/verification/clone-detector/pr365-2026-10-25/kani-clone-detector.log.gz differ diff --git a/docs/whitaker-clone-detector-design.md b/docs/whitaker-clone-detector-design.md index f7a506c84..ece221656 100644 --- a/docs/whitaker-clone-detector-design.md +++ b/docs/whitaker-clone-detector-design.md @@ -611,36 +611,32 @@ cargo whitaker clones report --in target/whitaker/clones.refined.sarif --html the public set-semantics contract from 7.2.2 while avoiding verifier-heavy `BTreeSet` internals in the bounded Kani proof. -2. **Kani verifies real `MinHasher::sketch` calls with a private proof seam.** - The harnesses use a `cfg(kani)`-only `MinHasher::from_seed_for_kani` - constructor so the proof does not spend its unwind bound constructing the - 128-seed stream. Successful harnesses still call the production - `MinHasher::sketch` body; only the seed fixture and fixed-width signature - construction are specialized for Kani. - -3. **The bounded proof shape is deliberately split by invariant.** The empty - input harness checks that `MinHasher::sketch(&[])` returns - `IndexError::EmptyFingerprintSet`. The determinism harness sketches the same - one-fingerprint set twice and proves equality for an arbitrary symbolic - signature lane. The duplicate-hash harness keeps the retained hash symbolic - over a bounded `u8` domain and proves that repeating that hash at a distinct - range does not change the first signature lane. +2. **Kani checks bounded MinHash seams, not full-signature properties.** The + empty-input harness calls `MinHasher::sketch`; the lane-minimum harness calls + `minimum_mixed_hash` for fixed mixed hashes of `0` and `u64::MAX`; and the + normalization harness checks sorting and deduplication through + `unique_hashes` for four fixed fingerprints. These proofs do not establish + non-empty full-signature determinism or general duplicate-hash insensitivity. + +3. **Runtime tests carry broader MinHash properties.** Unit and BDD tests still + cover deterministic full signatures, reordered input, empty input and + duplicate retained hashes. The bounded Kani seams retain the properties + whose proof state spaces have explicit, measured resource bounds. 4. **Ordinary tests carry the broader regression surface.** Unit tests and the `rstest-bdd` MinHash/LSH scenario cover full-signature equality, - representative full-width `u64` hashes, reordered inputs, empty input, and - duplicate retained hashes. The Kani harnesses provide exhaustive bounded - verification for the proof-shaped state spaces rather than replacing those - runtime regression tests. + representative full-width `u64` hashes, reordered inputs, empty input and + duplicate retained hashes. Kani verifies only its named bounded + propositions; it does not stand in for these runtime regression tests. ## Implementation decisions (7.2.8) -1. **Bounded `LshIndex` invariants are verified with Kani.** The clone-detector - Kani harness set now checks four candidate-generation invariants: repeated - insertion of one fragment emits no self-pair, reversed lexical insertion - still emits the canonical pair, two fragments colliding in two bands emit - one deduplicated pair, and a bounded three-fragment scenario is independent - of insertion order. +1. **Bounded candidate-generation scenarios use a Kani-specific model.** Four + harnesses check no self-pairs, canonical pair ordering, shared-band + scenarios, and insertion-order independence. In `#[cfg(kani)]` builds, + `candidate_pair_summary_for_kani` compares recorded fragment pairs directly + and emits each pair once when any recorded band matches. These checks do not + execute production bucket enumeration or its cross-band set deduplication. 2. **Production candidate generation remains B-tree backed.** Normal builds still store LSH buckets in `BTreeMap>` @@ -648,12 +644,18 @@ cargo whitaker clones report --in target/whitaker/clones.refined.sarif --html the public runtime API. Existing unit tests and `rstest-bdd` scenarios keep covering that public path. -3. **The Kani proof uses private fixed-size logs.** `#[cfg(kani)]` builds +3. **The Kani model uses private fixed-size logs.** `#[cfg(kani)]` builds compile out production B-tree storage and record inserted fragments in a - four-slot insertion log with compact two-band keys, plus a six-slot bounded - pair log. This keeps the proof focused on the LSH transition and - `CandidatePair::new` policy instead of allocator, destructor, and - tree-balancing internals in the standard library. + four-slot insertion log with compact two-band keys. The separate + `candidate_pair_summary_for_kani` routine checks all recorded fragment pairs + for any shared band, then applies `CandidatePair::new` and records results + in a six-slot pair log. It is a bounded candidate-generation model, not + execution of production `candidate_pairs` or `add_bucket_pairs`; runtime + tests and BDD scenarios cover those production paths. This boundary avoids + allocator, destructor, and tree-balancing internals in the standard library. + The canonical-order harness uses the same `CandidatePair::new` constructor + for its expected pair, so it checks reversal invariance rather than serving + as an independent oracle for the constructor's ordering rule. 4. **Unwind bounds are tied to the proof representation.** The new LSH harnesses use `#[kani::unwind(7)]`, one greater than the six-slot bounded @@ -698,7 +700,24 @@ cargo whitaker clones report --in target/whitaker/clones.refined.sarif --html sibling-order independence for count and production accumulation, leaf normalization, and parser-version drift. Kani verifies bounded AST span-cover selection and bounded AST feature invariants over synthetic - trees. Verus proves the contribution-count accumulator algebra for a + trees. The full-range `KindId` round trip is separate from the five-node + depth-count proof. That harness uses fixed kinds and checks the shared + preorder depth visitor with a four-slot array tally, avoiding production + `BTreeMap` internals while proving the five-node depth counts. Its unwind + bound of six covers five node visits and the terminating loop check; each + node has at most two children, and child iteration fits within that bound. A + scalar proof uses four independent two-bit kind labels for the root, left + subtree, right subtree, and query. Those four values cover every equality + partition and relative ordering among the roles; the proof checks both `Eq` + and `Ord` against the raw `u16` values and contains no loops or maps. + Bounded Kani proofs intentionally leave production `BTreeMap` construction + to runtime tests: both symbolic and fixed-key five-node map experiments + exceeded their ten-minute bounds, with peak memory of 3,381 MiB and 3,915 + MiB respectively. Unit tests and proptest cover sibling-order independence + for the production traversal and `BTreeMap` accumulator. Each clone-detector + harness runs with an 8 GiB process-tree memory cap and ten-minute timeout; + timeout, resource exhaustion, or missing peak measurement is unsuccessful or + inconclusive. Verus proves the contribution-count accumulator algebra for a supplied multiset; it does not claim to verify parser traversal or the production Rust implementation directly. @@ -709,6 +728,18 @@ cargo whitaker clones report --in target/whitaker/clones.refined.sarif --html selected Rust code, and Verus for sidecar proofs with explicit trust boundaries. +7. **Lowering budgets include every emitted normalized node.** The per-candidate + `MAX_AST_NODES` limit counts parser syntax nodes and non-trivia token + leaves; token-heavy macro bodies cannot bypass the allocation bound. The + adapter retains its pruned covering-path traversal, while its coverage and + minimum-width choice use the same parser-independent predicate as the Kani + selector harnesses. + +8. **The parser pin guard accepts one complete exact version.** The build + script's `semver::Version` parse rejects partial `=major` or `=major.minor` + requirements and comma-separated comparators before deriving + `PARSER_SCHEMA_VERSION`. + ## Minimal code skeletons (selected) ### Token to fingerprints to candidates diff --git a/docs/whitaker-dylint-suite-design.md b/docs/whitaker-dylint-suite-design.md index 7ab9e8989..09092e077 100644 --- a/docs/whitaker-dylint-suite-design.md +++ b/docs/whitaker-dylint-suite-design.md @@ -469,16 +469,20 @@ is exactly `doc` (including those injected via `cfg_attr`) mark the module as documented. The attribute parser tolerates spacing after the `#!` marker and traces the first-level `cfg_attr` meta list to find `doc` entries, which avoids substring-based false positives such as `#![allow(undocumented_unsafe_blocks)]` -and misclassifying `documentation = "…"`. If the snippet starts with an inner -attribute marker (`#`) before any doc, the lint emits `FirstInnerIsNotDoc` and -highlights the offending token when a later inner doc exists; if no inner doc -appears at all (including a lone inner attribute), it falls back to -`MissingDocs` so the diagnostic targets the module start. A doc-less `cfg_attr` -wrapper also maps to `MissingDocs`. The shared span helpers from -`whitaker::hir` supply consistent ranges for inline and file modules. Localized -strings pull from `common/locales/*/module_must_have_inner_docs.ftl`, passing -the module name via the Fluent argument map, and fall back to a deterministic -English message whenever localization fails. +and misclassifying `documentation = "…"`. A crate-private meta-list walker +handles nested parentheses and quoted string literals, including escapes and +raw strings; callers supply the identifier predicate for doc recognition or +case-mismatch detection. Reuse stays within this lint's `driver` parser and +does not extend to general Rust tokenization. If the snippet starts with an +inner attribute marker (`#`) before any doc, the lint emits +`FirstInnerIsNotDoc` and highlights the offending token when a later inner doc +exists; if no inner doc appears at all (including a lone inner attribute), it +falls back to `MissingDocs` so the diagnostic targets the module start. A +doc-less `cfg_attr` wrapper also maps to `MissingDocs`. The shared span helpers +from `whitaker::hir` supply consistent ranges for inline and file modules. +Localized strings pull from `common/locales/*/module_must_have_inner_docs.ftl`, +passing the module name via the Fluent argument map, and fall back to a +deterministic English message whenever localization fails. **Testing.** Unit tests (rstest) and `rstest-bdd` scenarios exercise the snippet classifier, covering happy paths, missing docs, inner attributes that @@ -984,8 +988,9 @@ Pair each `.rs` with a `.stderr` expectation using `dylint_testing::ui_test`. Bundle all lint crates for users who prefer a single dynamic library. The `whitaker_suite` crate enables the `constituent` feature on each lint -dependency so their individual dylint entrypoints stay dormant while the -combined pass exposes a single `register_lints` symbol. +dependency so their individual Dylint entrypoints stay dormant. The shared +entrypoint macro exports Dylint's required `register_lints` symbol and forwards +to the suite's internal `register_suite_entry` function.
[package] name = "whitaker_suite" version =
@@ -993,8 +998,9 @@ combined pass exposes a single `register_lints` symbol.
 
 [lib] crate-type = ["cdylib", "rlib"]
 
-[dependencies] dylint_linting = { workspace = true } function_attrs_follow_docs
-= { path = "../crates/function_attrs_follow_docs", features = ["dylint-driver",
+[dependencies] dylint_linting = { workspace = true } whitaker-common = {
+workspace = true } function_attrs_follow_docs = { path =
+"../crates/function_attrs_follow_docs", features = ["dylint-driver",
 "constituent"] } no_expect_outside_tests = { path =
 "../crates/no_expect_outside_tests", features = ["dylint-driver",
 "constituent"] } module_must_have_inner_docs = { path =
@@ -1033,8 +1039,7 @@ declare_combined_late_lint_pass!(SuitePass => [
     NoStdFsOperations,
 ]);
 
-#[no_mangle] pub extern "C" fn register_lints(sess: &Session, store: &mut
-LintStore) {
+fn register_suite_entry(sess: &Session, store: &mut LintStore) {
     dylint_linting::init_config(sess);
     store.register_lints(&[
         function_attrs_follow_docs::FUNCTION_ATTRS_FOLLOW_DOCS,
@@ -1046,7 +1051,10 @@ LintStore) {
         no_std_fs_operations::NO_STD_FS_OPERATIONS,
     ]);
     store.register_late_pass(|_| Box::new(SuitePass));
-} 
+} + +whitaker_common::declare_dylint_register_entry!(register_suite_entry); + Re-export `register_suite_lints` and `suite_lint_decls` so tests and diff --git a/scripts/run-kani-limited.sh b/scripts/run-kani-limited.sh new file mode 100755 index 000000000..58e1d74b9 --- /dev/null +++ b/scripts/run-kani-limited.sh @@ -0,0 +1,147 @@ +#!/usr/bin/env bash +# Run one Kani invocation inside a measured, resource-limited cgroup scope. +# +# Usage: +# scripts/run-kani-limited.sh HARNESS COMMAND [ARGUMENT...] +# +# The per-invocation cgroup is capped at 8 GiB with swap disabled, and GNU +# timeout stops the complete command process group after ten minutes. Limits +# may be lowered for a run through environment variables but never raised. +set -eu + +DEFAULT_MEMORY_MAX_BYTES=8589934592 +DEFAULT_TIMEOUT_SECONDS=600 +MEMORY_MAX_BYTES=${WHITAKER_KANI_MEMORY_MAX_BYTES:-${DEFAULT_MEMORY_MAX_BYTES}} +TIMEOUT_SECONDS=${WHITAKER_KANI_TIMEOUT_SECONDS:-${DEFAULT_TIMEOUT_SECONDS}} + +validate_limit() { + local name=$1 + local value=$2 + local maximum=$3 + + case "${value}" in + ''|*[!0-9]*) + printf '%s must be a positive integer, got %s\n' "${name}" "${value}" >&2 + exit 2 + ;; + esac + if [ "${value}" -eq 0 ] || [ "${value}" -gt "${maximum}" ]; then + printf '%s must be between 1 and %s, got %s\n' \ + "${name}" "${maximum}" "${value}" >&2 + exit 2 + fi +} + +if [ "$(uname -s)" != Linux ]; then + printf 'Kani resource limits require Linux systemd cgroup scopes\n' >&2 + exit 2 +fi +if [ "$#" -lt 2 ]; then + printf 'usage: scripts/run-kani-limited.sh HARNESS COMMAND [ARGUMENT...]\n' >&2 + exit 2 +fi + +validate_limit WHITAKER_KANI_MEMORY_MAX_BYTES \ + "${MEMORY_MAX_BYTES}" "${DEFAULT_MEMORY_MAX_BYTES}" +validate_limit WHITAKER_KANI_TIMEOUT_SECONDS \ + "${TIMEOUT_SECONDS}" "${DEFAULT_TIMEOUT_SECONDS}" + +for command_name in systemd-run systemctl timeout; do + if ! command -v "${command_name}" >/dev/null 2>&1; then + printf 'Kani requires %s to enforce process-tree resource limits\n' \ + "${command_name}" >&2 + exit 2 + fi +done +systemd_version=$(systemd-run --version 2>/dev/null \ + | sed -n '1s/^systemd \([0-9][0-9]*\).*/\1/p') +if [ ! -r /sys/fs/cgroup/cgroup.controllers ] \ + || ! grep -qw memory /sys/fs/cgroup/cgroup.controllers; then + printf 'Kani requires the cgroup v2 memory controller for resource limits\n' >&2 + exit 2 +fi + +harness=$1 +shift +unit_name="whitaker-kani-${$}-$(date +%s%N)" +started_at=$(date +%s) +peak_file=$(mktemp "${TMPDIR:-/tmp}/whitaker-kani-peak.XXXXXX") +trap 'rm -f "${peak_file}"' EXIT +systemd_run_options=( + --user --scope --quiet --unit="${unit_name}" + --property="MemoryMax=${MEMORY_MAX_BYTES}" + --property=MemorySwapMax=0 +) +if [ -n "${systemd_version}" ] && [ "${systemd_version}" -ge 258 ]; then + systemd_run_options=(--expand-environment=no "${systemd_run_options[@]}") +fi +# The child Bash must expand this program after it enters the scoped cgroup. +# Expanding it here would read the parent's cgroup and miss Kani's peak. +# shellcheck disable=SC2016 +scope_command=' +timeout_seconds=$1 +peak_file=$2 +shift 2 +timeout --signal=TERM --kill-after=15s "${timeout_seconds}" "$@" +exit_status=$? +cgroup_path=$(awk -F: '\''$1 == "0" { print $3 }'\'' /proc/self/cgroup) +if [ -z "${cgroup_path}" ] || [ ! -r "/sys/fs/cgroup${cgroup_path}/memory.peak" ]; then + printf "Kani cgroup peak memory is unavailable for %s\\n" "$unit_name" >&2 + exit 125 +fi +cat "/sys/fs/cgroup${cgroup_path}/memory.peak" > "${peak_file}" || exit 125 +exit "${exit_status}" +' +if systemd-run "${systemd_run_options[@]}" -- \ + bash -c "${scope_command}" kani-scope \ + "${TIMEOUT_SECONDS}s" "${peak_file}" "$@" +then + exit_status=0 +else + exit_status=$? +fi +elapsed_seconds=$(($(date +%s) - started_at)) + +scope_details=$(systemctl --user show "${unit_name}.scope" \ + --no-pager -p Result -p MemoryPeak 2>/dev/null || true) +scope_result=$(printf '%s\n' "${scope_details}" \ + | sed -n 's/^Result=//p') +scope_peak_bytes=$(printf '%s\n' "${scope_details}" \ + | sed -n 's/^MemoryPeak=//p') +peak_bytes=$(cat "${peak_file}") +if [ -z "${peak_bytes}" ] && [ -n "${scope_peak_bytes}" ]; then + peak_bytes=${scope_peak_bytes} +fi +case "${peak_bytes}" in + ''|*[!0-9]*) + peak_bytes=unavailable + ;; +esac + +peak_mib=unavailable +if [ "${peak_bytes}" != unavailable ]; then + peak_mib=$((peak_bytes / 1048576)) +fi +printf 'Kani resources: harness=%s exit_status=%s duration_seconds=%s peak_memory_bytes=%s peak_memory_mib=%s memory_cap_bytes=%s timeout_seconds=%s scope_result=%s\n' \ + "${harness}" "${exit_status}" "${elapsed_seconds}" \ + "${peak_bytes}" "${peak_mib}" \ + "${MEMORY_MAX_BYTES}" "${TIMEOUT_SECONDS}" "${scope_result:-unknown}" +if [ "${scope_result}" = oom-kill ]; then + printf 'Kani exceeded the process-tree memory cap for %s; proof is inconclusive\n' \ + "${harness}" >&2 + exit 1 +fi +if [ "${peak_bytes}" = unavailable ]; then + printf 'Kani could not report peak memory for %s; proof is inconclusive\n' \ + "${harness}" >&2 + exit 1 +fi +if [ "${exit_status}" -eq 124 ]; then + printf 'Kani timed out for %s; proof is inconclusive\n' "${harness}" >&2 + exit "${exit_status}" +fi +if [ "${exit_status}" -ne 0 ]; then + printf 'Kani failed for %s (possibly a resource limit); no proof pass recorded\n' \ + "${harness}" >&2 + exit "${exit_status}" +fi diff --git a/scripts/run-kani.sh b/scripts/run-kani.sh index 22b7abb69..cef7aadbc 100755 --- a/scripts/run-kani.sh +++ b/scripts/run-kani.sh @@ -4,12 +4,16 @@ # # Usage: # scripts/run-kani.sh -# scripts/run-kani.sh clone-detector [EXTRA_KANI_FLAGS...] +# scripts/run-kani.sh clone-detector [HARNESS_FILTER] [EXTRA_KANI_FLAGS...] +# scripts/run-kani.sh clone-detector-harness HARNESS # scripts/run-kani.sh decomposition [HARNESS_FILTER] # scripts/run-kani.sh [HARNESS_FILTER] # # Arguments: -# clone-detector Run the explicit clone-detector harness list. +# clone-detector Run the explicit clone-detector harness list, optionally +# restricted to one harness name. +# clone-detector-harness +# Run exactly one clone-detector harness. # decomposition Run the existing decomposition/common harnesses. # HARNESS_FILTER When no group is given, treat the first positional # argument as a decomposition/common harness filter. @@ -20,11 +24,12 @@ # cargo-kani. set -eu -SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) -REPO_ROOT=$(CDPATH= cd -- "${SCRIPT_DIR}/.." && pwd) +SCRIPT_DIR=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd) +REPO_ROOT=$(CDPATH='' cd -- "${SCRIPT_DIR}/.." && pwd) KANI_INSTALL_DIR=$("${SCRIPT_DIR}/install-kani.sh") CARGO_KANI_BIN="${KANI_INSTALL_DIR}/bin/cargo-kani" CLONE_DETECTOR_MANIFEST="${REPO_ROOT}/crates/whitaker_clones_core/Cargo.toml" +KANI_LIMITED_RUNNER="${SCRIPT_DIR}/run-kani-limited.sh" # kani-compiler is dynamically linked against the toolchain's libLLVM. # On macOS use DYLD_LIBRARY_PATH, on Linux use LD_LIBRARY_PATH. @@ -43,40 +48,58 @@ export RUSTUP_TOOLCHAIN="${TOOLCHAIN}" export CC="${CC:-gcc}" run_clone_detector_harnesses() { + local harness_filter="" + local found_filter=false + + if [ "$#" -gt 0 ] && [[ "$1" != -* ]]; then + harness_filter=$1 + shift + fi + for harness in \ verify_lsh_config_new_smoke \ verify_lsh_config_new_symbolic \ verify_lsh_config_new_overflow_product \ verify_min_hasher_sketch_rejects_empty_input \ - verify_min_hasher_sketch_is_deterministic \ - verify_min_hasher_sketch_ignores_duplicate_hashes \ + verify_min_hasher_lane_minimum_for_boundary_hashes \ + verify_min_hasher_unique_hashes_sorts_and_deduplicates \ verify_lsh_index_rejects_self_pairs \ verify_lsh_index_canonicalizes_pair_order \ verify_lsh_index_deduplicates_repeated_band_collisions \ verify_lsh_index_is_insertion_order_independent \ verify_smallest_covering_node_selects_minimal_range \ - verify_smallest_covering_root_fallback \ - verify_kind_index_is_bounded \ - verify_count_accumulation_is_order_independent_bounded + verify_missing_cover_returns_none \ + verify_kind_id_round_trips_full_u16_domain \ + verify_kind_counts_stop_at_max_depth \ + verify_kind_id_equality_and_order_cover_small_alphabet do + if [ -n "${harness_filter}" ] && [ "${harness}" != "${harness_filter}" ]; then + continue + fi + found_filter=true # Keep the parser feature off for every clone-detector harness, including # verify_lsh_index_rejects_self_pairs: Kani's pinned Rust cannot satisfy # ra_ap_syntax's MSRV. - "${CARGO_KANI_BIN}" kani \ + "${KANI_LIMITED_RUNNER}" "${harness}" "${CARGO_KANI_BIN}" kani \ --manifest-path "${CLONE_DETECTOR_MANIFEST}" \ --no-default-features \ --default-unwind 4 \ --harness "${harness}" \ "$@" done + + if [ -n "${harness_filter}" ] && [ "${found_filter}" != true ]; then + printf 'unknown clone-detector Kani harness: %s\n' "${harness_filter}" >&2 + return 2 + fi } run_decomposition_harnesses() { cd "${REPO_ROOT}/common" if [ $# -gt 0 ]; then - "${CARGO_KANI_BIN}" kani --harness "$1" + "${KANI_LIMITED_RUNNER}" "$1" "${CARGO_KANI_BIN}" kani --harness "$1" else - "${CARGO_KANI_BIN}" kani + "${KANI_LIMITED_RUNNER}" decomposition-all "${CARGO_KANI_BIN}" kani fi } @@ -95,6 +118,14 @@ case "$1" in shift run_clone_detector_harnesses "$@" ;; + clone-detector-harness) + shift + if [ "$#" -ne 1 ]; then + printf 'usage: scripts/run-kani.sh clone-detector-harness HARNESS\n' >&2 + exit 2 + fi + run_clone_detector_harnesses "$1" + ;; *) run_decomposition_harnesses "$@" ;; diff --git a/src/testing/ui/mod.rs b/src/testing/ui/mod.rs index 9172e1189..453394bb3 100644 --- a/src/testing/ui/mod.rs +++ b/src/testing/ui/mod.rs @@ -6,8 +6,6 @@ //! This module centralizes input validation so lint crates can depend on a //! small helper rather than repeat the same checks. -#[cfg(windows)] -use std::env; use std::fmt; use camino::{Utf8Path, Utf8PathBuf}; @@ -15,8 +13,6 @@ use camino::{Utf8Path, Utf8PathBuf}; mod toolchain; use self::toolchain::{CrateName, ensure_toolchain_library}; -#[cfg(windows)] -use whitaker_common::test_support::env_test_guard; /// Errors produced when preparing or executing Dylint UI tests. #[derive(Clone, Debug, Eq, PartialEq)] @@ -163,18 +159,19 @@ pub fn run_with_runner( let crate_name_owned = CrateName::try_from(crate_name).map_err(|_| HarnessError::EmptyCrateName)?; - let crate_name_str = crate_name_owned.as_str(); - let _runner_env_guard = runner_env_guard(); - ensure_toolchain_library(&crate_name_owned)?; + let crate_name_str = crate_name_owned.as_str().to_owned(); + with_runner_env(|| { + ensure_toolchain_library(&crate_name_owned)?; - match runner(crate_name_str, directory.as_ref()) { - Ok(()) => Ok(()), - Err(message) => Err(HarnessError::RunnerFailure { - crate_name: crate_name_owned.into_inner(), - directory, - message, - }), - } + match runner(&crate_name_str, directory.as_ref()) { + Ok(()) => Ok(()), + Err(message) => Err(HarnessError::RunnerFailure { + crate_name: crate_name_owned.into_inner(), + directory, + message, + }), + } + }) } /// Runs a UI test and reports runner failures using the standard panic format. @@ -207,65 +204,41 @@ pub fn run_ui_test( }); } -/// Serializes environment mutations required by `run_with_runner`. +/// Runs `callback` with the environment `run_with_runner` requires. /// /// On Windows, one additional environment variable needs temporary adjustment: /// /// - `VCPKG_ROOT`: must be set to `C:\vcpkg` when that directory exists and the /// variable is otherwise absent, so downstream `cargo` invocations resolve vcpkg. /// -/// Each mutation and restoration step acquires `env_test_guard()` only for the -/// environment write itself. The guard deliberately does not hold that mutex -/// across the UI runner callback, because runner closures can perform their -/// own environment-guarded setup. -struct RunnerEnvGuard { - #[cfg(windows)] - vcpkg_root_was_absent: bool, -} - -impl Drop for RunnerEnvGuard { - /// Restore temporary environment changes when the runner finishes. - fn drop(&mut self) { - #[cfg(windows)] - { - let _env_guard = env_test_guard(); - - // SAFETY: `env_test_guard` serializes the restoration write below. - if self.vcpkg_root_was_absent { - unsafe { - env::remove_var("VCPKG_ROOT"); - } - } - } - } +/// The runner preserves `RUSTC_WRAPPER` so Cargo continues to use the configured +/// compiler-admission and cache wrappers. +/// +/// The scoped mutations are serialized by `temp_env`'s re-entrant global lock, +/// so runner closures that perform their own scoped environment setup nest +/// without deadlocking, and every prior value is restored when the callback +/// returns or panics. +fn with_runner_env(callback: impl FnOnce() -> R) -> R { + with_vcpkg_root(callback) } -/// Prepare any platform-specific environment required by the UI runner. +/// Add `VCPKG_ROOT` for runners that need a local Windows vcpkg installation. #[cfg(windows)] -fn runner_env_guard() -> Option { - let vcpkg_candidate = Utf8Path::new(r"C:\vcpkg"); - let vcpkg_applicable = vcpkg_candidate.is_dir(); +fn with_vcpkg_root(callback: impl FnOnce() -> R) -> R { + use whitaker_common::test_support::with_env_var; - if !vcpkg_applicable { - return None; - } - let _env_guard = env_test_guard(); - if env::var_os("VCPKG_ROOT").is_some() { - return None; + let candidate = Utf8Path::new(r"C:\vcpkg"); + if candidate.is_dir() && std::env::var_os("VCPKG_ROOT").is_none() { + with_env_var("VCPKG_ROOT", candidate.as_std_path(), callback) + } else { + callback() } - // SAFETY: `_env_guard` serializes concurrent environment mutations. - unsafe { - env::set_var("VCPKG_ROOT", vcpkg_candidate.as_std_path()); - } - Some(RunnerEnvGuard { - vcpkg_root_was_absent: true, - }) } -/// Prepare any platform-specific environment required by the UI runner. +/// Run the callback unchanged on platforms without a vcpkg environment. #[cfg(not(windows))] -const fn runner_env_guard() -> Option { - None +fn with_vcpkg_root(callback: impl FnOnce() -> R) -> R { + callback() } fn directory_is_rooted(path: &Utf8Path) -> bool { diff --git a/src/testing/ui/tests.rs b/src/testing/ui/tests.rs index ee5b0885b..293e46d5a 100644 --- a/src/testing/ui/tests.rs +++ b/src/testing/ui/tests.rs @@ -4,7 +4,9 @@ use super::{HarnessError, run_ui_test, run_with_runner}; use camino::{Utf8Path, Utf8PathBuf}; use rstest::rstest; use std::env; -use whitaker_common::test_support::{EnvTestGuard, EnvVarGuard, env_test_guard}; +#[cfg(windows)] +use whitaker_common::test_support::with_env_var_removed; +use whitaker_common::test_support::{EnvTestGuard, env_test_guard, with_env_var}; #[rstest] #[case( @@ -115,31 +117,32 @@ fn run_ui_test_panics_with_context_from_runner_failure() { #[test] fn runner_env_guard_preserves_rustc_wrapper() { let _serial_guard = runner_env_guard_test_lock(); - let _guard = EnvVarGuard::set("RUSTC_WRAPPER", "sccache"); + with_env_var("RUSTC_WRAPPER", "sccache", || { + run_with_runner("lint", "ui", |_, _| { + assert_eq!(env::var_os("RUSTC_WRAPPER"), Some("sccache".into())); + Ok(()) + }) + .expect("runner should execute with RUSTC_WRAPPER preserved"); - run_with_runner("lint", "ui", |_, _| { assert_eq!(env::var_os("RUSTC_WRAPPER"), Some("sccache".into())); - Ok(()) - }) - .expect("runner should execute with RUSTC_WRAPPER preserved"); - - assert_eq!(env::var_os("RUSTC_WRAPPER"), Some("sccache".into())); + }); } #[cfg(windows)] #[test] fn windows_env_guard_leaves_absent_rustc_wrapper_untouched() { let _serial_guard = runner_env_guard_test_lock(); - let _vcpkg_root = EnvVarGuard::set("VCPKG_ROOT", r"C:\vcpkg"); - let _rustc_wrapper = EnvVarGuard::remove("RUSTC_WRAPPER"); - - run_with_runner("lint", "ui", |_, _| { - assert_eq!(env::var_os("RUSTC_WRAPPER"), None); - Ok(()) - }) - .expect("runner should execute without installing RUSTC_WRAPPER"); - - assert_eq!(env::var_os("RUSTC_WRAPPER"), None); + with_env_var("VCPKG_ROOT", r"C:\vcpkg", || { + with_env_var_removed("RUSTC_WRAPPER", || { + run_with_runner("lint", "ui", |_, _| { + assert_eq!(env::var_os("RUSTC_WRAPPER"), None); + Ok(()) + }) + .expect("runner should execute without installing RUSTC_WRAPPER"); + + assert_eq!(env::var_os("RUSTC_WRAPPER"), None); + }); + }); } #[test] diff --git a/suite/Cargo.toml b/suite/Cargo.toml index 5623c642d..ab926939d 100644 --- a/suite/Cargo.toml +++ b/suite/Cargo.toml @@ -32,6 +32,7 @@ experimental-rstest-helper-should-be-fixture = [ ] [dependencies] +whitaker-common = { workspace = true } dylint_linting = { workspace = true, optional = true } rustc_lint = { workspace = true, optional = true } rustc_session = { workspace = true, optional = true } @@ -49,6 +50,8 @@ bumpy_road_function = { path = "../crates/bumpy_road_function", optional = true, rstest_helper_should_be_fixture = { path = "../crates/rstest_helper_should_be_fixture", optional = true, features = ["dylint-driver", "constituent"] } [dev-dependencies] +dylint_testing = { workspace = true } +whitaker = { workspace = true } whitaker_test_macros = { workspace = true } rstest = { workspace = true } rstest-bdd = { workspace = true } diff --git a/suite/src/driver.rs b/suite/src/driver.rs index 49a862276..7ee02fd48 100644 --- a/suite/src/driver.rs +++ b/suite/src/driver.rs @@ -80,19 +80,14 @@ pub fn register_suite_lints(store: &mut LintStore) { /// assert!(names.contains(&"no_unwrap_or_else_panic".to_string())); /// ``` #[must_use] -pub fn suite_lint_decls() -> &'static [&'static Lint] { +pub const fn suite_lint_decls() -> &'static [&'static Lint] { SUITE_LINT_DECLS } -/// Dylint entrypoint that initializes configuration and registers lints. -/// -/// # Safety -/// -/// Callers must pass non-null, correctly initialized `Session` and -/// `LintStore` references from the host compiler context that remain valid on -/// this thread for the duration of the call. -#[unsafe(no_mangle)] -pub unsafe extern "C" fn register_lints(sess: &Session, store: &mut LintStore) { +/// Initializes Dylint configuration then registers the suite lints. +fn register_suite_entry(sess: &Session, store: &mut LintStore) { dylint_linting::init_config(sess); register_suite_lints(store); } + +whitaker_common::declare_dylint_register_entry!(register_suite_entry); diff --git a/suite/tests/registration.rs b/suite/tests/registration.rs index 2c1f78865..9e5e1bfae 100644 --- a/suite/tests/registration.rs +++ b/suite/tests/registration.rs @@ -123,3 +123,10 @@ fn scenario_registers_cleanly(world: RegistrationWorld) { fn scenario_double_registration(world: RegistrationWorld) { let _ = world; } + +/// Verify that Dylint loads the suite through its exported `register_lints` entry point. +#[test] +fn exported_entry_point_registers_suite_lints() { + whitaker::run_ui_tests!("tests/ui") + .expect("Dylint should load the suite and satisfy its registered lint expectation"); +} diff --git a/suite/tests/ui/register_suite_entry.rs b/suite/tests/ui/register_suite_entry.rs new file mode 100644 index 000000000..c2f3b948f --- /dev/null +++ b/suite/tests/ui/register_suite_entry.rs @@ -0,0 +1,6 @@ +//! Verify that loading the suite exposes its inner-doc lint. + +mod undocumented {} + +/// Provide the binary entry point expected by the Dylint UI harness. +fn main() {} diff --git a/suite/tests/ui/register_suite_entry.stderr b/suite/tests/ui/register_suite_entry.stderr new file mode 100644 index 000000000..93173b521 --- /dev/null +++ b/suite/tests/ui/register_suite_entry.stderr @@ -0,0 +1,16 @@ +warning: Module undocumented must start with an inner doc comment. + --> $DIR/register_suite_entry.rs:3:19 + | +LL | mod undocumented {} + | ^ + | +note: The first item in the module is not a `//!` style comment. + --> $DIR/register_suite_entry.rs:3:1 + | +LL | mod undocumented {} + | ^^^^^^^^^^^^^^^^ + = help: Explain the purpose of undocumented by adding an inner doc comment at the top. + = note: `#[warn(module_must_have_inner_docs)]` on by default + +warning: 1 warning emitted + diff --git a/tests/config_loading.rs b/tests/config_loading.rs index aac92dcd0..de13d5af3 100644 --- a/tests/config_loading.rs +++ b/tests/config_loading.rs @@ -135,7 +135,10 @@ fn load_config( let maybe_source = config_source.borrow().clone(); let outcome = catch_unwind(AssertUnwindSafe(|| { SharedConfig::load_with("module_max_lines", |crate_name| { - assert_eq!(crate_name, "module_max_lines"); + assert_eq!( + crate_name, "module_max_lines", + "the loader should request configuration for the requested lint", + ); maybe_source .as_ref() .map_or_else(SharedConfig::default, |input| { diff --git a/tests/ui_harness.rs b/tests/ui_harness.rs index 27d210828..2745abd3b 100644 --- a/tests/ui_harness.rs +++ b/tests/ui_harness.rs @@ -141,7 +141,11 @@ fn assert_absolute_error(harness_world: &HarnessWorld, path: String) { let borrow = harness_world.harness_result.borrow(); match borrow.as_ref() { Some(Err(HarnessError::AbsoluteDirectory { directory })) => { - assert_eq!(directory, &Utf8PathBuf::from(path)); + assert_eq!( + directory, + &Utf8PathBuf::from(path), + "the rejected directory should be reported verbatim", + ); } Some(Ok(())) => panic!("expected an error but harness succeeded"), Some(Err(error)) => panic!("expected an absolute directory error, found {error}"), @@ -155,7 +159,10 @@ fn assert_runner_failure(harness_world: &HarnessWorld, snippet: StepString) { let snippet_value = snippet.into_inner(); match borrow.as_ref() { Some(Err(HarnessError::RunnerFailure { message, .. })) => { - assert!(message.contains(snippet_value.as_str())); + assert!( + message.contains(snippet_value.as_str()), + "runner failure should mention {snippet_value}, got: {message}", + ); } Some(Ok(())) => panic!("expected an error but harness succeeded"), Some(Err(error)) => panic!("expected a runner failure error, found {error}"), diff --git a/tests/workflow_contracts/compile_contract_allowance_test.py b/tests/workflow_contracts/compile_contract_allowance_test.py index 71b1a81a9..0a3c10fec 100644 --- a/tests/workflow_contracts/compile_contract_allowance_test.py +++ b/tests/workflow_contracts/compile_contract_allowance_test.py @@ -22,12 +22,13 @@ import pytest from nextest_profile_test import REQUIRED_OVERRIDES +from nextest_durations import seconds from rust_functions import compile_contracts_in from nextest_config import load_nextest_config from ubicloud_workflow_support import REPOSITORY_ROOT -#: Bound for compile-contract harnesses under shared compiler admission. -COMPILE_CONTRACT_TIMEOUT: typ.Final[str] = "45m" +#: Minimum allowance for compile-contract harnesses under shared admission. +COMPILE_CONTRACT_MINIMUM_TIMEOUT: typ.Final[str] = "20m" class RustSourceError(OSError): @@ -204,11 +205,13 @@ def test_every_compile_contract_test_carries_the_long_allowance( # override names no test whatever its allowance. An empty string # matches no `test(...)` clause below, which is the same answer a # crash would have hidden. - long_filters = [ - override.get("filter", "") - for override in (parsed_nextest["profile"][profile].get("overrides") or []) - if _period(override.get("slow-timeout")) == COMPILE_CONTRACT_TIMEOUT - ] + long_filters = [] + for override in parsed_nextest["profile"][profile].get("overrides") or []: + period = _period(override.get("slow-timeout")) + if period is not None and seconds(period) >= seconds( + COMPILE_CONTRACT_MINIMUM_TIMEOUT + ): + long_filters.append(override.get("filter", "")) for name, path in sorted(discovered.items()): assert any(f"test({name})" in one for one in long_filters), ( f"[profile.{profile}] grants no long allowance to the compile " diff --git a/tests/workflow_contracts/kani_runner_contract_test.py b/tests/workflow_contracts/kani_runner_contract_test.py new file mode 100644 index 000000000..668b1e42d --- /dev/null +++ b/tests/workflow_contracts/kani_runner_contract_test.py @@ -0,0 +1,367 @@ +"""Test the Kani resource wrapper and harness-selection boundary.""" + +from __future__ import annotations + +import os +import subprocess +import typing as typ +from pathlib import Path + +import pytest + +REPOSITORY_ROOT = Path(__file__).resolve().parents[2] +RUN_KANI = REPOSITORY_ROOT / "scripts" / "run-kani.sh" +RUN_KANI_LIMITED = REPOSITORY_ROOT / "scripts" / "run-kani-limited.sh" + + +class _PreflightCase(typ.NamedTuple): + """Describe one limited-runner preflight rejection scenario.""" + + runner_arguments: tuple[str, ...] + environment: dict[str, str] + missing_tools: frozenset[str] + platform: str + grep_status: int | None + error_fragment: str + + +def _write_executable(path: Path, contents: str) -> None: + """Write an executable shell stub.""" + path.write_text(contents, encoding="utf-8") + path.chmod(0o755) + + +def _write_limited_stubs( + directory: Path, + *, + missing_tools: frozenset[str] = frozenset(), + platform: str = "Linux", + grep_status: int | None = None, +) -> None: + """Create controlled command stubs for the limited runner.""" + directory.mkdir(parents=True, exist_ok=True) + _write_executable(directory / "uname", f"#!/bin/sh\nprintf '%s\\n' '{platform}'\n") + if "systemd-run" not in missing_tools: + _write_executable( + directory / "systemd-run", + """#!/bin/bash +if [[ ${1:-} == --version ]]; then + printf '%s\\n' "${FAKE_SYSTEMD_VERSION:-systemd 257}" + exit 0 +fi +printf '%s\\n' "$*" >> "$SYSTEMD_RUN_LOG" +while [[ $# -gt 0 && $1 != -- ]]; do shift; done +[[ $# -gt 0 ]] || exit 2 +shift +exec "$@" +""", + ) + if "systemctl" not in missing_tools: + _write_executable( + directory / "systemctl", + """#!/bin/bash +printf 'Result=%s\\n' "${FAKE_SCOPE_RESULT:-success}" +printf 'MemoryPeak=%s\\n' "${FAKE_SCOPE_PEAK:-}" +""", + ) + if "timeout" not in missing_tools: + _write_executable(directory / "timeout", "#!/bin/sh\nexec /usr/bin/timeout \"$@\"\n") + _write_executable( + directory / "cat", + """#!/bin/bash +case ${1:-} in + */memory.peak) + if [[ ${FAKE_PEAK_MODE:-value} == unavailable ]]; then exit 1; fi + printf '%s\\n' "${FAKE_PEAK_BYTES:-1048576}" + ;; + *) exec /usr/bin/cat "$@" ;; +esac +""", + ) + if grep_status is not None: + _write_executable(directory / "grep", f"#!/bin/sh\nexit {grep_status}\n") + _write_executable(directory / "sed", "#!/bin/sh\nprintf '257\\n'\n") + + +def _limited_environment( + tmp_path: Path, + bin_dir: Path, + overrides: dict[str, str] | None = None, + *, + include_host_path: bool = True, +) -> dict[str, str]: + """Build an isolated environment for the limited runner.""" + environment = os.environ.copy() + for name in ( + "WHITAKER_KANI_MEMORY_MAX_BYTES", + "WHITAKER_KANI_TIMEOUT_SECONDS", + "FAKE_SCOPE_RESULT", + "FAKE_SCOPE_PEAK", + "FAKE_PEAK_MODE", + "FAKE_SYSTEMD_VERSION", + ): + environment.pop(name, None) + system_path = environment.get("PATH", "/usr/bin:/bin") + environment["PATH"] = ( + f"{bin_dir}{os.pathsep}{system_path}" if include_host_path else str(bin_dir) + ) + environment["TMPDIR"] = str(tmp_path) + environment["SYSTEMD_RUN_LOG"] = str(tmp_path / "systemd-run.log") + if overrides: + environment.update(overrides) + return environment + + +def _run_limited( + tmp_path: Path, + command: tuple[str, ...] = ("/bin/bash", "-c", "true"), + overrides: dict[str, str] | None = None, +) -> subprocess.CompletedProcess[str]: + """Run the actual limited-runner script with controlled systemd commands.""" + bin_dir = tmp_path / "bin" + _write_limited_stubs(bin_dir) + environment = _limited_environment(tmp_path, bin_dir, overrides) + return subprocess.run( + ["/bin/bash", str(RUN_KANI_LIMITED), "test_harness", *command], + cwd=REPOSITORY_ROOT, + env=environment, + capture_output=True, + check=False, + text=True, + timeout=20, + ) + + +def _has_readable_process_cgroup_peak() -> bool: + """Report whether the current Linux cgroup exposes its peak file.""" + cgroup_file = Path("/proc/self/cgroup") + controller_file = Path("/sys/fs/cgroup/cgroup.controllers") + if not cgroup_file.is_file() or not controller_file.is_file(): + return False + cgroup_path = next( + ( + entry.split("::", maxsplit=1)[1] + for entry in cgroup_file.read_text(encoding="utf-8").splitlines() + if entry.startswith("0::") + ), + None, + ) + if cgroup_path is None: + return False + peak_file = Path("/sys/fs/cgroup", cgroup_path.lstrip("/"), "memory.peak") + return peak_file.is_file() and os.access(peak_file, os.R_OK) + + +@pytest.mark.parametrize( + "case", + [ + pytest.param( + _PreflightCase( + ("test_harness", "/bin/true"), + {"WHITAKER_KANI_MEMORY_MAX_BYTES": "0"}, + frozenset(), + "Linux", + None, + "WHITAKER_KANI_MEMORY_MAX_BYTES", + ), + id="memory-limit-zero", + ), + pytest.param( + _PreflightCase( + ("test_harness", "/bin/true"), + {"WHITAKER_KANI_MEMORY_MAX_BYTES": "8589934593"}, + frozenset(), + "Linux", + None, + "WHITAKER_KANI_MEMORY_MAX_BYTES", + ), + id="memory-limit-above-cap", + ), + pytest.param( + _PreflightCase( + ("test_harness", "/bin/true"), + {"WHITAKER_KANI_TIMEOUT_SECONDS": "not-a-number"}, + frozenset(), + "Linux", + None, + "WHITAKER_KANI_TIMEOUT_SECONDS", + ), + id="timeout-not-numeric", + ), + pytest.param( + _PreflightCase( + ("test_harness", "/bin/true"), + {"WHITAKER_KANI_TIMEOUT_SECONDS": "601"}, + frozenset(), + "Linux", + None, + "WHITAKER_KANI_TIMEOUT_SECONDS", + ), + id="timeout-above-cap", + ), + pytest.param( + _PreflightCase( + ("test_harness", "/bin/true"), + {}, + frozenset(), + "Darwin", + None, + "require Linux", + ), + id="unsupported-platform", + ), + pytest.param( + _PreflightCase( + (), + {}, + frozenset(), + "Linux", + None, + "usage: scripts/run-kani-limited.sh", + ), + id="missing-harness", + ), + pytest.param( + _PreflightCase( + ("test_harness",), + {}, + frozenset(), + "Linux", + None, + "usage: scripts/run-kani-limited.sh", + ), + id="missing-command", + ), + pytest.param( + _PreflightCase( + ("test_harness", "/bin/true"), + {}, + frozenset({"systemd-run"}), + "Linux", + None, + "requires systemd-run", + ), + id="missing-systemd-run", + ), + pytest.param( + _PreflightCase( + ("test_harness", "/bin/true"), + {}, + frozenset({"systemctl"}), + "Linux", + None, + "requires systemctl", + ), + id="missing-systemctl", + ), + pytest.param( + _PreflightCase( + ("test_harness", "/bin/true"), + {}, + frozenset({"timeout"}), + "Linux", + None, + "requires timeout", + ), + id="missing-timeout", + ), + pytest.param( + _PreflightCase( + ("test_harness", "/bin/true"), + {}, + frozenset(), + "Linux", + 1, + "cgroup v2 memory controller", + ), + id="missing-cgroup-memory-controller", + ), + ], +) +def test_limited_runner_rejects_preflight_failures( + tmp_path: Path, case: _PreflightCase +) -> None: + """Reject invalid wrapper settings and unavailable host prerequisites.""" + bin_dir = tmp_path / "bin" + _write_limited_stubs( + bin_dir, + missing_tools=case.missing_tools, + platform=case.platform, + grep_status=case.grep_status, + ) + result = subprocess.run( + ["/bin/bash", str(RUN_KANI_LIMITED), *case.runner_arguments], + cwd=REPOSITORY_ROOT, + env=_limited_environment( + tmp_path, + bin_dir, + case.environment, + include_host_path=False, + ), + capture_output=True, + check=False, + text=True, + ) + + assert result.returncode == 2 + assert case.error_fragment in result.stderr + + +@pytest.mark.parametrize(("version", "option_expected"), [("systemd 257", False), ("systemd 258", True)]) +def test_limited_runner_controls_systemd_environment_expansion( + tmp_path: Path, version: str, option_expected: bool +) -> None: + """Disable command expansion only on systemd versions that support it.""" + if not _has_readable_process_cgroup_peak(): + pytest.skip("the test host does not expose readable cgroup v2 peak memory") + result = _run_limited(tmp_path, overrides={"FAKE_SYSTEMD_VERSION": version}) + invocation = (tmp_path / "systemd-run.log").read_text(encoding="utf-8") + + assert result.returncode == 0, result.stderr + assert ("--expand-environment=no" in invocation) is option_expected + + +def test_limited_runner_propagates_timeout_status(tmp_path: Path) -> None: + """Return GNU timeout's status when a proof exceeds its time budget.""" + if not _has_readable_process_cgroup_peak(): + pytest.skip("the test host does not expose readable cgroup v2 peak memory") + result = _run_limited( + tmp_path, + command=("/bin/bash", "-c", "sleep 3"), + overrides={"WHITAKER_KANI_TIMEOUT_SECONDS": "1"}, + ) + + assert result.returncode == 124 + assert "Kani timed out for test_harness" in result.stderr + + +def test_limited_runner_propagates_command_failure(tmp_path: Path) -> None: + """Return a proof command's non-zero status without recording a pass.""" + if not _has_readable_process_cgroup_peak(): + pytest.skip("the test host does not expose readable cgroup v2 peak memory") + result = _run_limited(tmp_path, command=("/bin/bash", "-c", "exit 23")) + + assert result.returncode == 23 + assert "exit_status=23" in result.stdout + + +def test_limited_runner_fails_closed_after_an_oom_kill(tmp_path: Path) -> None: + """Report an OOM-killed scope as an inconclusive proof.""" + if not _has_readable_process_cgroup_peak(): + pytest.skip("the test host does not expose readable cgroup v2 peak memory") + result = _run_limited(tmp_path, overrides={"FAKE_SCOPE_RESULT": "oom-kill"}) + + assert result.returncode == 1 + assert "process-tree memory cap" in result.stderr + + +def test_limited_runner_fails_closed_without_peak_memory(tmp_path: Path) -> None: + """Report missing peak-memory data as an inconclusive proof.""" + if not _has_readable_process_cgroup_peak(): + pytest.skip("the test host does not expose readable cgroup v2 peak memory") + result = _run_limited(tmp_path, overrides={"FAKE_PEAK_MODE": "unavailable"}) + + assert result.returncode == 1 + assert "could not report peak memory" in result.stderr + + diff --git a/tests/workflow_contracts/kani_selection_contract_test.py b/tests/workflow_contracts/kani_selection_contract_test.py new file mode 100644 index 000000000..3d7184c78 --- /dev/null +++ b/tests/workflow_contracts/kani_selection_contract_test.py @@ -0,0 +1,99 @@ +"""Test Kani harness selection without installing the verifier.""" + +from __future__ import annotations + +import os +import subprocess +from pathlib import Path + +REPOSITORY_ROOT = Path(__file__).resolve().parents[2] +RUN_KANI = REPOSITORY_ROOT / "scripts" / "run-kani.sh" + + +def _write_executable(path: Path, contents: str) -> None: + """Write an executable shell stub.""" + path.write_text(contents, encoding="utf-8") + path.chmod(0o755) + + +def _run_kani_selection( + tmp_path: Path, arguments: tuple[str, ...], runner_exit: str = "0" +) -> subprocess.CompletedProcess[str]: + """Run harness selection against stubbed Kani installer and runner commands.""" + script_directory = tmp_path / "scripts" + script_directory.mkdir() + kani_directory = tmp_path / "kani" + (kani_directory / "bin").mkdir(parents=True) + (kani_directory / "toolchain" / "bin").mkdir(parents=True) + (kani_directory / "rust-toolchain-version").write_text("nightly-test\n", encoding="utf-8") + _write_executable(script_directory / "run-kani.sh", RUN_KANI.read_text(encoding="utf-8")) + _write_executable( + script_directory / "install-kani.sh", + "#!/bin/sh\nprintf '%s\\n' \"$FAKE_KANI_DIR\"\n", + ) + capture = tmp_path / "runner-arguments.log" + _write_executable( + script_directory / "run-kani-limited.sh", + """#!/bin/bash +printf '%s\\n' "$*" >> "$KANI_CAPTURE_FILE" +exit "${KANI_STUB_EXIT:-0}" +""", + ) + (kani_directory / "bin" / "cargo-kani").write_text("", encoding="utf-8") + environment = os.environ.copy() + environment.update( + { + "FAKE_KANI_DIR": str(kani_directory), + "KANI_CAPTURE_FILE": str(capture), + "KANI_STUB_EXIT": runner_exit, + } + ) + return subprocess.run( + ["/bin/bash", str(script_directory / "run-kani.sh"), *arguments], + cwd=REPOSITORY_ROOT, + env=environment, + capture_output=True, + check=False, + text=True, + ) + + +def test_kani_runner_selects_only_the_requested_harness(tmp_path: Path) -> None: + """Pass one known clone-detector harness to the limited runner.""" + result = _run_kani_selection( + tmp_path, ("clone-detector", "verify_missing_cover_returns_none") + ) + capture = tmp_path / "runner-arguments.log" + arguments = capture.read_text(encoding="utf-8").splitlines() + + assert result.returncode == 0, result.stderr + assert len(arguments) == 1 + assert arguments[0].startswith("verify_missing_cover_returns_none ") + + +def test_kani_runner_rejects_unknown_harnesses(tmp_path: Path) -> None: + """Reject a harness absent from the declared clone-detector group.""" + result = _run_kani_selection(tmp_path, ("clone-detector", "verify_not_a_harness")) + + assert result.returncode == 2 + assert "unknown clone-detector Kani harness" in result.stderr + assert not (tmp_path / "runner-arguments.log").exists() + + +def test_kani_runner_rejects_invalid_harness_command_arguments(tmp_path: Path) -> None: + """Require exactly one harness with the explicit single-harness command.""" + result = _run_kani_selection(tmp_path, ("clone-detector-harness",)) + + assert result.returncode == 2 + assert "usage: scripts/run-kani.sh clone-detector-harness HARNESS" in result.stderr + + +def test_kani_runner_propagates_limited_runner_failure(tmp_path: Path) -> None: + """Propagate a failed limited-runner status through harness selection.""" + result = _run_kani_selection( + tmp_path, + ("clone-detector-harness", "verify_missing_cover_returns_none"), + runner_exit="37", + ) + + assert result.returncode == 37 diff --git a/tests/workflow_contracts/nextest_profile_test.py b/tests/workflow_contracts/nextest_profile_test.py index c064877a9..f2057110d 100644 --- a/tests/workflow_contracts/nextest_profile_test.py +++ b/tests/workflow_contracts/nextest_profile_test.py @@ -41,12 +41,16 @@ } #: The whole-run budget both profiles must declare. -REQUIRED_GLOBAL_TIMEOUT: typ.Final[str] = "85m" +REQUIRED_GLOBAL_TIMEOUT: typ.Final[str] = "140m" # 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)" +ISOLATED_CARGO_CHECKS_FILTER: typ.Final[str] = ( + "test(crate_builds_without_dylint_driver_feature) | " + "test(fluent_bundles_are_included_in_the_package_tarball)" +) COMPILE_CONTRACT_FILTER: typ.Final[str] = ( "test(dependency_check_result_contract_is_public) | " "test(fixture_expansion_lint_attribute_contract) | " @@ -101,7 +105,7 @@ class Allowance(typ.NamedTuple): "default": { BUILD_SCRIPT_INTEGRATION_FILTER: Allowance( slow_timeout={ - "period": "45m", + "period": "60m", "terminate-after": 1, "grace-period": "5s", }, @@ -115,9 +119,17 @@ class Allowance(typ.NamedTuple): }, test_group="serial-nested-cargo-builds", ), + ISOLATED_CARGO_CHECKS_FILTER: Allowance( + slow_timeout={ + "period": "30m", + "terminate-after": 1, + "grace-period": "5s", + }, + test_group="serial-nested-cargo-builds", + ), COMPILE_CONTRACT_FILTER: Allowance( slow_timeout={ - "period": "45m", + "period": "90m", "terminate-after": 1, "grace-period": "5s", }, @@ -138,6 +150,7 @@ class Allowance(typ.NamedTuple): "test(driver::ui::) | " "test(tests::ui::) | " "test(ui::ui) | " + "test(exported_entry_point_registers_suite_lints) | " "test(example_compiles_without_diagnostics) | " "test(example_harness_collects_call_site_evidence) | " "test(ui::example_compiles_under_test_harness) | " @@ -163,7 +176,7 @@ class Allowance(typ.NamedTuple): "ci": { BUILD_SCRIPT_INTEGRATION_FILTER: Allowance( slow_timeout={ - "period": "45m", + "period": "60m", "terminate-after": 1, "grace-period": "5s", }, @@ -177,9 +190,17 @@ class Allowance(typ.NamedTuple): }, test_group="serial-nested-cargo-builds", ), + ISOLATED_CARGO_CHECKS_FILTER: Allowance( + slow_timeout={ + "period": "30m", + "terminate-after": 1, + "grace-period": "5s", + }, + test_group="serial-nested-cargo-builds", + ), COMPILE_CONTRACT_FILTER: Allowance( slow_timeout={ - "period": "45m", + "period": "90m", "terminate-after": 1, "grace-period": "5s", }, @@ -198,6 +219,7 @@ class Allowance(typ.NamedTuple): "test(driver::ui::) | " "test(tests::ui::) | " "test(ui::ui) | " + "test(exported_entry_point_registers_suite_lints) | " "(binary(ui) & test(=ui))" ): Allowance( slow_timeout={ diff --git a/tests/workflow_contracts/nixie_tmpdir_contract_test.py b/tests/workflow_contracts/nixie_tmpdir_contract_test.py new file mode 100644 index 000000000..9f5ef309b --- /dev/null +++ b/tests/workflow_contracts/nixie_tmpdir_contract_test.py @@ -0,0 +1,116 @@ +"""Exercise the Makefile's temporary-directory choice for Nixie.""" + +from __future__ import annotations + +import os +import subprocess +from pathlib import Path + +import pytest + +REPOSITORY_ROOT = Path(__file__).resolve().parents[2] + + +def _write_nixie_stub(path: Path) -> None: + """Write a Nixie stub that reports its temporary directory and arguments.""" + path.write_text( + "#!/bin/sh\nprintf 'NIXIE_TMPDIR=%s\\n' \"$TMPDIR\"\nprintf 'NIXIE_ARGS=%s\\n' \"$*\"\n", + encoding="utf-8", + ) + path.chmod(0o755) + + +def _write_mktemp_stub(path: Path) -> None: + """Reject the preferred directory and delegate other probes to coreutils.""" + path.write_text( + "#!/bin/sh\n" + 'case "$2" in "$FAIL_MKTEMP_PREFIX"*) exit 1 ;; esac\n' + 'exec /usr/bin/mktemp "$@"\n', + encoding="utf-8", + ) + path.chmod(0o755) + + +def _run_nixie_target(tmp_path: Path, environment: dict[str, str]) -> str: + """Run the real Makefile recipe with a hermetic Nixie stub.""" + stub = tmp_path / "nixie" + _write_nixie_stub(stub) + result = subprocess.run( + ["make", "--silent", "--no-print-directory", "nixie", f"NIXIE={stub}"], + cwd=REPOSITORY_ROOT, + env=environment, + capture_output=True, + check=False, + text=True, + ) + assert result.returncode == 0, result.stderr + return result.stdout + + +def test_nixie_prefers_a_writable_xdg_runtime_directory(tmp_path: Path) -> None: + """Use writable XDG_RUNTIME_DIR ahead of the shared temporary directory.""" + runtime = tmp_path / "runtime" + fallback = tmp_path / "fallback" + runtime.mkdir() + fallback.mkdir() + assert os.access(runtime, os.W_OK) + environment = os.environ.copy() + environment.update({"XDG_RUNTIME_DIR": str(runtime), "TMPDIR": str(fallback)}) + + output = _run_nixie_target(tmp_path, environment) + + assert f"NIXIE_TMPDIR={runtime}" in output + assert "NIXIE_ARGS=--no-sandbox" in output + + +def test_nixie_falls_back_when_preferred_directory_cannot_create_scratch( + tmp_path: Path, +) -> None: + """Skip a writable runtime directory when it cannot hold scratch files.""" + runtime = tmp_path / "runtime" + fallback = tmp_path / "fallback" + runtime.mkdir() + fallback.mkdir() + bin_directory = tmp_path / "bin" + bin_directory.mkdir() + _write_mktemp_stub(bin_directory / "mktemp") + + environment = os.environ.copy() + environment.update( + { + "XDG_RUNTIME_DIR": str(runtime), + "TMPDIR": str(fallback), + "FAIL_MKTEMP_PREFIX": str(runtime), + "PATH": os.pathsep.join( + [str(bin_directory), environment.get("PATH", "")] + ), + } + ) + + output = _run_nixie_target(tmp_path, environment) + + assert f"NIXIE_TMPDIR={fallback}" in output + + +@pytest.mark.parametrize("invalid_runtime", ["unset", "missing", "file"]) +def test_nixie_falls_back_to_tmpdir_for_invalid_runtime( + tmp_path: Path, invalid_runtime: str +) -> None: + """Fall back to TMPDIR when XDG_RUNTIME_DIR is absent or unusable.""" + fallback = tmp_path / "fallback" + fallback.mkdir() + environment = os.environ.copy() + environment["TMPDIR"] = str(fallback) + + if invalid_runtime == "unset": + environment.pop("XDG_RUNTIME_DIR", None) + elif invalid_runtime == "missing": + environment["XDG_RUNTIME_DIR"] = str(tmp_path / "missing") + else: + runtime_file = tmp_path / "runtime-file" + runtime_file.touch() + environment["XDG_RUNTIME_DIR"] = str(runtime_file) + + output = _run_nixie_target(tmp_path, environment) + + assert f"NIXIE_TMPDIR={fallback}" in output diff --git a/tests/workflow_contracts/runner_lanes.py b/tests/workflow_contracts/runner_lanes.py index 11064543c..0ed9f2369 100644 --- a/tests/workflow_contracts/runner_lanes.py +++ b/tests/workflow_contracts/runner_lanes.py @@ -202,6 +202,10 @@ def leg_guard_selects(condition: object, leg: str) -> bool: #: hours is a typo or a hang nobody has looked at. MAXIMUM_LANE_TIMEOUT_MINUTES: Final[int] = 120 +#: The test-suite lanes have a separately derived ceiling because their +#: nextest budgets exceed the ordinary Ubicloud build-lane limit. +SUITE_LANE_TIMEOUT_MINUTES: Final[int] = 180 + #: Every lane whose runner image sets the glibc floor for a consumer of #: whitaker's x86_64 Linux binaries. Both rolling-release matrix jobs publish diff --git a/tests/workflow_contracts/runner_placement_contract_test.py b/tests/workflow_contracts/runner_placement_contract_test.py index b37d86c01..c3d2581e6 100644 --- a/tests/workflow_contracts/runner_placement_contract_test.py +++ b/tests/workflow_contracts/runner_placement_contract_test.py @@ -23,6 +23,7 @@ GITHUB_HOSTED_LABELS, LEG_DISCRIMINATOR, MAXIMUM_LANE_TIMEOUT_MINUTES, + SUITE_LANE_TIMEOUT_MINUTES, billable_labels, declared_labels, GLIBC_BASELINE_IMAGE, @@ -45,6 +46,16 @@ restore_steps, steps_by_name, ) +from suite_lanes import read_suite_lanes + + +@pytest.fixture(scope="module") +def suite_job_keys() -> frozenset[tuple[str, str]]: + """Return the workflow jobs that execute the Nextest suite.""" + return frozenset( + (lane.workflow, lane.job) + for lane in read_suite_lanes() + ) @pytest.mark.parametrize("lane", UBICLOUD_LANES, ids=str) @@ -227,7 +238,9 @@ def test_the_actionlint_registry_matches_the_labels_in_use() -> None: @pytest.mark.parametrize("lane", UBICLOUD_LANES, ids=str) -def test_every_ubicloud_lane_declares_a_timeout(lane: RunnerLane) -> None: +def test_every_ubicloud_lane_declares_a_timeout( + lane: RunnerLane, suite_job_keys: frozenset[tuple[str, str]] +) -> None: """A hung Ubicloud job bills until something stops it. Ubicloud runners register as self-hosted just-in-time runners, so GitHub's @@ -246,9 +259,14 @@ def test_every_ubicloud_lane_declares_a_timeout(lane: RunnerLane) -> None: f"{lane} runs on a paid runner and must declare timeout-minutes; " f"got {timeout!r}" ) - assert 0 < timeout <= MAXIMUM_LANE_TIMEOUT_MINUTES, ( + maximum = ( + SUITE_LANE_TIMEOUT_MINUTES + if (lane.workflow, lane.job) in suite_job_keys + else MAXIMUM_LANE_TIMEOUT_MINUTES + ) + assert 0 < timeout <= maximum, ( f"{lane} declares timeout-minutes {timeout}, outside the reviewed " - f"range of 1 to {MAXIMUM_LANE_TIMEOUT_MINUTES}" + f"range of 1 to {maximum}" ) diff --git a/tests/workflow_contracts/suite_lanes.py b/tests/workflow_contracts/suite_lanes.py index ccc4385bd..a46f54953 100644 --- a/tests/workflow_contracts/suite_lanes.py +++ b/tests/workflow_contracts/suite_lanes.py @@ -251,6 +251,26 @@ def _lanes_in_job(job: _Job) -> tuple[SuiteLane, ...]: ) +def read_suite_lanes() -> tuple[SuiteLane, ...]: + """Read every suite-running lane from the repository workflows. + + Returns + ------- + tuple[SuiteLane, ...] + One entry for each workflow step that runs the test suite. + + Raises + ------ + WorkflowLoadError + If a workflow file cannot be read or parsed. + """ + return tuple( + lane + for job in _declared_jobs(_workflow_documents()) + for lane in _lanes_in_job(job) + ) + + def _watchdog_offences( job: _Job, documents: dict[str, dict[str, typ.Any]], diff --git a/tests/workflow_contracts/timeout_ordering_test.py b/tests/workflow_contracts/timeout_ordering_test.py index 142e5bdb0..c89a94be1 100644 --- a/tests/workflow_contracts/timeout_ordering_test.py +++ b/tests/workflow_contracts/timeout_ordering_test.py @@ -18,7 +18,7 @@ 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 an -85 m nextest budget. See "Test timeouts: four tiers, outermost last" in +140 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. @@ -31,6 +31,7 @@ import pytest from nextest_config import nextest_config_text +from runner_lanes import SUITE_LANE_TIMEOUT_MINUTES from timeout_budgets import ( Profile, CEILING_MARGIN_SECONDS, @@ -58,6 +59,7 @@ _declared_jobs, _lanes_in_job, _workflow_documents, + read_suite_lanes, ) @@ -87,11 +89,7 @@ def suite_lanes() -> tuple[SuiteLane, ...]: tuple[SuiteLane, ...] One entry per suite-running step. """ - return tuple( - lane - for job in _declared_jobs(_workflow_documents()) - for lane in _lanes_in_job(job) - ) + return read_suite_lanes() def test_the_suite_runs_somewhere(suite_lanes: tuple[SuiteLane, ...]) -> None: @@ -277,7 +275,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 an 85 m nextest budget, which is the inversion the + default underneath a 140 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 +296,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] = 120 +REQUIRED_CEILING_MINUTES: typ.Final[int] = SUITE_LANE_TIMEOUT_MINUTES #: The condition each suite lane legitimately carries, keyed by workflow #: and job, as the step's ``if`` and its job's. @@ -326,8 +324,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 120 minutes the developers' guide pins: a policy - value above the derived requirement of 116 minutes 5 seconds, which + it. This asserts the 180 minutes the developers' guide pins: a policy + value above the derived requirement of 171 minutes 5 seconds, which already carries the fifteen-minute margin. """ wrong = {