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
61 changes: 61 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,67 @@ 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.5.1] - 2026-06-09

Release fixing a class of **macro-token blindness** that ran across the
codebase, plus a new (backward-compatible) `allow_prelude_glob` architecture
option. syn's visitor
treats a macro body as an opaque token stream, so calls, `self` uses, and
component renders inside `vec![…]`, `format!(…)`, and DSL macros like dioxus
`rsx!{…}` were invisible to every analyzer that only walks the AST — producing
false DEAD_CODE, TQ_NO_SUT, TQ_UNTESTED, and SRP-cohesion findings, and *missed*
forbidden calls in the architecture matchers. Seven sites had each reinvented a
weak `Punctuated<Expr, Comma>` parse blind to the `;`-repeat and block-bodied
forms; they now share one recovery helper.

### Added
- **`adapters/shared/macro_tokens.rs`** — the single source of macro-body
recovery. `recover_exprs` escalates comma-list → braced-block (handles
`vec![x; n]` repeat and `;`-separated bodies, lifting trailing `Stmt::Macro`
back to an `Expr::Macro` and keeping a `let … else { … }` diverging branch) →
single-expr. `idents_in_call_position` /
`tokens_reference_ident` are the raw, never-fail fallback for DSL bodies
(`rsx!{ Component { .. } }`) that parse as no structured expression.
- **`[[architecture.pattern]] allow_prelude_glob` (bool, default `true`).** When
a pattern sets `forbid_glob_import = true`, idiomatic `*::prelude::*` re-export
globs (`std`/`dioxus`/`bevy`; a `prelude` segment at any depth) are exempt by
default. Set `allow_prelude_glob = false` to forbid even prelude globs. The
`forbid_glob_import` matcher stays a "find all globs" primitive — the prelude
exemption is a policy decision applied at the analyzer layer, so projects keep
full control (unlike a hard-coded matcher exemption).

### Fixed
- **Macro-embedded calls are now seen across all call-graph / cohesion sites.**
Dead-code (DRY-002), TQ-SUT (TQ-002), TQ reachability (TQ-003), SRP cohesion
(LCOM4), and the architecture `forbid_function_call`/`forbid_method_call`/
`forbid_macro_call` matchers all route macro bodies through `recover_exprs`,
so a call inside `vec![helper(); 3]` or a method inside `format!(…)` is no
longer dropped. The reachability consumers additionally harvest idents in
*call/construction position* — a call `f(..)` (any case) or an UpperCamelCase
component/struct render `Component { .. }` (lowercase DSL element tags like
`div { .. }` and prop keys are excluded, so an unused `fn div()` is never
masked) — so a dioxus component referenced only via `rsx!{ Component { .. } }`
(struct syntax the structured parse sees but does not record as a call) is
recognised as used — clearing false DEAD_CODE / NO_SUT on component-heavy UI
crates. This is the safe direction for findings: an extra recovered reference
only ever *suppresses* a finding, never raises a false one (a rare colliding
name can mask a true positive — the accepted conservative bias). The
architecture `forbid_*` matchers deliberately use only the structured path,
never the positional fallback, since over-collection there would manufacture
false violations.
- **SLM (self-less method) no longer false-fires on macro `self` use.** A `self`
referenced only inside `format!("{}", self.x)`, `write!(…)`, or any macro is
now found via a raw token scan (`tokens_reference_ident`), replacing the old
`matches!`-only special case.

### Notes
- **IOSP own-call counting stays deliberately macro-blind.** Counting own-calls
inside macro bodies would newly flag real macro-driven render code (a
component holding a conditional while rendering own components in `rsx!`) as
an IOSP Violation — a breaking change out of scope here. This is the safe
direction (under-reports, never false-positive) and is pinned by a
characterization test.

## [1.5.0] - 2026-06-05

Minor release with one **breaking** config change. rustqual stops shipping a
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.5.0"
version = "1.5.1"
edition = "2021"
description = "Comprehensive Rust code quality analyzer — seven dimensions: IOSP, Complexity, DRY, SRP, Coupling, Test Quality, Architecture"
license = "MIT"
Expand Down
1 change: 1 addition & 0 deletions book/reference-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,7 @@ Repeatable symbol-pattern rules.
| `forbid_method_call` | List of method names to forbid (`unwrap`, `expect`, …) |
| `forbid_macro_call` | List of macro names to forbid (`println`, `dbg`, …) |
| `forbid_glob_import` | `true` to forbid `use foo::*;` |
| `allow_prelude_glob` | When forbidding globs, still allow `*::prelude::*` re-exports (`std`/`dioxus`/`bevy`). Default `true`; set `false` to forbid even prelude globs |
| `forbidden_in` | Globs where the rule fires |
| `allowed_in` | Globs where the rule is exempted |
| `except` | Globs in `forbidden_in` that are exempted |
Expand Down
18 changes: 17 additions & 1 deletion src/adapters/analyzers/architecture/analyzer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,10 @@ fn build_globset(patterns: &[String]) -> Option<GlobSet> {

/// Run every active matcher of `pattern` on one parsed file.
/// Integration: iterator-chain over matchers, collects findings.
fn run_pattern_matchers(file: &crate::ports::ParsedFile, pattern: &SymbolPattern) -> Vec<Finding> {
pub(super) fn run_pattern_matchers(
file: &crate::ports::ParsedFile,
pattern: &SymbolPattern,
) -> Vec<Finding> {
let rule_id = format!("architecture/pattern/{}", pattern.name);
let mut out = Vec::new();

Expand Down Expand Up @@ -204,6 +207,7 @@ fn run_pattern_matchers(file: &crate::ports::ParsedFile, pattern: &SymbolPattern
let hits = find_glob_imports(&file.path, &file.ast);
out.extend(
hits.into_iter()
.filter(|h| !pattern.allow_prelude_glob || !is_prelude_glob(h))
.map(|h| match_to_finding(h, &rule_id, pattern)),
);
}
Expand All @@ -224,6 +228,18 @@ fn run_pattern_matchers(file: &crate::ports::ParsedFile, pattern: &SymbolPattern
out
}

/// True if a glob-import hit targets a `*::prelude::*` re-export (a `prelude`
/// segment at any depth) — the idiomatic `std`/`dioxus`/`bevy` pattern exempted
/// by `allow_prelude_glob`. The matcher reports every glob; this policy predicate
/// decides which are forgiven. Operation: kind match + segment scan, no own calls.
fn is_prelude_glob(hit: &MatchLocation) -> bool {
matches!(
&hit.kind,
ViolationKind::GlobImport { base_path }
if base_path.split("::").any(|seg| seg == "prelude")
)
}

// ── layer rule ─────────────────────────────────────────────────────────

/// Run the layer rule against the whole parsed workspace.
Expand Down
41 changes: 6 additions & 35 deletions src/adapters/analyzers/architecture/call_parity_rule/calls/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -122,42 +122,13 @@ mod scope;
mod visit;

/// Best-effort extraction of expressions from a macro token stream.
/// Most macros accept comma-separated exprs (`assert!(a, b)`,
/// `format!("{}", x)`), but block-like bodies (`tokio::select! { ... }`)
/// and separator-`;` variants (`vec![x; n]`) don't. We try three
/// strategies in order:
/// 1. Comma-separated `syn::Expr` list (covers ~90% of macro calls).
/// 2. Brace-wrapped parse as a `syn::Block` — extracts every statement
/// expression, covering block-bodied and `;`-separated forms.
/// 3. Single `syn::Expr` — for macros whose argument is one expression.
///
/// Still silent-skips on total parse failure (extern-DSL macros, custom
/// grammar) — a documented limitation of syntax-level call-graph
/// construction.
/// Thin alias for the shared [`crate::adapters::shared::macro_tokens::recover_exprs`]
/// — the single source of the comma-list / `;`-repeat / block-bodied / single-expr
/// recovery strategy used by every macro-aware collector. Kept as a local name
/// so the call_parity call-graph code reads in its own vocabulary.
/// Integration: delegates to the shared helper, no logic.
pub(super) fn parse_macro_tokens(tokens: proc_macro2::TokenStream) -> Vec<syn::Expr> {
use syn::parse::Parser;
use syn::punctuated::Punctuated;
use syn::Token;
let parser = Punctuated::<syn::Expr, Token![,]>::parse_terminated;
if let Ok(exprs) = parser.parse2(tokens.clone()) {
return exprs.into_iter().collect();
}
let braced = quote::quote! { { #tokens } };
if let Ok(block) = syn::parse2::<syn::Block>(braced) {
return block
.stmts
.into_iter()
.filter_map(|stmt| match stmt {
syn::Stmt::Expr(e, _) => Some(e),
syn::Stmt::Local(l) => l.init.map(|init| *init.expr),
_ => None,
})
.collect();
}
if let Ok(expr) = syn::parse2::<syn::Expr>(tokens) {
return vec![expr];
}
Vec::new()
crate::adapters::shared::macro_tokens::recover_exprs(&tokens)
}

/// Project an inferred receiver type to the canonical call-graph
Expand Down
19 changes: 10 additions & 9 deletions src/adapters/analyzers/architecture/matcher/function_call.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,15 +74,16 @@ impl<'ast> Visit<'ast> for FunctionCallVisitor<'_> {
}

fn visit_macro(&mut self, node: &'ast syn::Macro) {
// Macro token streams are invisible to the default visitor. Parse
// the token stream as comma-separated expressions so calls inside
// `format!("{}", Box::new(x))` are caught.
use syn::punctuated::Punctuated;
if let Ok(args) = syn::parse::Parser::parse2(
Punctuated::<syn::Expr, syn::Token![,]>::parse_terminated,
node.tokens.clone(),
) {
args.iter().for_each(|expr| visit::visit_expr(self, expr));
// Macro token streams are invisible to the default visitor. Recover the
// embedded exprs (comma-list, `;`-repeat, and block-bodied forms) so
// forbidden calls inside `format!("{}", Box::new(x))`, `vec![f(); n]`,
// etc. are caught. Structured-only by design: unlike the reachability
// consumers, a forbid-matcher must NOT use the raw positional fallback
// (it would manufacture false violations), so a forbidden call inside an
// unparseable extern-DSL body is best-effort-missed rather than risk a
// false positive.
for expr in crate::adapters::shared::macro_tokens::recover_exprs(&node.tokens) {
visit::visit_expr(self, &expr);
}
visit::visit_macro(self, node);
}
Expand Down
5 changes: 5 additions & 0 deletions src/adapters/analyzers/architecture/matcher/glob_import.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@ impl GlobImportVisitor<'_> {
segments.pop();
}
syn::UseTree::Glob(g) => {
// Report EVERY glob (including `self::*`, `super::*`, and
// `*::prelude::*`). Policy decides what is allowed: path scope
// via `forbidden_in`/`except`, and the prelude exemption via
// `allow_prelude_glob`, both applied at the analyzer layer. The
// matcher stays a dumb "find all globs" primitive.
let base_path = segments.join("::");
let start = g.star_token.span().start();
self.hits.push(MatchLocation {
Expand Down
16 changes: 8 additions & 8 deletions src/adapters/analyzers/architecture/matcher/macro_call.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,14 +41,14 @@ impl<'ast> Visit<'ast> for MacroCallVisitor<'_> {
}
}
// Descend into the macro token stream so nested macros (e.g.
// `vec![format!(...)]`) are caught. Matches the same parse trick
// the rustqual call-target collector uses.
use syn::punctuated::Punctuated;
if let Ok(args) = syn::parse::Parser::parse2(
Punctuated::<syn::Expr, syn::Token![,]>::parse_terminated,
node.tokens.clone(),
) {
args.iter().for_each(|expr| visit::visit_expr(self, expr));
// `vec![format!(...)]`) are caught — recover the embedded exprs
// (comma-list, `;`-repeat, and block-bodied forms) via the shared
// helper the rustqual call-target collector also uses. Structured-only
// by design: a forbid-matcher must NOT use the raw positional fallback
// (it would manufacture false violations), so a nested macro inside an
// unparseable extern-DSL body is best-effort-missed.
for expr in crate::adapters::shared::macro_tokens::recover_exprs(&node.tokens) {
visit::visit_expr(self, &expr);
}
visit::visit_macro(self, node);
}
Expand Down
19 changes: 9 additions & 10 deletions src/adapters/analyzers/architecture/matcher/method_call.rs
Original file line number Diff line number Diff line change
Expand Up @@ -80,16 +80,15 @@ impl<'ast> Visit<'ast> for MethodCallVisitor<'_> {
}

fn visit_macro(&mut self, node: &'ast syn::Macro) {
// Macro token streams are not parsed by syn's default visitor, but
// we want to catch method calls inside `format!("{}", x.unwrap())`
// and similar. Parse the token stream as a comma-separated list of
// expressions (works for most function-like macros).
use syn::punctuated::Punctuated;
if let Ok(args) = syn::parse::Parser::parse2(
Punctuated::<syn::Expr, syn::Token![,]>::parse_terminated,
node.tokens.clone(),
) {
args.iter().for_each(|expr| visit::visit_expr(self, expr));
// Macro token streams are not parsed by syn's default visitor, but we
// want to catch method calls inside `format!("{}", x.unwrap())` and
// similar. Recover the embedded exprs (comma-list, `;`-repeat, and
// block-bodied forms) and feed them through the visitor. Structured-only
// by design: a forbid-matcher must NOT use the raw positional fallback
// (it would manufacture false violations), so a method call inside an
// unparseable extern-DSL body is best-effort-missed.
for expr in crate::adapters::shared::macro_tokens::recover_exprs(&node.tokens) {
visit::visit_expr(self, &expr);
}
visit::visit_macro(self, node);
}
Expand Down
18 changes: 18 additions & 0 deletions src/adapters/analyzers/architecture/matcher/tests/function_call.rs
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,24 @@ fn descends_into_macro_tokens() {
assert_eq!(hits.len(), 1, "{hits:?}");
}

#[test]
fn descends_into_repeat_form_macro_tokens() {
// `vec![Box::new(42); 3]` — the `;`-repeat body fails a comma-expr parse;
// the shared recovery's block fallback must still surface the forbidden
// call, otherwise `forbid_function_call` silently misses it.
let src = r#"
fn main() {
let _v = vec![Box::new(42); 3];
}
"#;
let hits = find(src, &["Box::new"]);
assert_eq!(
hits.len(),
1,
"forbidden call inside a vec![_; n] repeat macro must be caught: {hits:?}"
);
}

// ── turbofish tolerance ──────────────────────────────────────────────

#[test]
Expand Down
21 changes: 21 additions & 0 deletions src/adapters/analyzers/architecture/matcher/tests/glob_import.rs
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,27 @@ fn does_not_match_empty_file() {
assert!(hits.is_empty());
}

#[test]
fn matches_prelude_glob_at_matcher_level() {
// The matcher is a dumb "find ALL globs" primitive — it reports prelude
// globs too (`crate::prelude::*`, `dioxus::prelude::*`, …). The idiomatic
// exemption is a POLICY decision applied at the analyzer layer via
// `allow_prelude_glob` (see `run_pattern_matchers`), not here — consistent
// with the `self::*`/`super::*` handling above.
for src in [
"use crate::prelude::*;",
"use dioxus::prelude::*;",
"use some::nested::prelude::*;",
] {
let hits = find(src);
assert_eq!(
hits.len(),
1,
"matcher must report the prelude glob (policy decides exemption), got {hits:?} for `{src}`"
);
}
}

#[test]
fn line_number_points_to_glob_statement() {
let src = "\n\n\nuse foo::*;\n";
Expand Down
18 changes: 18 additions & 0 deletions src/adapters/analyzers/architecture/matcher/tests/macro_call.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,24 @@ fn matches_macro_inside_macro_args() {
);
}

#[test]
fn matches_macro_inside_repeat_form_macro_args() {
// `vec![format!("inner"); 3]` — the `;`-repeat outer body needs the shared
// recovery's block fallback for the nested forbidden macro to be found.
let src = r#"
fn run() {
let v = vec![format!("inner"); 3];
let _ = v;
}
"#;
let hits = find(src, &["format"]);
assert_eq!(
hits.len(),
1,
"nested macro inside a vec![_; n] repeat macro must be found: {hits:?}"
);
}

// ── Negative matches ──────────────────────────────────────────────────

#[test]
Expand Down
18 changes: 18 additions & 0 deletions src/adapters/analyzers/architecture/matcher/tests/method_call.rs
Original file line number Diff line number Diff line change
Expand Up @@ -187,3 +187,21 @@ fn matches_method_call_inside_macro_arg() {
"method inside macro arg must match: {hits:?}"
);
}

#[test]
fn matches_method_call_inside_repeat_form_macro() {
// `vec![x.unwrap(); 3]` — the `;`-repeat body needs the shared recovery's
// block fallback; a plain comma-expr parse would miss the forbidden method.
let src = r#"
fn run() {
let x: Option<i32> = Some(1);
let _v = vec![x.unwrap(); 3];
}
"#;
let hits = find(src, &["unwrap"]);
assert_eq!(
hits.len(),
1,
"method inside a vec![_; n] repeat macro must match: {hits:?}"
);
}
Loading
Loading