diff --git a/CHANGELOG.md b/CHANGELOG.md index 5213da05..ad1c6f2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,36 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [1.2.5] - in development +## [1.2.6] - 2026-05-27 + +Patch release: **dyn-compatibility precision in `must_be_object_safe`** — +the conservative object-safety check used to false-flag legitimate +lifetime-generic methods. Streaming-trait patterns like +`fn stream<'a>(&'a self) -> Box` (and the +analogous LlmEngine `BoxStream<'a, _>` shape) are dyn-safe per Rust's +actual rule but were rejected by rustqual. Failing-first regression +test in `src/adapters/analyzers/architecture/tests/trait_contract.rs`. + +### Fixed (object-safety check) + +- **`must_be_object_safe` no longer false-flags lifetime-generic + methods.** The conservative check in + `trait_contract_rule/checks.rs::check_object_safety` used + `!generics.params.is_empty()` to flag method-level generics as + object-unsafe — but Rust's actual dyn-compatibility rule treats + lifetime parameters as object-safe (they're compile-time-only and + erased at codegen; only type and const generics need vtable slots + the compiler can't synthesise). Affected idiom: + `fn stream<'a>(&'a self) -> Box` and any + streaming-engine trait that ties a returned `BoxStream<'a, …>` to + `&'a self`. Fix: filter to `GenericParam::Type | GenericParam::Const` + only via new `has_object_unsafe_generic` helper. Finding message + sharpened to "has type/const method-level generics". Regression + tests: `must_be_object_safe_allows_method_level_lifetime` (passes + for `<'a>`) and `must_be_object_safe_flags_const_generic_method` + (defensive — locks in that `` is still flagged). + +## [1.2.5] - 2026-05-17 Patch release: **`pub use` re-export resolution gate** — closes a double-mismatch in trait dispatch and inherent-impl associated-fn @@ -105,7 +134,7 @@ live in `call_parity_rule/tests/reexport_resolution.rs`. ~3-4 weeks incremental work; replaces the reviewer-discipline invariant with a compile-time-enforced one. -## [1.2.4] - in development +## [1.2.4] - 2026-05-16 Patch release: **call-parity audit follow-up + post-review sharpening** — closes the two remaining call-parity gaps surfaced by diff --git a/Cargo.lock b/Cargo.lock index df94d610..14638c20 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -578,7 +578,7 @@ dependencies = [ [[package]] name = "rustqual" -version = "1.2.5" +version = "1.2.6" dependencies = [ "clap", "clap_complete", diff --git a/Cargo.toml b/Cargo.toml index ea76f04b..f7186ffe 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rustqual" -version = "1.2.5" +version = "1.2.6" edition = "2021" description = "Comprehensive Rust code quality analyzer — seven dimensions: IOSP, Complexity, DRY, SRP, Coupling, Test Quality, Architecture" license = "MIT" diff --git a/src/adapters/analyzers/architecture/tests/trait_contract.rs b/src/adapters/analyzers/architecture/tests/trait_contract.rs index a8ec3e3a..a286a8cc 100644 --- a/src/adapters/analyzers/architecture/tests/trait_contract.rs +++ b/src/adapters/analyzers/architecture/tests/trait_contract.rs @@ -208,6 +208,45 @@ fn must_be_object_safe_flags_generic_method() { assert_eq!(checks(&hits), vec!["object_safety"]); } +#[test] +fn must_be_object_safe_allows_method_level_lifetime() { + // Rust's actual dyn-compatibility rule treats lifetime params on + // methods as dyn-safe — only type and const generics break + // object-safety (the compiler can't synthesise a vtable entry for + // unknown `T` or `const N`, but lifetimes are compile-time-only + // and erased). Streaming traits routinely tie a returned `Box` to `&'a self` via a lifetime param. The check + // must not false-flag this idiom. + let mut rule = empty(); + rule.must_be_object_safe = Some(true); + let src = r#" + pub trait Streamable { + fn stream<'a>(&'a self) -> Box + 'a>; + } + "#; + let hits = run("any.rs", src, &rule); + assert!( + hits.is_empty(), + "method-level lifetime params are object-safe; expected no \ + findings but got {hits:?}", + ); +} + +#[test] +fn must_be_object_safe_flags_const_generic_method() { + // Defensive: const-generic method params break object-safety + // (same vtable-synthesis problem as type generics). Lock in the + // current behaviour so the lifetime-fix doesn't accidentally + // widen the exemption. + let mut rule = empty(); + rule.must_be_object_safe = Some(true); + let src = r#" + pub trait A { fn pack(&self, data: [u8; N]); } + "#; + let hits = run("any.rs", src, &rule); + assert_eq!(checks(&hits), vec!["object_safety"]); +} + // ── forbidden_error_variant_contains ────────────────────────────────── #[test] diff --git a/src/adapters/analyzers/architecture/trait_contract_rule/checks.rs b/src/adapters/analyzers/architecture/trait_contract_rule/checks.rs index 28380de2..bd260820 100644 --- a/src/adapters/analyzers/architecture/trait_contract_rule/checks.rs +++ b/src/adapters/analyzers/architecture/trait_contract_rule/checks.rs @@ -142,7 +142,13 @@ pub(super) fn check_supertraits( }); } -/// Conservative object-safety check: flag `Self` return and method-level generics. +/// Conservative object-safety check: flag `Self` return and method-level +/// type/const generics. Lifetime parameters on methods are intentionally +/// permitted — Rust's dyn-compatibility rule treats them as object-safe +/// because lifetimes are compile-time-only (erased at codegen) and don't +/// need a vtable slot. Type and const generics, by contrast, break +/// object-safety: the compiler can't synthesise a vtable entry without +/// knowing the concrete `T` / `const N` at the call site. pub(super) fn check_object_safety( site: &TraitSite<'_>, rule: &CompiledTraitContract, @@ -159,17 +165,28 @@ pub(super) fn check_object_safety( "object_safety", format!("{} returns Self", m.sig.ident), )); - } else if !m.sig.generics.params.is_empty() { + } else if has_object_unsafe_generic(&m.sig.generics.params) { out.push(hit_method( site, m, "object_safety", - format!("{} has method-level generics", m.sig.ident), + format!("{} has type/const method-level generics", m.sig.ident), )); } }); } +/// True iff any generic parameter is a type or const generic — i.e. +/// would break object-safety. Lifetime-only generics are intentionally +/// permitted (see `check_object_safety`). +fn has_object_unsafe_generic( + params: &syn::punctuated::Punctuated, +) -> bool { + params + .iter() + .any(|p| matches!(p, syn::GenericParam::Type(_) | syn::GenericParam::Const(_))) +} + /// Flag enum variants of the trait's error return type that match forbidden substrings. /// Dedupes by (error_name, forbidden_substring) so a single enum only /// produces one hit per forbidden match, regardless of how many methods