Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 31 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<dyn Iterator + 'a>` (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<dyn Iterator + 'a>` 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 `<const N>` 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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
39 changes: 39 additions & 0 deletions src/adapters/analyzers/architecture/tests/trait_contract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<dyn
// Iterator + 'a>` 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<dyn Iterator<Item = u8> + '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<const N: usize>(&self, data: [u8; N]); }
"#;
let hits = run("any.rs", src, &rule);
assert_eq!(checks(&hits), vec!["object_safety"]);
}

// ── forbidden_error_variant_contains ──────────────────────────────────

#[test]
Expand Down
23 changes: 20 additions & 3 deletions src/adapters/analyzers/architecture/trait_contract_rule/checks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<syn::GenericParam, syn::Token![,]>,
) -> 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
Expand Down
Loading