Skip to main content

judge/rules/
pattern.rs

1//! Pattern-candidate recommendations aggregated from projectwide evidence
2//! (see todo.md §16 "Rust-Pattern-Empfehlungen aus projektweiter Evidenz").
3//!
4//! This is deliberately **not** the `Finding`/`Report`/verdict path: a
5//! [`PatternCandidate`] is a heuristic design suggestion, never a gating
6//! result. Nothing in this module is wired into `evidence_class_for_rule`,
7//! the health score, or a baseline verdict (todo.md §16.1 "Pattern-
8//! Empfehlungen sind keine normalen Findings").
9//!
10//! Scope of this module (MVP slice, todo.md §16.6):
11//! [`PatternCandidate`]/[`CorroboratedEvidence`] plus the five §16.3 MVP
12//! aggregation rules — `stringly-error-boundary`, `primitive-domain-value`,
13//! `boolean-state-cluster`, `public-invariant-bypass`, and
14//! `manual-resource-lifecycle` (see [`analyze_workspace`]). The latter four
15//! are listed as "Deep" tier in todo.md §16.3's rule table; the versions
16//! implemented here are deliberately narrower, Fast-Tier-reachable subsets
17//! of their full definitions (see each rule function's doc comment for the
18//! exact narrowing). The broader `PrincipleHeuristic` type from todo.md
19//! §16.7 (abstract design-principle heuristics like SRP/KISS/YAGNI) is a
20//! deliberately separate, later slice and is not implemented here.
21
22use std::collections::{BTreeMap, BTreeSet};
23use std::path::{Path, PathBuf};
24
25use serde::{Deserialize, Serialize};
26use syn::spanned::Spanned;
27use syn::visit::Visit;
28
29use crate::advisory::clippy_import::ClippyBoolParamsHit;
30use crate::finding::{Finding, FindingId};
31use crate::ingest::{CrateInfo, Workspace};
32
33/// The rule id for the `stringly-error-boundary` aggregation implemented in
34/// this module (see todo.md §16.3's rule table). Distinct from a
35/// [`RustPattern`] — the rule is the *evidence pattern* judge looks for, the
36/// pattern is the *recommendation* it emits.
37pub const STRINGLY_ERROR_BOUNDARY_RULE: &str = "stringly-error-boundary";
38
39/// The rule id for the `primitive-domain-value` aggregation implemented in
40/// this module (see [`primitive_domain_value_candidates`]).
41pub const PRIMITIVE_DOMAIN_VALUE_RULE: &str = "primitive-domain-value";
42
43/// The rule id for the `boolean-state-cluster` aggregation implemented in
44/// this module (see [`boolean_state_cluster_candidates`]).
45pub const BOOLEAN_STATE_CLUSTER_RULE: &str = "boolean-state-cluster";
46
47/// The rule id for the `public-invariant-bypass` aggregation implemented in
48/// this module (see [`public_invariant_bypass_candidates`]).
49pub const PUBLIC_INVARIANT_BYPASS_RULE: &str = "public-invariant-bypass";
50
51/// The rule id for the `manual-resource-lifecycle` aggregation implemented in
52/// this module (see [`manual_resource_lifecycle_candidates`]).
53pub const MANUAL_RESOURCE_LIFECYCLE_RULE: &str = "manual-resource-lifecycle";
54
55/// A recommended Rust design pattern (todo.md §16.2, §16.3). Exactly the enum
56/// from the todo.md sketch — no additional variants.
57///
58/// `Deserialize` is derived alongside `Serialize` so a
59/// [`crate::pattern_baseline::PatternBaseline`] JSON file can be loaded back,
60/// not just saved.
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
62#[serde(rename_all = "snake_case")]
63pub enum RustPattern {
64    ValidatedNewtype,
65    SmartConstructor,
66    StateEnum,
67    TypeState,
68    Builder,
69    OptionsStruct,
70    RaiiGuard,
71    DomainError,
72    FunctionalCore,
73    EncapsulatedAggregate,
74}
75
76impl RustPattern {
77    /// Stable kebab-case identifier, used both for [`PatternCandidateId`]
78    /// computation and TTY rendering.
79    pub const fn slug(self) -> &'static str {
80        match self {
81            Self::ValidatedNewtype => "validated-newtype",
82            Self::SmartConstructor => "smart-constructor",
83            Self::StateEnum => "state-enum",
84            Self::TypeState => "type-state",
85            Self::Builder => "builder",
86            Self::OptionsStruct => "options-struct",
87            Self::RaiiGuard => "raii-guard",
88            Self::DomainError => "domain-error",
89            Self::FunctionalCore => "functional-core",
90            Self::EncapsulatedAggregate => "encapsulated-aggregate",
91        }
92    }
93}
94
95impl std::fmt::Display for RustPattern {
96    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
97        f.write_str(self.slug())
98    }
99}
100
101/// Where a [`PatternCandidate`] applies: a crate, and (if the evidence
102/// concentrates on specific items) the qualified item paths within it.
103/// Deliberately not [`crate::finding::Location`] — that type is a single
104/// file/line/item anchor for a line-precise finding, while a pattern
105/// candidate is crate- or module-wide by construction (todo.md §16.1: many
106/// local symptoms are aggregated into one design decision, so there is no
107/// single line to anchor to).
108#[derive(Debug, Clone, Serialize)]
109pub struct CodeScope {
110    /// The crate this candidate concerns (see [`CrateInfo::name`]).
111    pub krate: String,
112    /// Qualified item paths the evidence concentrates on, deduplicated and
113    /// sorted. Empty when the candidate concerns the crate as a whole rather
114    /// than specific items.
115    pub modules: Vec<String>,
116}
117
118/// A single evidenced location backing an [`Evidence`] entry — a file, and
119/// (if the evidence is item-scoped rather than file-scoped) a qualified item
120/// path within it.
121#[derive(Debug, Clone, Serialize)]
122pub struct EvidenceLocation {
123    pub file: PathBuf,
124    pub item_path: Option<String>,
125}
126
127/// One piece of evidence: a human-readable description of what was observed
128/// (phrased as an observation, never as an absolute claim — see todo.md
129/// §16.7 "Sprachdisziplin"), plus the concrete locations backing it so the
130/// claim stays checkable rather than asserted.
131#[derive(Debug, Clone, Serialize)]
132pub struct Evidence {
133    pub description: String,
134    pub locations: Vec<EvidenceLocation>,
135}
136
137/// At least two independently sourced signals corroborating one
138/// [`PatternCandidate`] (todo.md §16.2, §16.6: "mindestens zwei unabhängige
139/// Evidenzpunkte; andernfalls wird sie standardmäßig unterdrückt"). `primary`
140/// and `independent` are mandatory and must come from different detection
141/// mechanisms; `additional` holds any further corroborating signal beyond
142/// those two.
143#[derive(Debug, Clone, Serialize)]
144pub struct CorroboratedEvidence {
145    pub primary: Evidence,
146    pub independent: Evidence,
147    pub additional: Vec<Evidence>,
148}
149
150/// A situation in which the current structure can already be justified —
151/// mandatory on every [`PatternCandidate`] (todo.md §16.4 "Gegenindikationen
152/// sind Pflicht").
153#[derive(Debug, Clone, Serialize)]
154pub struct Contraindication {
155    pub description: String,
156}
157
158/// A condition the recommendation assumes holds (e.g. "several boundary
159/// functions in this crate convert errors the same way").
160#[derive(Debug, Clone, Serialize)]
161pub struct Precondition {
162    pub description: String,
163}
164
165/// One numbered step of a migration plan. Text only — no patch is generated
166/// (todo.md §16.5: "liefert zunächst nur einen geordneten Migrationsplan und
167/// betroffene API-/Call-Sites, noch keinen Patch"). `affected_paths` names
168/// the call sites/files that step concerns, when known.
169#[derive(Debug, Clone, Serialize)]
170pub struct MigrationStep {
171    pub step: u32,
172    pub description: String,
173    pub affected_paths: Vec<PathBuf>,
174}
175
176/// Stable identifier for a [`PatternCandidate`], analogous in spirit to how
177/// [`FindingId`] identifies a `Finding` — but composed differently, since a
178/// pattern candidate has no single file/line to anchor an id string to.
179/// Deterministically hashed from `(pattern, normalized scope, sorted
180/// evidence identities)` (todo.md §16.5: "stabile ID aus Pattern,
181/// normalisiertem Scope und Evidenzidentitäten bilden").
182///
183/// Note on the hash function: the task brief for this module referenced
184/// Finding ids as a `b3:`-style blake3 hash and assumed blake3 was already a
185/// project dependency. Neither holds for this codebase: `Finding` ids are
186/// plain descriptive `rule:file:line:column` strings (see
187/// `SlopVisitor::record` in `slop.rs`), and blake3 is not in `Cargo.toml`.
188/// Adding a new dependency for a single hash call isn't justified, so this
189/// uses a small hand-rolled, version-independent FNV-1a hash instead of
190/// either blake3 or `std::hash::DefaultHasher` (whose algorithm is
191/// explicitly not guaranteed stable across Rust releases — unsuitable for an
192/// id meant to stay stable across judge upgrades).
193///
194/// `Deserialize` is derived alongside `Serialize` so a
195/// [`crate::pattern_baseline::PatternBaseline`] JSON file can be loaded back,
196/// not just saved.
197#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
198#[serde(transparent)]
199pub struct PatternCandidateId(String);
200
201impl PatternCandidateId {
202    pub fn as_str(&self) -> &str {
203        &self.0
204    }
205
206    fn compute(pattern: RustPattern, scope: &CodeScope, evidence_identities: &[String]) -> Self {
207        let mut modules = scope.modules.clone();
208        modules.sort();
209        let mut identities = evidence_identities.to_vec();
210        identities.sort();
211        identities.dedup();
212        let normalized = format!(
213            "{}|{}|{}|{}",
214            pattern.slug(),
215            scope.krate,
216            modules.join(","),
217            identities.join(",")
218        );
219        Self(format!(
220            "pattern:{}:{}",
221            pattern.slug(),
222            crate::finding::fnv1a_hex(&normalized)
223        ))
224    }
225}
226
227impl std::fmt::Display for PatternCandidateId {
228    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
229        f.write_str(&self.0)
230    }
231}
232
233/// A pattern recommendation aggregated from corroborated projectwide
234/// evidence (todo.md §16.2). Exactly the struct from the todo.md sketch, plus
235/// one deliberate omission: there is no `confidence` field. That is a type-
236/// level guarantee, not a convention — nothing in this module (or anywhere
237/// else) can attach `PatternCandidate` to `Finding`/`Report`/the verdict
238/// path, so this aggregate can never be serialized as a gating result
239/// (todo.md §16.7: "Der Typ selbst garantiert, dass diese Aussage nie als...
240/// CI-Verletzung serialisiert werden kann").
241#[derive(Debug, Clone, Serialize)]
242pub struct PatternCandidate {
243    pub id: PatternCandidateId,
244    pub pattern: RustPattern,
245    pub scope: CodeScope,
246    pub evidence: CorroboratedEvidence,
247    pub preconditions: Vec<Precondition>,
248    pub contraindications: Vec<Contraindication>,
249    pub migration: Vec<MigrationStep>,
250    pub related_findings: Vec<FindingId>,
251}
252
253// Keeps the candidate builders focused on their domain-specific evidence and
254// migration advice while preserving the stable id construction in one place.
255macro_rules! pattern_candidate {
256    (
257        pattern: $pattern:expr,
258        $scope:expr,
259        $evidence_identities:expr,
260        {
261            evidence: $evidence:expr,
262            preconditions: $preconditions:expr,
263            contraindications: $contraindications:expr,
264            migration: $migration:expr,
265            related_findings: $related_findings:expr $(,)?
266        }
267    ) => {{
268        let pattern = $pattern;
269        let scope = $scope;
270        let evidence_identities = $evidence_identities;
271        PatternCandidate {
272            id: PatternCandidateId::compute(pattern, &scope, &evidence_identities),
273            pattern,
274            scope,
275            evidence: $evidence,
276            preconditions: $preconditions,
277            contraindications: $contraindications,
278            migration: $migration,
279            related_findings: $related_findings,
280        }
281    }};
282}
283
284/// Runs every implemented pattern-aggregation rule over `workspace` and
285/// `findings` (the combined output of `judge::rules::slop::analyze_workspace`, or
286/// any superset of it): `stringly-error-boundary`, `primitive-domain-value`,
287/// `boolean-state-cluster`, `public-invariant-bypass`, and
288/// `manual-resource-lifecycle` — this is the dispatch point future rules
289/// from todo.md §16.3 attach to.
290pub fn analyze_workspace(workspace: &Workspace, findings: &[Finding]) -> Vec<PatternCandidate> {
291    analyze_workspace_with_clippy(workspace, findings, &[])
292}
293
294/// Same as [`analyze_workspace`], plus `clippy_hits` — parsed
295/// `clippy::fn_params_excessive_bools` results (see
296/// [`crate::advisory::clippy_import::read_clippy_report`]) that
297/// [`boolean_state_cluster_candidates`] uses as optional, additive
298/// corroborating evidence. Passing an empty slice is exactly
299/// [`analyze_workspace`]'s behavior — the clippy import is fully opt-in.
300pub fn analyze_workspace_with_clippy(
301    workspace: &Workspace,
302    findings: &[Finding],
303    clippy_hits: &[ClippyBoolParamsHit],
304) -> Vec<PatternCandidate> {
305    let mut candidates = stringly_error_boundary_candidates(workspace, findings);
306    candidates.extend(primitive_domain_value_candidates(workspace));
307    candidates.extend(boolean_state_cluster_candidates(workspace, clippy_hits));
308    candidates.extend(public_invariant_bypass_candidates(workspace));
309    candidates.extend(manual_resource_lifecycle_candidates(workspace));
310    candidates
311}
312
313/// `stringly-error-boundary` (todo.md §16.3): concrete errors are converted
314/// to `String`/context-free collectors at module/crate boundaries, while the
315/// crate already has the raw material for a proper domain error. Requires
316/// two independent signals per crate (todo.md §16.6's "mindestens zwei
317/// unabhängige Evidenzpunkte", refined further by this rule's own brief:
318/// signal 1 alone additionally needs at least two occurrences to count as a
319/// pattern rather than a one-off):
320///
321/// 1. **Primary** — at least two `catch-all-error` findings within the same
322///    crate (a syntax fact judge already computes; see `crate::rules::slop`).
323/// 2. **Independent** — the same crate already defines at least one typed
324///    error (an enum with `Error` in its name, an item carrying an
325///    `Error`-suffixed derive such as `#[derive(thiserror::Error)]`, or an
326///    `impl ... Error for ...`) — a structural-availability fact, not a
327///    syntax-frequency one.
328///
329/// Only crates satisfying both produce a candidate — exactly one per crate,
330/// referencing every contributing `catch-all-error` finding.
331fn stringly_error_boundary_candidates(
332    workspace: &Workspace,
333    findings: &[Finding],
334) -> Vec<PatternCandidate> {
335    let mut by_crate: BTreeMap<&str, Vec<&Finding>> = BTreeMap::new();
336    for finding in findings {
337        if finding.rule.as_str() != crate::rules::slop::CATCH_ALL_ERROR_RULE {
338            continue;
339        }
340        let Some(krate) = crate_for_file(workspace, &finding.location.file) else {
341            continue;
342        };
343        by_crate
344            .entry(krate.name.as_str())
345            .or_default()
346            .push(finding);
347    }
348
349    let mut candidates = Vec::new();
350    for (krate_name, crate_findings) in by_crate {
351        if crate_findings.len() < 2 {
352            continue;
353        }
354        let Some(krate) = workspace.crates.iter().find(|k| k.name == krate_name) else {
355            continue;
356        };
357        let Some(independent) = crate_defines_typed_error(krate) else {
358            continue;
359        };
360        candidates.push(build_candidate(krate, &crate_findings, independent));
361    }
362    candidates
363}
364
365/// The crate a source file belongs to, matched against each crate's known
366/// source-file list (populated by `judge::ingest::load`).
367fn crate_for_file<'a>(workspace: &'a Workspace, file: &Path) -> Option<&'a CrateInfo> {
368    workspace
369        .crates
370        .iter()
371        .find(|krate| krate.source_files.iter().any(|source| source.path == file))
372}
373
374/// Reads and parses every source file in `krate` with `syn`, calling `visit`
375/// with each file's path and parsed AST. A file that fails to read or parse
376/// is silently skipped rather than surfaced as an analyzer error — every
377/// rule in this module treats its `syn`-derived facts as best-effort
378/// corroborating evidence, not ground truth, so skipping an unreadable file
379/// just means one less place a signal could have come from. Shared by every
380/// per-crate rule below instead of each repeating the same read/parse/skip
381/// loop.
382fn for_each_parsed_source(krate: &CrateInfo, mut visit: impl FnMut(&Path, &syn::File)) {
383    for source in &krate.source_files {
384        let Ok(text) = std::fs::read_to_string(&source.path) else {
385            continue;
386        };
387        let Ok(ast) = syn::parse_file(&text) else {
388            continue;
389        };
390        visit(&source.path, &ast);
391    }
392}
393
394impl EvidenceLocation {
395    /// An evidence location with a known item path — the shape every
396    /// `build_X_candidate`/visitor in this module constructs once it has
397    /// both a file and a qualified item path (as opposed to a file-only
398    /// location, which uses the struct literal directly with `item_path:
399    /// None`).
400    fn new(file: PathBuf, item_path: impl Into<String>) -> Self {
401        Self {
402            file,
403            item_path: Some(item_path.into()),
404        }
405    }
406}
407
408/// Sorts `locations` by `(file, item_path)` — the ordering discipline every
409/// `build_X_candidate` applies to its evidence locations so output stays
410/// deterministic across runs.
411fn sort_evidence_locations(locations: &mut [EvidenceLocation]) {
412    locations.sort_by(|a, b| (&a.file, &a.item_path).cmp(&(&b.file, &b.item_path)));
413}
414
415/// A [`CodeScope`] for `krate` with `modules` sorted and deduplicated — the
416/// scope-construction step every crate-scoped `build_X_candidate` performs
417/// before filling in its own evidence-specific fields.
418fn crate_scope(krate: &CrateInfo, mut modules: Vec<String>) -> CodeScope {
419    modules.sort();
420    modules.dedup();
421    CodeScope {
422        krate: krate.name.clone(),
423        modules,
424    }
425}
426
427impl CorroboratedEvidence {
428    /// Corroborated evidence with no additional (beyond primary/independent)
429    /// signal yet — every `build_X_candidate` in this module starts here;
430    /// some (e.g. [`boolean_state_cluster_candidates`]) push a further
431    /// `Evidence` onto `additional` afterwards.
432    fn new(primary: Evidence, independent: Evidence) -> Self {
433        Self {
434            primary,
435            independent,
436            additional: Vec::new(),
437        }
438    }
439}
440
441/// Evidence identities in the `"file:item_path"` shape
442/// [`PatternCandidateId::compute`] hashes over, one per location.
443fn location_identities(locations: &[EvidenceLocation]) -> Vec<String> {
444    locations
445        .iter()
446        .map(|location| {
447            format!(
448                "{}:{}",
449                location.file.display(),
450                location.item_path.as_deref().unwrap_or("")
451            )
452        })
453        .collect()
454}
455
456/// The qualified item path for a function found while visiting: `Self::name`
457/// inside an `impl` block (using the block's resolved self type), or just
458/// `name` for a free function. Shared by every pattern visitor that tracks
459/// `self_type` while walking `impl` blocks (see
460/// `visit_item_impl_with_self_type!`).
461fn qualified_item_path(self_type: Option<&str>, name: &str) -> String {
462    match self_type {
463        Some(self_type) => format!("{self_type}::{name}"),
464        None => name.to_string(),
465    }
466}
467
468/// The `(name, type)` of `input` if it's a `pat: Type`-shaped typed argument
469/// with a plain identifier pattern (not `self`, not a destructuring
470/// pattern) — the common shape every pattern visitor here extracts function
471/// parameters through, whether it goes on to check the type
472/// ([`primitive_type_name`]/[`is_bool_type`]) or just needs the name.
473fn typed_ident_arg(input: &syn::FnArg) -> Option<(String, &syn::Type)> {
474    let syn::FnArg::Typed(pat_type) = input else {
475        return None;
476    };
477    let syn::Pat::Ident(pat_ident) = pat_type.pat.as_ref() else {
478        return None;
479    };
480    Some((pat_ident.ident.to_string(), &pat_type.ty))
481}
482
483/// Whether `node` is an `impl <trait> for ...` block whose trait path's last
484/// segment is `ident` (matched structurally, no type resolution — same
485/// caveat as [`primitive_type_name`]).
486fn impl_trait_is(node: &syn::ItemImpl, ident: &str) -> bool {
487    node.trait_.as_ref().is_some_and(|(_, path, _)| {
488        path.segments
489            .last()
490            .is_some_and(|segment| segment.ident == ident)
491    })
492}
493
494fn build_candidate(
495    krate: &CrateInfo,
496    crate_findings: &[&Finding],
497    independent: Evidence,
498) -> PatternCandidate {
499    let mut related_findings: Vec<FindingId> = crate_findings
500        .iter()
501        .map(|finding| finding.id.clone())
502        .collect();
503    related_findings.sort_by(|a, b| a.as_str().cmp(b.as_str()));
504
505    let modules: Vec<String> = crate_findings
506        .iter()
507        .map(|finding| finding.location.item_path.clone())
508        .collect();
509    let scope = crate_scope(krate, modules);
510
511    let mut primary_locations: Vec<EvidenceLocation> = crate_findings
512        .iter()
513        .map(|finding| {
514            EvidenceLocation::new(
515                finding.location.file.clone(),
516                finding.location.item_path.clone(),
517            )
518        })
519        .collect();
520    sort_evidence_locations(&mut primary_locations);
521
522    let mut affected_paths: Vec<PathBuf> = crate_findings
523        .iter()
524        .map(|finding| finding.location.file.clone())
525        .collect();
526    affected_paths.sort();
527    affected_paths.dedup();
528
529    let primary = Evidence {
530        description: format!(
531            "{} `catch-all-error` finding(s) in crate `{}` convert concrete errors to \
532             `String`/`Box<dyn Error>`/context-free collectors at public boundaries.",
533            crate_findings.len(),
534            krate.name
535        ),
536        locations: primary_locations,
537    };
538
539    let evidence_identities: Vec<String> = related_findings
540        .iter()
541        .map(|id| id.as_str().to_string())
542        .collect();
543
544    pattern_candidate! {
545        pattern: RustPattern::DomainError,
546        scope,
547        evidence_identities,
548        {
549        evidence: CorroboratedEvidence::new(primary, independent),
550        preconditions: vec![Precondition {
551            description: format!(
552                "Mehrere Boundary-Funktionen in Crate `{}` wandeln unterschiedliche \
553                 Fehlerquellen an derselben Grenze in `anyhow`/`Box<dyn Error>`/`String` um.",
554                krate.name
555            ),
556        }],
557        contraindications: vec![
558            Contraindication {
559                description: "Die Grenze kann bewusst ein Kompatibilitäts-Shim sein, der \
560                    verschiedene Fehlerquellen absichtlich vereinheitlicht."
561                    .to_string(),
562            },
563            Contraindication {
564                description: "Ein zusätzliches Domain-Error-Enum kann bei sehr wenigen \
565                    Aufrufstellen mehr Boilerplate als Nutzen erzeugen."
566                    .to_string(),
567            },
568        ],
569        migration: vec![
570            MigrationStep {
571                step: 1,
572                description: "Gemeinsame Fehlerquellen an dieser Grenze identifizieren."
573                    .to_string(),
574                affected_paths: affected_paths.clone(),
575            },
576            MigrationStep {
577                step: 2,
578                description: "Domain-Error-Enum mit einer Variante pro Quelle entwerfen."
579                    .to_string(),
580                affected_paths: Vec::new(),
581            },
582            MigrationStep {
583                step: 3,
584                description: "`From`-Impls für die Quellfehler ergänzen.".to_string(),
585                affected_paths: Vec::new(),
586            },
587            MigrationStep {
588                step: 4,
589                description: "Boundary-Funktionen auf das neue Enum umstellen und `?` statt \
590                    manueller Konvertierung nutzen."
591                    .to_string(),
592                affected_paths,
593            },
594        ],
595            related_findings: related_findings,
596        }
597    }
598}
599
600/// Whether `krate` already defines at least one typed error (see
601/// [`stringly_error_boundary_candidates`]'s signal 2). Reads and parses every
602/// source file in the crate with `syn`; a file that fails to read or parse
603/// is silently skipped rather than surfaced as an analyzer error — this is a
604/// best-effort corroborating signal, not the primary evidence, and skipping
605/// an unreadable file just means one less place this signal could have come
606/// from.
607fn crate_defines_typed_error(krate: &CrateInfo) -> Option<Evidence> {
608    let mut hits = Vec::new();
609    for_each_parsed_source(krate, |file, ast| {
610        let mut visitor = TypedErrorVisitor {
611            file,
612            path: Vec::new(),
613            hits: Vec::new(),
614        };
615        visitor.visit_file(ast);
616        hits.append(&mut visitor.hits);
617    });
618    if hits.is_empty() {
619        return None;
620    }
621    hits.sort_by(|a, b| (&a.file, &a.item_path).cmp(&(&b.file, &b.item_path)));
622    Some(Evidence {
623        description: format!(
624            "Crate `{}` already defines {} typed error item(s) in its own source (an enum with \
625             `Error` in its name, an `Error`-deriving item, or an `impl ... Error for ...`) — \
626             the raw material for a domain error already exists in this crate.",
627            krate.name,
628            hits.len()
629        ),
630        locations: hits,
631    })
632}
633
634/// Collects candidate typed-error item locations: `enum`s named `*Error*`,
635/// any item carrying a derive ending in `Error` (covers
636/// `#[derive(thiserror::Error)]` without depending on the `thiserror` crate
637/// itself), and `impl ... Error for ...` blocks.
638struct TypedErrorVisitor<'a> {
639    file: &'a Path,
640    path: Vec<String>,
641    hits: Vec<EvidenceLocation>,
642}
643
644impl TypedErrorVisitor<'_> {
645    fn current_item_path(&self) -> String {
646        crate::functions::qualified_item_path(self.file, &self.path)
647    }
648
649    fn record(&mut self) {
650        self.hits.push(EvidenceLocation::new(
651            self.file.to_path_buf(),
652            self.current_item_path(),
653        ));
654    }
655}
656
657impl<'ast> Visit<'ast> for TypedErrorVisitor<'_> {
658    fn visit_item_enum(&mut self, node: &'ast syn::ItemEnum) {
659        self.path.push(node.ident.to_string());
660        if node.ident.to_string().contains("Error") || has_derive_ending_in(&node.attrs, "Error") {
661            self.record();
662        }
663        syn::visit::visit_item_enum(self, node);
664        self.path.pop();
665    }
666
667    fn visit_item_struct(&mut self, node: &'ast syn::ItemStruct) {
668        self.path.push(node.ident.to_string());
669        if has_derive_ending_in(&node.attrs, "Error") {
670            self.record();
671        }
672        syn::visit::visit_item_struct(self, node);
673        self.path.pop();
674    }
675
676    fn visit_item_impl(&mut self, node: &'ast syn::ItemImpl) {
677        use quote::ToTokens;
678        self.path.push(node.self_ty.to_token_stream().to_string());
679        if impl_trait_is(node, "Error") {
680            self.record();
681        }
682        syn::visit::visit_item_impl(self, node);
683        self.path.pop();
684    }
685}
686
687/// Whether any `#[derive(...)]` attribute in `attrs` lists a path ending in
688/// `ident` (e.g. `#[derive(thiserror::Error)]` for `ident == "Error"`).
689fn has_derive_ending_in(attrs: &[syn::Attribute], ident: &str) -> bool {
690    attrs.iter().any(|attr| {
691        if !attr.path().is_ident("derive") {
692            return false;
693        }
694        let syn::Meta::List(list) = &attr.meta else {
695            return false;
696        };
697        list.parse_args_with(
698            syn::punctuated::Punctuated::<syn::Path, syn::Token![,]>::parse_terminated,
699        )
700        .is_ok_and(|paths| {
701            paths.iter().any(|path| {
702                path.segments
703                    .last()
704                    .is_some_and(|segment| segment.ident == ident)
705            })
706        })
707    })
708}
709
710/// `primitive-domain-value` (todo.md §16.3): the same primitive value is
711/// validated identically at several boundaries, or only ever used within a
712/// restricted range.
713///
714/// **This is a deliberately narrower, Fast-Tier-reachable subset of the full
715/// rule from todo.md §16.3** (which is listed there as "Deep" tier). The
716/// full rule can reason about validation performed anywhere a value flows,
717/// across crates, and via non-syntactic evidence (e.g. Deep-Tier semantic
718/// analysis of call sites). This implementation only looks at:
719///
720/// 1. **Primary** — the same (parameter name, type) pair appears as a
721///    parameter in at least two *different* `pub fn` signatures within the
722///    *same crate*. Types are restricted to `u8`/`u16`/`u32`/`u64`/`usize`/
723///    `i8`/`i16`/`i32`/`i64`/`isize`/`f32`/`f64`/`String`/`&str` (`bool` is
724///    deliberately excluded — that is [`boolean_state_cluster_candidates`]'s
725///    domain, not this rule's).
726/// 2. **Independent** — at least one of those signatures has a validation
727///    guard referencing the parameter within the function body: an `if`
728///    whose condition references the parameter and whose then-branch
729///    returns `Err(...)` or calls `panic!(...)`, or an `assert!(...)` whose
730///    arguments reference the parameter.
731///
732/// Only (crate, parameter name, type) tuples satisfying both produce a
733/// candidate — exactly one per tuple, referencing every contributing
734/// signature.
735fn primitive_domain_value_candidates(workspace: &Workspace) -> Vec<PatternCandidate> {
736    let mut candidates = Vec::new();
737    for krate in &workspace.crates {
738        let mut facts: Vec<SignatureParamFact> = Vec::new();
739        for_each_parsed_source(krate, |file, ast| {
740            let mut visitor = PrimitiveDomainValueVisitor {
741                file,
742                self_type: None,
743                facts: Vec::new(),
744            };
745            visitor.visit_file(ast);
746            facts.append(&mut visitor.facts);
747        });
748
749        let mut by_param: BTreeMap<(String, String), Vec<SignatureParamFact>> = BTreeMap::new();
750        for fact in facts {
751            by_param
752                .entry((fact.param.clone(), fact.type_name.clone()))
753                .or_default()
754                .push(fact);
755        }
756
757        for ((param, type_name), group) in by_param {
758            if group.len() < 2 {
759                continue;
760            }
761            if !group.iter().any(|fact| fact.has_guard) {
762                continue;
763            }
764            candidates.push(build_primitive_domain_value_candidate(
765                krate, &param, &type_name, &group,
766            ));
767        }
768    }
769    candidates
770}
771
772/// One `pub fn` parameter matching [`primitive_domain_value_candidates`]'s
773/// type restriction, plus whether the function body guards it.
774struct SignatureParamFact {
775    file: PathBuf,
776    item_path: String,
777    param: String,
778    type_name: String,
779    has_guard: bool,
780}
781
782fn build_primitive_domain_value_candidate(
783    krate: &CrateInfo,
784    param: &str,
785    type_name: &str,
786    group: &[SignatureParamFact],
787) -> PatternCandidate {
788    let modules: Vec<String> = group.iter().map(|fact| fact.item_path.clone()).collect();
789    let scope = crate_scope(krate, modules);
790
791    let mut primary_locations: Vec<EvidenceLocation> = group
792        .iter()
793        .map(|fact| EvidenceLocation::new(fact.file.clone(), fact.item_path.clone()))
794        .collect();
795    sort_evidence_locations(&mut primary_locations);
796
797    let mut guard_locations: Vec<EvidenceLocation> = group
798        .iter()
799        .filter(|fact| fact.has_guard)
800        .map(|fact| EvidenceLocation::new(fact.file.clone(), fact.item_path.clone()))
801        .collect();
802    sort_evidence_locations(&mut guard_locations);
803
804    let primary = Evidence {
805        description: format!(
806            "Parameter `{param}: {type_name}` appears with the same name and type in {} `pub \
807             fn` signature(s) in crate `{}`.",
808            group.len(),
809            krate.name
810        ),
811        locations: primary_locations,
812    };
813    let independent = Evidence {
814        description: format!(
815            "At least one of these signatures guards `{param}` with an early error/panic path \
816             referencing the parameter (`if` + `return Err(...)`, `if` + `panic!(...)`, or \
817             `assert!(...)`)."
818        ),
819        locations: guard_locations,
820    };
821
822    let evidence_identities: Vec<String> = location_identities(&primary.locations);
823    let mut affected_paths: Vec<PathBuf> = group.iter().map(|fact| fact.file.clone()).collect();
824    affected_paths.sort();
825    affected_paths.dedup();
826
827    pattern_candidate! {
828        pattern: RustPattern::ValidatedNewtype,
829        scope,
830        evidence_identities,
831        {
832        evidence: CorroboratedEvidence::new(primary, independent),
833        preconditions: vec![Precondition {
834            description: format!(
835                "Crate `{}` verwendet `{param}: {type_name}` wiederholt als Parametername/-typ, \
836                 und mindestens eine Fundstelle validiert den Wertebereich explizit.",
837                krate.name
838            ),
839        }],
840        contraindications: vec![
841            Contraindication {
842                description: "Der Parametername kann in verschiedenen Funktionen tatsächlich \
843                    unterschiedliche Bedeutungen haben, auch wenn Name und Typ übereinstimmen."
844                    .to_string(),
845            },
846            Contraindication {
847                description: "Bei nur einer Validierungsstelle könnte ein Newtype mehr \
848                    Boilerplate als Nutzen erzeugen, falls die übrigen Aufrufstellen den Wert nie \
849                    direkt validieren müssen."
850                    .to_string(),
851            },
852        ],
853        migration: vec![
854            MigrationStep {
855                step: 1,
856                description: "Newtype für den Wertebereich definieren.".to_string(),
857                affected_paths: Vec::new(),
858            },
859            MigrationStep {
860                step: 2,
861                description: "`TryFrom<...>` mit der gefundenen Validierungslogik implementieren."
862                    .to_string(),
863                affected_paths: Vec::new(),
864            },
865            MigrationStep {
866                step: 3,
867                description: "Betroffene Signaturen schrittweise auf den Newtype umstellen."
868                    .to_string(),
869                affected_paths: affected_paths.clone(),
870            },
871            MigrationStep {
872                step: 4,
873                description: "Call-Sites anpassen.".to_string(),
874                affected_paths,
875            },
876        ],
877            related_findings: Vec::new(),
878        }
879    }
880}
881
882/// Whether `ty` is one of [`primitive_domain_value_candidates`]'s allowed
883/// primitive types (`u8`.."f64"`, `String`, `&str`), matched structurally by
884/// the type path's last segment — no type resolution, so a local type alias
885/// named e.g. `type String = Foo;` would produce a false positive. This is
886/// an accepted Fast-Tier limitation, same in spirit as `crate_for_file`'s
887/// path-based crate matching above.
888fn primitive_type_name(ty: &syn::Type) -> Option<String> {
889    const NUMERIC: &[&str] = &[
890        "u8", "u16", "u32", "u64", "usize", "i8", "i16", "i32", "i64", "isize", "f32", "f64",
891    ];
892    match ty {
893        syn::Type::Path(type_path) if type_path.qself.is_none() => {
894            let segment = type_path.path.segments.last()?;
895            if !matches!(segment.arguments, syn::PathArguments::None) {
896                return None;
897            }
898            let name = segment.ident.to_string();
899            if NUMERIC.contains(&name.as_str()) || name == "String" {
900                Some(name)
901            } else {
902                None
903            }
904        }
905        syn::Type::Reference(type_ref) => match &*type_ref.elem {
906            syn::Type::Path(type_path) if type_path.qself.is_none() => {
907                let segment = type_path.path.segments.last()?;
908                if matches!(segment.arguments, syn::PathArguments::None) && segment.ident == "str" {
909                    Some("&str".to_string())
910                } else {
911                    None
912                }
913            }
914            _ => None,
915        },
916        _ => None,
917    }
918}
919
920/// Collects [`SignatureParamFact`]s from `pub fn` items (free functions and
921/// `impl` methods) in one source file.
922struct PrimitiveDomainValueVisitor<'a> {
923    file: &'a Path,
924    self_type: Option<String>,
925    facts: Vec<SignatureParamFact>,
926}
927
928/// Reuses the shared `self_type` stack discipline across pattern visitors
929/// that need qualified inherent-method names.
930macro_rules! visit_item_impl_with_self_type {
931    () => {
932        fn visit_item_impl(&mut self, node: &'ast syn::ItemImpl) {
933            use quote::ToTokens;
934            let previous = self
935                .self_type
936                .replace(node.self_ty.to_token_stream().to_string());
937            syn::visit::visit_item_impl(self, node);
938            self.self_type = previous;
939        }
940    };
941}
942
943/// Shared `visit_item_fn`/`visit_impl_item_fn` pair for pattern visitors
944/// that only record `pub fn`s and delegate to a `record_fn(name, sig,
945/// block)` method — used by [`PrimitiveDomainValueVisitor`] and
946/// [`ConstructorVisitor`].
947macro_rules! visit_pub_fns_via_record_fn {
948    () => {
949        fn visit_item_fn(&mut self, node: &'ast syn::ItemFn) {
950            if matches!(node.vis, syn::Visibility::Public(_)) {
951                self.record_fn(&node.sig.ident.to_string(), &node.sig, &node.block);
952            }
953            syn::visit::visit_item_fn(self, node);
954        }
955
956        fn visit_impl_item_fn(&mut self, node: &'ast syn::ImplItemFn) {
957            if matches!(node.vis, syn::Visibility::Public(_)) {
958                self.record_fn(&node.sig.ident.to_string(), &node.sig, &node.block);
959            }
960            syn::visit::visit_impl_item_fn(self, node);
961        }
962    };
963}
964
965impl PrimitiveDomainValueVisitor<'_> {
966    fn record_fn(&mut self, name: &str, sig: &syn::Signature, block: &syn::Block) {
967        let item_path = qualified_item_path(self.self_type.as_deref(), name);
968        for input in &sig.inputs {
969            let Some((param, ty)) = typed_ident_arg(input) else {
970                continue;
971            };
972            let Some(type_name) = primitive_type_name(ty) else {
973                continue;
974            };
975            let has_guard = body_has_validation_guard_for(block, &param);
976            self.facts.push(SignatureParamFact {
977                file: self.file.to_path_buf(),
978                item_path: item_path.clone(),
979                param,
980                type_name,
981                has_guard,
982            });
983        }
984    }
985}
986
987impl<'ast> Visit<'ast> for PrimitiveDomainValueVisitor<'_> {
988    visit_pub_fns_via_record_fn!();
989    visit_item_impl_with_self_type!();
990}
991
992/// Whether `expr` references identifier `ident` anywhere within it (used to
993/// check that a validation guard's condition actually mentions the
994/// parameter in question, not just any `if`/`assert!`).
995fn expr_references_ident(expr: &syn::Expr, ident: &str) -> bool {
996    struct Finder<'a> {
997        ident: &'a str,
998        found: bool,
999    }
1000    impl<'ast> Visit<'ast> for Finder<'_> {
1001        fn visit_expr_path(&mut self, node: &'ast syn::ExprPath) {
1002            if node.path.is_ident(self.ident) {
1003                self.found = true;
1004            }
1005            syn::visit::visit_expr_path(self, node);
1006        }
1007    }
1008    let mut finder = Finder {
1009        ident,
1010        found: false,
1011    };
1012    finder.visit_expr(expr);
1013    finder.found
1014}
1015
1016/// Whether `tokens` contains identifier `ident` as a token anywhere,
1017/// including inside nested groups (used for `assert!(...)` macro arguments,
1018/// which `syn` only exposes as an opaque `TokenStream`).
1019fn tokens_reference_ident(tokens: &proc_macro2::TokenStream, ident: &str) -> bool {
1020    tokens.clone().into_iter().any(|tree| match tree {
1021        proc_macro2::TokenTree::Ident(node) => node == ident,
1022        proc_macro2::TokenTree::Group(group) => tokens_reference_ident(&group.stream(), ident),
1023        _ => false,
1024    })
1025}
1026
1027/// Whether `block` contains a `return Err(...)` or a `panic!(...)` call
1028/// anywhere within it (used as the then-branch check for an `if`-shaped
1029/// validation guard).
1030fn block_leads_to_error_path(block: &syn::Block) -> bool {
1031    struct Finder {
1032        found: bool,
1033    }
1034    impl<'ast> Visit<'ast> for Finder {
1035        fn visit_expr_return(&mut self, node: &'ast syn::ExprReturn) {
1036            if node.expr.as_deref().is_some_and(is_err_call) {
1037                self.found = true;
1038            }
1039            syn::visit::visit_expr_return(self, node);
1040        }
1041
1042        fn visit_macro(&mut self, node: &'ast syn::Macro) {
1043            if node.path.is_ident("panic") {
1044                self.found = true;
1045            }
1046            syn::visit::visit_macro(self, node);
1047        }
1048    }
1049    let mut finder = Finder { found: false };
1050    finder.visit_block(block);
1051    finder.found
1052}
1053
1054/// Whether `expr` is a call whose callee path ends in the segment `Err`
1055/// (covers both bare `Err(...)` and a qualified `Result::Err(...)`).
1056fn is_err_call(expr: &syn::Expr) -> bool {
1057    match expr {
1058        syn::Expr::Call(call) => matches!(
1059            call.func.as_ref(),
1060            syn::Expr::Path(path) if path.path.segments.last().is_some_and(|segment| segment.ident == "Err")
1061        ),
1062        _ => false,
1063    }
1064}
1065
1066/// Whether `block` (a function body) contains a validation guard for
1067/// `param` — see [`primitive_domain_value_candidates`]'s signal 2.
1068fn body_has_validation_guard_for(block: &syn::Block, param: &str) -> bool {
1069    struct GuardVisitor<'a> {
1070        param: &'a str,
1071        found: bool,
1072    }
1073    impl<'ast> Visit<'ast> for GuardVisitor<'_> {
1074        fn visit_expr_if(&mut self, node: &'ast syn::ExprIf) {
1075            if expr_references_ident(&node.cond, self.param)
1076                && block_leads_to_error_path(&node.then_branch)
1077            {
1078                self.found = true;
1079            }
1080            syn::visit::visit_expr_if(self, node);
1081        }
1082
1083        fn visit_macro(&mut self, node: &'ast syn::Macro) {
1084            if node.path.is_ident("assert") && tokens_reference_ident(&node.tokens, self.param) {
1085                self.found = true;
1086            }
1087            syn::visit::visit_macro(self, node);
1088        }
1089    }
1090    let mut visitor = GuardVisitor {
1091        param,
1092        found: false,
1093    };
1094    visitor.visit_block(block);
1095    visitor.found
1096}
1097
1098/// `boolean-state-cluster` (todo.md §16.3): several bool values are passed
1099/// around together; combinations of them are checked or guarded against
1100/// repeatedly.
1101///
1102/// **This is a deliberately narrower, Fast-Tier-reachable subset of the full
1103/// rule from todo.md §16.3** (which is listed there as "Deep" tier), and it
1104/// is scoped to a single function rather than cross-call-site — the full
1105/// rule can aggregate evidence about how bool parameters are combined
1106/// *across* call sites; this implementation only looks within one function
1107/// body:
1108///
1109/// 1. **Primary** — a `fn`/`pub fn` (including a `pub fn new` constructor)
1110///    has at least three `bool`-typed parameters.
1111/// 2. **Independent** — the function body contains a condition or `match`
1112///    that combines at least two of those bool parameters together in one
1113///    condition (e.g. `if a && b`, `if a && !b`, `match (a, b) { ... }`,
1114///    `if a || b`) — evidence that combinations are actually checked, not
1115///    just that several bools happen to be parameters.
1116///
1117/// Only functions satisfying both produce a candidate — exactly one per
1118/// function, scoped to that function rather than the whole crate (unlike
1119/// `primitive-domain-value`, since the finding here is local to one
1120/// function).
1121///
1122/// `clippy_hits` (optional, see [`crate::advisory::clippy_import`]) never creates a
1123/// candidate on its own — only the two signals above can do that. When a
1124/// function already produces a candidate and a
1125/// `clippy::fn_params_excessive_bools` hit independently matches the same
1126/// function (same file, overlapping line range — see
1127/// [`clippy_hit_matches_fact`]), it is added as a third, purely additive
1128/// [`Evidence`] entry (todo.md §16: "mehrere `fn_params_excessive_bools`-
1129/// Fundstellen als Signal").
1130fn boolean_state_cluster_candidates(
1131    workspace: &Workspace,
1132    clippy_hits: &[ClippyBoolParamsHit],
1133) -> Vec<PatternCandidate> {
1134    let mut candidates = Vec::new();
1135    for krate in &workspace.crates {
1136        for_each_parsed_source(krate, |file, ast| {
1137            let mut visitor = BooleanStateClusterVisitor {
1138                file,
1139                self_type: None,
1140                facts: Vec::new(),
1141            };
1142            visitor.visit_file(ast);
1143            for fact in visitor.facts {
1144                let mut candidate = build_boolean_state_cluster_candidate(krate, &fact);
1145                if let Some(hit) = clippy_hits
1146                    .iter()
1147                    .find(|hit| clippy_hit_matches_fact(&workspace.root, hit, &fact))
1148                {
1149                    candidate.evidence.additional.push(Evidence {
1150                        description: format!(
1151                            "`clippy::fn_params_excessive_bools` independently flagged \
1152                             `{}`'s parameter list (lines {}-{}), corroborating this from a \
1153                             separate tool.",
1154                            fact.item_path, hit.line_start, hit.line_end
1155                        ),
1156                        locations: vec![EvidenceLocation::new(
1157                            fact.file.clone(),
1158                            fact.item_path.clone(),
1159                        )],
1160                    });
1161                }
1162                candidates.push(candidate);
1163            }
1164        });
1165    }
1166    candidates
1167}
1168
1169/// Whether a `clippy::fn_params_excessive_bools` hit corroborates `fact`:
1170/// same file, normalized relative to `workspace_root` (clippy's
1171/// `file_name` is relative to the directory `cargo clippy` was invoked in,
1172/// typically the workspace root, mirroring
1173/// [`crate::advisory::coverage::parse_lcov`]'s `SF:`-path normalization), and an
1174/// overlapping line range — clippy's span may cover just the parameter
1175/// list while `fact`'s span covers the whole item, so overlap (not
1176/// equality) is what proves they're the same function.
1177fn clippy_hit_matches_fact(
1178    workspace_root: &Path,
1179    hit: &ClippyBoolParamsHit,
1180    fact: &BoolClusterFact,
1181) -> bool {
1182    let fact_relative = fact.file.strip_prefix(workspace_root).unwrap_or(&fact.file);
1183    let hit_normalized: PathBuf = hit
1184        .file
1185        .components()
1186        .filter(|component| !matches!(component, std::path::Component::CurDir))
1187        .collect();
1188    fact_relative == hit_normalized
1189        && fact.line_start <= hit.line_end
1190        && hit.line_start <= fact.line_end
1191}
1192
1193/// One function whose signature/body satisfy both
1194/// [`boolean_state_cluster_candidates`] signals.
1195struct BoolClusterFact {
1196    file: PathBuf,
1197    item_path: String,
1198    bool_params: BTreeSet<String>,
1199    combo_hits: Vec<String>,
1200    line_start: usize,
1201    line_end: usize,
1202}
1203
1204fn build_boolean_state_cluster_candidate(
1205    krate: &CrateInfo,
1206    fact: &BoolClusterFact,
1207) -> PatternCandidate {
1208    let scope = crate_scope(krate, vec![fact.item_path.clone()]);
1209
1210    let location = EvidenceLocation::new(fact.file.clone(), fact.item_path.clone());
1211
1212    let bool_params: Vec<&String> = fact.bool_params.iter().collect();
1213    let primary = Evidence {
1214        description: format!(
1215            "`{}` has {} `bool`-typed parameters: {}.",
1216            fact.item_path,
1217            fact.bool_params.len(),
1218            bool_params
1219                .iter()
1220                .map(|name| name.as_str())
1221                .collect::<Vec<_>>()
1222                .join(", ")
1223        ),
1224        locations: vec![location.clone()],
1225    };
1226    let independent = Evidence {
1227        description: format!(
1228            "The function body combines at least two of these bool parameters together in a \
1229             condition, e.g. `{}`.",
1230            fact.combo_hits.join("`, `")
1231        ),
1232        locations: vec![location],
1233    };
1234
1235    let evidence_identities: Vec<String> = std::iter::once(fact.item_path.clone())
1236        .chain(fact.bool_params.iter().cloned())
1237        .chain(fact.combo_hits.iter().cloned())
1238        .collect();
1239    pattern_candidate! {
1240        pattern: RustPattern::OptionsStruct,
1241        scope,
1242        evidence_identities,
1243        {
1244        evidence: CorroboratedEvidence::new(primary, independent),
1245        preconditions: vec![Precondition {
1246            description: format!(
1247                "`{}` nimmt mehrere Bool-Parameter entgegen und prüft mindestens eine \
1248                 Kombination davon gemeinsam im Funktionskörper.",
1249                fact.item_path
1250            ),
1251        }],
1252        contraindications: vec![
1253            Contraindication {
1254                description: "Wenige, klar benannte, unabhängig verwendete Bool-Flags können \
1255                    lesbarer sein als ein zusätzlicher Enum-/Options-Typ."
1256                    .to_string(),
1257            },
1258            Contraindication {
1259                description: "Wenn die Kombinationsprüfung nur eine einmalige \
1260                    Eingabevalidierung ist (kein wiederholtes Muster), kann ein zusätzlicher Typ \
1261                    Overkill sein."
1262                    .to_string(),
1263            },
1264        ],
1265        migration: vec![
1266            MigrationStep {
1267                step: 1,
1268                description: "Gültige Optionen/Zustände benennen (Options-Struct vs. \
1269                    Zustands-Enum, je nach Anzahl gültiger Kombinationen)."
1270                    .to_string(),
1271                affected_paths: Vec::new(),
1272            },
1273            MigrationStep {
1274                step: 2,
1275                description: "Den gewählten Typ definieren.".to_string(),
1276                affected_paths: Vec::new(),
1277            },
1278            MigrationStep {
1279                step: 3,
1280                description: "Konstruktor-/Funktionsparameterliste ersetzen.".to_string(),
1281                affected_paths: vec![fact.file.clone()],
1282            },
1283            MigrationStep {
1284                step: 4,
1285                description: "Call-Sites aktualisieren.".to_string(),
1286                affected_paths: vec![fact.file.clone()],
1287            },
1288        ],
1289            related_findings: Vec::new(),
1290        }
1291    }
1292}
1293
1294/// Whether `ty` is `bool`, matched structurally (same caveat as
1295/// [`primitive_type_name`]: no type resolution).
1296fn is_bool_type(ty: &syn::Type) -> bool {
1297    matches!(
1298        ty,
1299        syn::Type::Path(type_path)
1300            if type_path.qself.is_none()
1301                && type_path.path.segments.last().is_some_and(|segment| {
1302                    segment.ident == "bool" && matches!(segment.arguments, syn::PathArguments::None)
1303                })
1304    )
1305}
1306
1307/// Collects [`BoolClusterFact`]s from `fn` items (free functions and `impl`
1308/// methods, any visibility) in one source file.
1309struct BooleanStateClusterVisitor<'a> {
1310    file: &'a Path,
1311    self_type: Option<String>,
1312    facts: Vec<BoolClusterFact>,
1313}
1314
1315impl BooleanStateClusterVisitor<'_> {
1316    fn record_fn(
1317        &mut self,
1318        name: &str,
1319        sig: &syn::Signature,
1320        block: &syn::Block,
1321        span: proc_macro2::Span,
1322    ) {
1323        let item_path = qualified_item_path(self.self_type.as_deref(), name);
1324        let bool_params: BTreeSet<String> = sig
1325            .inputs
1326            .iter()
1327            .filter_map(|input| {
1328                let (name, ty) = typed_ident_arg(input)?;
1329                is_bool_type(ty).then_some(name)
1330            })
1331            .collect();
1332        if bool_params.len() < 3 {
1333            return;
1334        }
1335        let combo_hits = body_boolean_combo_hits(block, &bool_params);
1336        if combo_hits.is_empty() {
1337            return;
1338        }
1339        self.facts.push(BoolClusterFact {
1340            file: self.file.to_path_buf(),
1341            item_path,
1342            bool_params,
1343            combo_hits,
1344            line_start: span.start().line,
1345            line_end: span.end().line,
1346        });
1347    }
1348}
1349
1350impl<'ast> Visit<'ast> for BooleanStateClusterVisitor<'_> {
1351    fn visit_item_fn(&mut self, node: &'ast syn::ItemFn) {
1352        self.record_fn(
1353            &node.sig.ident.to_string(),
1354            &node.sig,
1355            &node.block,
1356            node.span(),
1357        );
1358        syn::visit::visit_item_fn(self, node);
1359    }
1360
1361    visit_item_impl_with_self_type!();
1362
1363    fn visit_impl_item_fn(&mut self, node: &'ast syn::ImplItemFn) {
1364        self.record_fn(
1365            &node.sig.ident.to_string(),
1366            &node.sig,
1367            &node.block,
1368            node.span(),
1369        );
1370        syn::visit::visit_impl_item_fn(self, node);
1371    }
1372}
1373
1374/// The subset of `params` referenced anywhere within `expr` (used to check
1375/// how many distinct bool parameters a condition/match-scrutinee combines).
1376fn referenced_params_in_expr(expr: &syn::Expr, params: &BTreeSet<String>) -> BTreeSet<String> {
1377    struct Collector<'a> {
1378        params: &'a BTreeSet<String>,
1379        found: BTreeSet<String>,
1380    }
1381    impl<'ast> Visit<'ast> for Collector<'_> {
1382        fn visit_expr_path(&mut self, node: &'ast syn::ExprPath) {
1383            if let Some(ident) = node.path.get_ident() {
1384                let name = ident.to_string();
1385                if self.params.contains(&name) {
1386                    self.found.insert(name);
1387                }
1388            }
1389            syn::visit::visit_expr_path(self, node);
1390        }
1391    }
1392    let mut collector = Collector {
1393        params,
1394        found: BTreeSet::new(),
1395    };
1396    collector.visit_expr(expr);
1397    collector.found
1398}
1399
1400/// Rendered source text of every `if`/`match` in `block` whose
1401/// condition/scrutinee combines at least two of `bool_params` — see
1402/// [`boolean_state_cluster_candidates`]'s signal 2.
1403fn body_boolean_combo_hits(block: &syn::Block, bool_params: &BTreeSet<String>) -> Vec<String> {
1404    use quote::ToTokens;
1405
1406    struct ComboVisitor<'a> {
1407        bool_params: &'a BTreeSet<String>,
1408        hits: Vec<String>,
1409    }
1410    impl<'ast> Visit<'ast> for ComboVisitor<'_> {
1411        fn visit_expr_if(&mut self, node: &'ast syn::ExprIf) {
1412            if referenced_params_in_expr(&node.cond, self.bool_params).len() >= 2 {
1413                self.hits.push(node.cond.to_token_stream().to_string());
1414            }
1415            syn::visit::visit_expr_if(self, node);
1416        }
1417
1418        fn visit_expr_match(&mut self, node: &'ast syn::ExprMatch) {
1419            if referenced_params_in_expr(&node.expr, self.bool_params).len() >= 2 {
1420                self.hits.push(node.expr.to_token_stream().to_string());
1421            }
1422            syn::visit::visit_expr_match(self, node);
1423        }
1424    }
1425    let mut visitor = ComboVisitor {
1426        bool_params,
1427        hits: Vec::new(),
1428    };
1429    visitor.visit_block(block);
1430    visitor.hits
1431}
1432
1433/// `public-invariant-bypass` (todo.md §16.3): public fields are freely
1434/// writable, but consumers assume a value range or a combination of fields
1435/// holds together.
1436///
1437/// **This is a deliberately narrower, Fast-Tier-reachable subset of the full
1438/// rule from todo.md §16.3** (which is listed there as "Deep" tier) —
1439/// deliberately *without* the full rule's consumer-side analysis. This
1440/// implementation only looks at:
1441///
1442/// 1. **Primary (structural)** — a `pub struct` with ≥2 `pub` fields, without
1443///    a `#[non_exhaustive]` attribute, in the same crate.
1444/// 2. **Independent (control flow, same crate)** — at least one
1445///    constructor-shaped function for that struct type (a `pub fn` whose
1446///    return type is `Self`/the struct name, optionally wrapped in
1447///    `Result<..>`) contains a condition that jointly validates ≥2 of the
1448///    struct's `pub` fields — matched via parameter names that equal field
1449///    names (same name-matching heuristic as `boolean-state-cluster`) — and
1450///    whose then-branch leads to `Err(...)`/`panic!(...)`, or an
1451///    `assert!(...)` referencing ≥2 of those parameters.
1452///
1453/// Only structs satisfying both produce a candidate — exactly one per
1454/// struct, referencing every contributing constructor.
1455fn public_invariant_bypass_candidates(workspace: &Workspace) -> Vec<PatternCandidate> {
1456    let mut candidates = Vec::new();
1457    for krate in &workspace.crates {
1458        let mut structs: BTreeMap<String, PubStructFact> = BTreeMap::new();
1459        for_each_parsed_source(krate, |file, ast| {
1460            let mut visitor = PubStructVisitor {
1461                file,
1462                structs: BTreeMap::new(),
1463            };
1464            visitor.visit_file(ast);
1465            structs.extend(visitor.structs);
1466        });
1467        if structs.is_empty() {
1468            continue;
1469        }
1470
1471        let mut constructor_hits: BTreeMap<String, Vec<ConstructorFact>> = BTreeMap::new();
1472        for_each_parsed_source(krate, |file, ast| {
1473            let mut visitor = ConstructorVisitor {
1474                file,
1475                self_type: None,
1476                structs: &structs,
1477                hits: BTreeMap::new(),
1478            };
1479            visitor.visit_file(ast);
1480            for (name, mut facts) in visitor.hits {
1481                constructor_hits.entry(name).or_default().append(&mut facts);
1482            }
1483        });
1484
1485        for (name, fact) in &structs {
1486            let Some(ctor_facts) = constructor_hits.get(name) else {
1487                continue;
1488            };
1489            if ctor_facts.is_empty() {
1490                continue;
1491            }
1492            candidates.push(build_public_invariant_bypass_candidate(
1493                krate, fact, ctor_facts,
1494            ));
1495        }
1496    }
1497    candidates
1498}
1499
1500/// A crate-local `pub struct` with ≥2 `pub` fields and no `#[non_exhaustive]`
1501/// attribute (see [`public_invariant_bypass_candidates`]'s signal 1).
1502struct PubStructFact {
1503    file: PathBuf,
1504    name: String,
1505    fields: BTreeSet<String>,
1506}
1507
1508/// Whether any attribute in `attrs` is `#[non_exhaustive]`.
1509fn has_non_exhaustive_attr(attrs: &[syn::Attribute]) -> bool {
1510    attrs
1511        .iter()
1512        .any(|attr| attr.path().is_ident("non_exhaustive"))
1513}
1514
1515/// Collects [`PubStructFact`]s from `pub struct` items in one source file.
1516struct PubStructVisitor<'a> {
1517    file: &'a Path,
1518    structs: BTreeMap<String, PubStructFact>,
1519}
1520
1521impl<'ast> Visit<'ast> for PubStructVisitor<'_> {
1522    fn visit_item_struct(&mut self, node: &'ast syn::ItemStruct) {
1523        if matches!(node.vis, syn::Visibility::Public(_)) && !has_non_exhaustive_attr(&node.attrs) {
1524            let fields: BTreeSet<String> = node
1525                .fields
1526                .iter()
1527                .filter(|field| matches!(field.vis, syn::Visibility::Public(_)))
1528                .filter_map(|field| field.ident.as_ref().map(ToString::to_string))
1529                .collect();
1530            if fields.len() >= 2 {
1531                let name = node.ident.to_string();
1532                self.structs.insert(
1533                    name.clone(),
1534                    PubStructFact {
1535                        file: self.file.to_path_buf(),
1536                        name,
1537                        fields,
1538                    },
1539                );
1540            }
1541        }
1542        syn::visit::visit_item_struct(self, node);
1543    }
1544}
1545
1546/// One constructor-shaped function corroborating a [`PubStructFact`] (see
1547/// [`public_invariant_bypass_candidates`]'s signal 2).
1548struct ConstructorFact {
1549    file: PathBuf,
1550    item_path: String,
1551    hits: Vec<String>,
1552}
1553
1554/// Collects [`ConstructorFact`]s, keyed by struct name, from `pub fn` items
1555/// (free functions and `impl` methods) in one source file whose return type
1556/// resolves to a struct already recorded in `structs`.
1557struct ConstructorVisitor<'a> {
1558    file: &'a Path,
1559    self_type: Option<String>,
1560    structs: &'a BTreeMap<String, PubStructFact>,
1561    hits: BTreeMap<String, Vec<ConstructorFact>>,
1562}
1563
1564impl ConstructorVisitor<'_> {
1565    fn record_fn(&mut self, name: &str, sig: &syn::Signature, block: &syn::Block) {
1566        let syn::ReturnType::Type(_, ty) = &sig.output else {
1567            return;
1568        };
1569        let Some(struct_name) = resolved_struct_name(ty, self.self_type.as_deref()) else {
1570            return;
1571        };
1572        let Some(fact) = self.structs.get(&struct_name) else {
1573            return;
1574        };
1575        let param_names: BTreeSet<String> = sig
1576            .inputs
1577            .iter()
1578            .filter_map(|input| typed_ident_arg(input).map(|(name, _)| name))
1579            .collect();
1580        let matching_params: BTreeSet<String> =
1581            param_names.intersection(&fact.fields).cloned().collect();
1582        if matching_params.len() < 2 {
1583            return;
1584        }
1585        let hits = constructor_combo_hits(block, &matching_params);
1586        if hits.is_empty() {
1587            return;
1588        }
1589        let item_path = qualified_item_path(self.self_type.as_deref(), name);
1590        self.hits
1591            .entry(struct_name)
1592            .or_default()
1593            .push(ConstructorFact {
1594                file: self.file.to_path_buf(),
1595                item_path,
1596                hits,
1597            });
1598    }
1599}
1600
1601impl<'ast> Visit<'ast> for ConstructorVisitor<'_> {
1602    visit_pub_fns_via_record_fn!();
1603    visit_item_impl_with_self_type!();
1604}
1605
1606/// The struct name `ty` resolves to, if any: `Self` (resolved via
1607/// `self_type`, i.e. only within an `impl` block), the struct name directly,
1608/// or one level of `Result<T, _>` unwrapped around either — matched
1609/// structurally, no type resolution (same caveat as [`primitive_type_name`]).
1610fn resolved_struct_name(ty: &syn::Type, self_type: Option<&str>) -> Option<String> {
1611    let syn::Type::Path(type_path) = ty else {
1612        return None;
1613    };
1614    let segment = type_path.path.segments.last()?;
1615    let name = segment.ident.to_string();
1616    if name == "Self" {
1617        return self_type.map(str::to_string);
1618    }
1619    if name == "Result"
1620        && let syn::PathArguments::AngleBracketed(generics) = &segment.arguments
1621        && let Some(syn::GenericArgument::Type(inner)) = generics.args.first()
1622    {
1623        return resolved_struct_name(inner, self_type);
1624    }
1625    Some(name)
1626}
1627
1628/// Rendered source text of every `if`/`assert!` in `block` that jointly
1629/// validates ≥2 of `matching_params` — see
1630/// [`public_invariant_bypass_candidates`]'s signal 2.
1631fn constructor_combo_hits(block: &syn::Block, matching_params: &BTreeSet<String>) -> Vec<String> {
1632    use quote::ToTokens;
1633
1634    struct ComboVisitor<'a> {
1635        matching_params: &'a BTreeSet<String>,
1636        hits: Vec<String>,
1637    }
1638    impl<'ast> Visit<'ast> for ComboVisitor<'_> {
1639        fn visit_expr_if(&mut self, node: &'ast syn::ExprIf) {
1640            if referenced_params_in_expr(&node.cond, self.matching_params).len() >= 2
1641                && block_leads_to_error_path(&node.then_branch)
1642            {
1643                self.hits.push(node.cond.to_token_stream().to_string());
1644            }
1645            syn::visit::visit_expr_if(self, node);
1646        }
1647
1648        fn visit_macro(&mut self, node: &'ast syn::Macro) {
1649            if node.path.is_ident("assert")
1650                && tokens_reference_at_least_two_idents(&node.tokens, self.matching_params)
1651            {
1652                self.hits.push(node.tokens.to_string());
1653            }
1654            syn::visit::visit_macro(self, node);
1655        }
1656    }
1657    let mut visitor = ComboVisitor {
1658        matching_params,
1659        hits: Vec::new(),
1660    };
1661    visitor.visit_block(block);
1662    visitor.hits
1663}
1664
1665/// Whether `tokens` references at least two distinct identifiers from
1666/// `idents` anywhere, including inside nested groups (used for `assert!`
1667/// macro arguments, same rationale as [`tokens_reference_ident`]).
1668fn tokens_reference_at_least_two_idents(
1669    tokens: &proc_macro2::TokenStream,
1670    idents: &BTreeSet<String>,
1671) -> bool {
1672    fn collect(
1673        tokens: proc_macro2::TokenStream,
1674        idents: &BTreeSet<String>,
1675        found: &mut BTreeSet<String>,
1676    ) {
1677        for tree in tokens {
1678            match tree {
1679                proc_macro2::TokenTree::Ident(node) => {
1680                    let name = node.to_string();
1681                    if idents.contains(&name) {
1682                        found.insert(name);
1683                    }
1684                }
1685                proc_macro2::TokenTree::Group(group) => collect(group.stream(), idents, found),
1686                _ => {}
1687            }
1688        }
1689    }
1690    let mut found = BTreeSet::new();
1691    collect(tokens.clone(), idents, &mut found);
1692    found.len() >= 2
1693}
1694
1695fn build_public_invariant_bypass_candidate(
1696    krate: &CrateInfo,
1697    fact: &PubStructFact,
1698    ctor_facts: &[ConstructorFact],
1699) -> PatternCandidate {
1700    let scope = crate_scope(krate, vec![fact.name.clone()]);
1701
1702    let primary_locations: Vec<EvidenceLocation> = fact
1703        .fields
1704        .iter()
1705        .map(|field| EvidenceLocation::new(fact.file.clone(), format!("{}::{field}", fact.name)))
1706        .collect();
1707    let field_list: Vec<&str> = fact.fields.iter().map(String::as_str).collect();
1708
1709    let primary = Evidence {
1710        description: format!(
1711            "`pub struct {}` in crate `{}` has {} `pub` field(s) ({}) and carries no \
1712             `#[non_exhaustive]` attribute.",
1713            fact.name,
1714            krate.name,
1715            fact.fields.len(),
1716            field_list.join(", ")
1717        ),
1718        locations: primary_locations,
1719    };
1720
1721    let mut independent_locations: Vec<EvidenceLocation> = ctor_facts
1722        .iter()
1723        .map(|ctor| EvidenceLocation::new(ctor.file.clone(), ctor.item_path.clone()))
1724        .collect();
1725    sort_evidence_locations(&mut independent_locations);
1726
1727    let combo_texts: Vec<&str> = ctor_facts
1728        .iter()
1729        .flat_map(|ctor| ctor.hits.iter())
1730        .map(String::as_str)
1731        .collect();
1732    let independent = Evidence {
1733        description: format!(
1734            "At least one constructor for `{}` already validates a combination of ≥2 of these \
1735             `pub` fields together, e.g. `{}`.",
1736            fact.name,
1737            combo_texts.join("`, `")
1738        ),
1739        locations: independent_locations,
1740    };
1741
1742    let evidence_identities: Vec<String> = std::iter::once(fact.name.clone())
1743        .chain(fact.fields.iter().cloned())
1744        .chain(ctor_facts.iter().map(|ctor| ctor.item_path.clone()))
1745        .collect();
1746    let mut affected_paths: Vec<PathBuf> = std::iter::once(fact.file.clone())
1747        .chain(ctor_facts.iter().map(|ctor| ctor.file.clone()))
1748        .collect();
1749    affected_paths.sort();
1750    affected_paths.dedup();
1751
1752    pattern_candidate! {
1753        pattern: RustPattern::SmartConstructor,
1754        scope,
1755        evidence_identities,
1756        {
1757        evidence: CorroboratedEvidence::new(primary, independent),
1758        preconditions: vec![Precondition {
1759            description: format!(
1760                "`{}` hat mindestens zwei öffentliche Felder und mindestens ein Konstruktor \
1761                 validiert bereits eine Kombination davon.",
1762                fact.name
1763            ),
1764        }],
1765        contraindications: vec![
1766            Contraindication {
1767                description: "Wenn der Struct primär als reine Datenhülle ohne Invarianten \
1768                    außerhalb des Konstruktors gedacht ist, kann öffentlicher Feldzugriff bewusst \
1769                    sein."
1770                    .to_string(),
1771            },
1772            Contraindication {
1773                description: "Private Felder erzwingen Getter-/Setter-Boilerplate, was bei \
1774                    internen/Test-only-Structs mehr kostet als nützt."
1775                    .to_string(),
1776            },
1777        ],
1778        migration: vec![
1779            MigrationStep {
1780                step: 1,
1781                description: "Felder privat machen.".to_string(),
1782                affected_paths: vec![fact.file.clone()],
1783            },
1784            MigrationStep {
1785                step: 2,
1786                description:
1787                    "Bestehenden Konstruktor als einzigen Erzeugungsweg belassen/ausbauen."
1788                        .to_string(),
1789                affected_paths: Vec::new(),
1790            },
1791            MigrationStep {
1792                step: 3,
1793                description: "Falls Änderungen nach Konstruktion nötig sind, validierte Setter \
1794                    statt direkter Feldzuweisung ergänzen."
1795                    .to_string(),
1796                affected_paths: Vec::new(),
1797            },
1798            MigrationStep {
1799                step: 4,
1800                description: "Call-Sites, die Struct-Update-Syntax nutzen, anpassen.".to_string(),
1801                affected_paths,
1802            },
1803        ],
1804            related_findings: Vec::new(),
1805        }
1806    }
1807}
1808
1809/// `manual-resource-lifecycle` (todo.md §16.3): recurring acquire/release-,
1810/// register/unregister-, or setup/cleanup pairs on several control-flow
1811/// paths.
1812///
1813/// **This is a deliberately narrower, Fast-Tier-reachable subset of the full
1814/// rule from todo.md §16.3** (which is listed there as "Deep" tier), with
1815/// especially strong contraindications: todo.md §16.4 only allows this
1816/// recommendation "wenn Besitz und Lebensdauer der Ressource eindeutig an
1817/// einen Guard gebunden werden können" — this Fast-Tier heuristic cannot
1818/// prove that, and the contraindications say so explicitly. This
1819/// implementation only looks at:
1820///
1821/// 1. **Primary (structural)** — within one function, both a call whose
1822///    ident matches a fixed "acquire" name pattern (`register`, `acquire`,
1823///    `open`, `lock`, `begin`, `start`, `connect`, `subscribe`) and a call
1824///    matching the corresponding "release" pattern (`unregister`, `release`,
1825///    `close`, `unlock`, `end`, `stop`, `disconnect`, `unsubscribe`) appear —
1826///    detected purely by call identifier, no type resolution. This can
1827///    falsely couple unrelated calls that merely share a common name.
1828/// 2. **Independent (crate-wide)** — the entire crate contains no
1829///    `impl Drop for ...` block at all, i.e. no evidence this codebase
1830///    already uses RAII guards as a pattern.
1831///
1832/// Only when both signals hold does exactly one candidate per crate emerge,
1833/// referencing every contributing function.
1834fn manual_resource_lifecycle_candidates(workspace: &Workspace) -> Vec<PatternCandidate> {
1835    let mut candidates = Vec::new();
1836    for krate in &workspace.crates {
1837        let mut has_drop_impl = false;
1838        let mut hits: Vec<EvidenceLocation> = Vec::new();
1839        for_each_parsed_source(krate, |file, ast| {
1840            if file_has_drop_impl(ast) {
1841                has_drop_impl = true;
1842            }
1843            let mut visitor = ResourceLifecycleVisitor {
1844                file,
1845                self_type: None,
1846                hits: Vec::new(),
1847            };
1848            visitor.visit_file(ast);
1849            hits.append(&mut visitor.hits);
1850        });
1851        if has_drop_impl || hits.is_empty() {
1852            continue;
1853        }
1854        candidates.push(build_manual_resource_lifecycle_candidate(krate, &hits));
1855    }
1856    candidates
1857}
1858
1859const ACQUIRE_CALL_NAMES: &[&str] = &[
1860    "register",
1861    "acquire",
1862    "open",
1863    "lock",
1864    "begin",
1865    "start",
1866    "connect",
1867    "subscribe",
1868];
1869
1870const RELEASE_CALL_NAMES: &[&str] = &[
1871    "unregister",
1872    "release",
1873    "close",
1874    "unlock",
1875    "end",
1876    "stop",
1877    "disconnect",
1878    "unsubscribe",
1879];
1880
1881/// Whether `ast` contains an `impl Drop for ...` block anywhere (see
1882/// [`manual_resource_lifecycle_candidates`]'s signal 2).
1883fn file_has_drop_impl(ast: &syn::File) -> bool {
1884    struct DropFinder {
1885        found: bool,
1886    }
1887    impl<'ast> Visit<'ast> for DropFinder {
1888        fn visit_item_impl(&mut self, node: &'ast syn::ItemImpl) {
1889            if impl_trait_is(node, "Drop") {
1890                self.found = true;
1891            }
1892            syn::visit::visit_item_impl(self, node);
1893        }
1894    }
1895    let mut finder = DropFinder { found: false };
1896    finder.visit_file(ast);
1897    finder.found
1898}
1899
1900/// Whether `block` contains at least one call matching
1901/// [`ACQUIRE_CALL_NAMES`] and at least one call matching
1902/// [`RELEASE_CALL_NAMES`], by call identifier only.
1903fn acquire_and_release_calls(block: &syn::Block) -> (bool, bool) {
1904    struct Finder {
1905        acquire: bool,
1906        release: bool,
1907    }
1908    impl Finder {
1909        fn observe(&mut self, name: &str) {
1910            if ACQUIRE_CALL_NAMES.contains(&name) {
1911                self.acquire = true;
1912            }
1913            if RELEASE_CALL_NAMES.contains(&name) {
1914                self.release = true;
1915            }
1916        }
1917    }
1918    impl<'ast> Visit<'ast> for Finder {
1919        fn visit_expr_method_call(&mut self, node: &'ast syn::ExprMethodCall) {
1920            self.observe(&node.method.to_string());
1921            syn::visit::visit_expr_method_call(self, node);
1922        }
1923
1924        fn visit_expr_call(&mut self, node: &'ast syn::ExprCall) {
1925            if let syn::Expr::Path(path) = node.func.as_ref()
1926                && let Some(segment) = path.path.segments.last()
1927            {
1928                self.observe(&segment.ident.to_string());
1929            }
1930            syn::visit::visit_expr_call(self, node);
1931        }
1932    }
1933    let mut finder = Finder {
1934        acquire: false,
1935        release: false,
1936    };
1937    finder.visit_block(block);
1938    (finder.acquire, finder.release)
1939}
1940
1941/// Collects one [`EvidenceLocation`] per function (free function or `impl`
1942/// method, any visibility) in one source file whose body contains both an
1943/// acquire- and a release-shaped call.
1944struct ResourceLifecycleVisitor<'a> {
1945    file: &'a Path,
1946    self_type: Option<String>,
1947    hits: Vec<EvidenceLocation>,
1948}
1949
1950impl ResourceLifecycleVisitor<'_> {
1951    fn record_fn(&mut self, name: &str, block: &syn::Block) {
1952        let (has_acquire, has_release) = acquire_and_release_calls(block);
1953        if !has_acquire || !has_release {
1954            return;
1955        }
1956        let item_path = qualified_item_path(self.self_type.as_deref(), name);
1957        self.hits
1958            .push(EvidenceLocation::new(self.file.to_path_buf(), item_path));
1959    }
1960}
1961
1962impl<'ast> Visit<'ast> for ResourceLifecycleVisitor<'_> {
1963    fn visit_item_fn(&mut self, node: &'ast syn::ItemFn) {
1964        self.record_fn(&node.sig.ident.to_string(), &node.block);
1965        syn::visit::visit_item_fn(self, node);
1966    }
1967
1968    visit_item_impl_with_self_type!();
1969
1970    fn visit_impl_item_fn(&mut self, node: &'ast syn::ImplItemFn) {
1971        self.record_fn(&node.sig.ident.to_string(), &node.block);
1972        syn::visit::visit_impl_item_fn(self, node);
1973    }
1974}
1975
1976fn build_manual_resource_lifecycle_candidate(
1977    krate: &CrateInfo,
1978    hits: &[EvidenceLocation],
1979) -> PatternCandidate {
1980    let modules: Vec<String> = hits
1981        .iter()
1982        .filter_map(|hit| hit.item_path.clone())
1983        .collect();
1984    let scope = crate_scope(krate, modules);
1985
1986    let mut primary_locations = hits.to_vec();
1987    sort_evidence_locations(&mut primary_locations);
1988
1989    let primary = Evidence {
1990        description: format!(
1991            "{} function(s) in crate `{}` call both an acquire-shaped operation (e.g. \
1992             `register`/`acquire`/`open`/`lock`/`begin`/`start`/`connect`/`subscribe`) and a \
1993             release-shaped counterpart (e.g. \
1994             `unregister`/`release`/`close`/`unlock`/`end`/`stop`/`disconnect`/`unsubscribe`) by \
1995             call name.",
1996            hits.len(),
1997            krate.name
1998        ),
1999        locations: primary_locations,
2000    };
2001    let independent = Evidence {
2002        description: format!(
2003            "Crate `{}` contains no `impl Drop for ...` block anywhere — no evidence this \
2004             codebase already uses RAII guards as a pattern.",
2005            krate.name
2006        ),
2007        locations: Vec::new(),
2008    };
2009
2010    let evidence_identities: Vec<String> = location_identities(hits);
2011    let mut affected_paths: Vec<PathBuf> = hits.iter().map(|hit| hit.file.clone()).collect();
2012    affected_paths.sort();
2013    affected_paths.dedup();
2014
2015    pattern_candidate! {
2016        pattern: RustPattern::RaiiGuard,
2017        scope,
2018        evidence_identities,
2019        {
2020        evidence: CorroboratedEvidence::new(primary, independent),
2021        preconditions: vec![Precondition {
2022            description: format!(
2023                "Crate `{}` enthält mindestens ein Acquire-/Release-Aufrufpaar innerhalb einer \
2024                 Funktion, aber keine `Drop`-Implementierung.",
2025                krate.name
2026            ),
2027        }],
2028        contraindications: vec![
2029            Contraindication {
2030                description: "Diese Heuristik kann nicht belegen, dass Besitz und Lebensdauer \
2031                    der Ressource eindeutig an einen einzelnen Guard gebunden werden können — das \
2032                    ist Voraussetzung für einen sinnvollen RAII-Guard, nicht nur Namensähnlichkeit."
2033                    .to_string(),
2034            },
2035            Contraindication {
2036                description: "Acquire/Release könnten unabhängige, zufällig gleich benannte \
2037                    Operationen auf unterschiedlichen Objekten sein."
2038                    .to_string(),
2039            },
2040            Contraindication {
2041                description: "Bei seltener, einmaliger Nutzung kann der Boilerplate eines \
2042                    eigenen Guard-Typs mehr kosten als eine sorgfältige manuelle Passung."
2043                    .to_string(),
2044            },
2045        ],
2046        migration: vec![
2047            MigrationStep {
2048                step: 1,
2049                description: "Ressourcentyp und Lebensdauer-Bindung manuell bestätigen (nicht \
2050                    automatisierbar)."
2051                    .to_string(),
2052                affected_paths: Vec::new(),
2053            },
2054            MigrationStep {
2055                step: 2,
2056                description: "Guard-Struct mit dem Handle als Feld definieren.".to_string(),
2057                affected_paths: Vec::new(),
2058            },
2059            MigrationStep {
2060                step: 3,
2061                description: "`Drop::drop` mit der Release-Logik implementieren.".to_string(),
2062                affected_paths: Vec::new(),
2063            },
2064            MigrationStep {
2065                step: 4,
2066                description: "Acquire-Stelle so umbauen, dass sie den Guard statt des rohen \
2067                    Handles zurückgibt."
2068                    .to_string(),
2069                affected_paths,
2070            },
2071        ],
2072            related_findings: Vec::new(),
2073        }
2074    }
2075}
2076
2077#[cfg(test)]
2078mod tests {
2079    use super::*;
2080    use crate::finding::{EvidenceClass, Location, OneBasedLine, Origin, Severity};
2081    use crate::ingest::{SourceFile, SourceKind};
2082    use crate::test_util::TempDir;
2083
2084    fn workspace_with_crate(root: PathBuf, files: Vec<PathBuf>) -> Workspace {
2085        Workspace {
2086            root: root.clone(),
2087            crates: vec![CrateInfo {
2088                name: "fixture".to_string(),
2089                version: "0.1.0".to_string(),
2090                manifest_path: root.join("Cargo.toml"),
2091                root,
2092                source_files: files
2093                    .into_iter()
2094                    .map(|path| SourceFile {
2095                        path,
2096                        kind: SourceKind::Authored,
2097                    })
2098                    .collect(),
2099                entry_points: Vec::new(),
2100                dependencies: Vec::new(),
2101            }],
2102        }
2103    }
2104
2105    fn catch_all_error_finding(file: &Path, item_path: &str, line: usize) -> Finding {
2106        Finding::new(
2107            format!("catch-all-error:{}:{line}:1", file.display()),
2108            crate::rules::slop::CATCH_ALL_ERROR_RULE,
2109            Severity::Warn,
2110            Location {
2111                file: file.to_path_buf(),
2112                line: OneBasedLine::new(line).unwrap(),
2113                item_path: item_path.to_string(),
2114            },
2115            EvidenceClass::DerivedFact,
2116            Origin::Code,
2117            None,
2118        )
2119    }
2120
2121    /// (a) Two `catch-all-error` findings plus a crate-local typed error ⇒
2122    /// exactly one candidate, referencing both findings, with both evidence
2123    /// slots populated.
2124    #[test]
2125    fn two_symptoms_plus_a_typed_error_produce_one_candidate() {
2126        let dir = TempDir::new("pattern-corroborated");
2127        let boundary = dir.join("boundary.rs");
2128        std::fs::write(
2129            &boundary,
2130            "pub fn a() -> Result<(), Box<dyn std::error::Error>> { Ok(()) }\n\
2131             pub fn b() -> Result<(), Box<dyn std::error::Error>> { Ok(()) }\n",
2132        )
2133        .unwrap();
2134        let errors = dir.join("errors.rs");
2135        std::fs::write(&errors, "enum FooError { Bad }\n").unwrap();
2136
2137        let workspace = workspace_with_crate(dir.to_path_buf(), vec![boundary.clone(), errors]);
2138        let findings = vec![
2139            catch_all_error_finding(&boundary, "a", 1),
2140            catch_all_error_finding(&boundary, "b", 2),
2141        ];
2142
2143        let candidates = analyze_workspace(&workspace, &findings);
2144        assert_eq!(candidates.len(), 1);
2145        let candidate = &candidates[0];
2146        assert_eq!(candidate.pattern, RustPattern::DomainError);
2147        assert_eq!(candidate.scope.krate, "fixture");
2148        assert_eq!(candidate.related_findings.len(), 2);
2149        assert!(!candidate.evidence.primary.locations.is_empty());
2150        assert!(!candidate.evidence.independent.locations.is_empty());
2151        assert!(candidate.evidence.primary.description.contains('2'));
2152        assert!(!candidate.contraindications.is_empty());
2153        assert!(candidate.migration.len() >= 2);
2154    }
2155
2156    /// (b) Only one `catch-all-error` finding, even with a typed error
2157    /// present ⇒ no candidate (a single symptom is not a pattern).
2158    #[test]
2159    fn a_single_finding_is_below_threshold() {
2160        let dir = TempDir::new("pattern-single-finding");
2161        let boundary = dir.join("boundary.rs");
2162        std::fs::write(
2163            &boundary,
2164            "pub fn a() -> Result<(), Box<dyn std::error::Error>> { Ok(()) }\n",
2165        )
2166        .unwrap();
2167        let errors = dir.join("errors.rs");
2168        std::fs::write(&errors, "enum FooError { Bad }\n").unwrap();
2169
2170        let workspace = workspace_with_crate(dir.to_path_buf(), vec![boundary.clone(), errors]);
2171        let findings = vec![catch_all_error_finding(&boundary, "a", 1)];
2172
2173        assert!(analyze_workspace(&workspace, &findings).is_empty());
2174    }
2175
2176    /// (c) Two `catch-all-error` findings but no crate-local typed error ⇒
2177    /// no candidate (only one independent signal, not corroborated).
2178    #[test]
2179    fn two_findings_without_a_typed_error_are_not_corroborated() {
2180        let dir = TempDir::new("pattern-uncorroborated");
2181        let boundary = dir.join("boundary.rs");
2182        std::fs::write(
2183            &boundary,
2184            "pub fn a() -> Result<(), Box<dyn std::error::Error>> { Ok(()) }\n\
2185             pub fn b() -> Result<(), Box<dyn std::error::Error>> { Ok(()) }\n",
2186        )
2187        .unwrap();
2188
2189        let workspace = workspace_with_crate(dir.to_path_buf(), vec![boundary.clone()]);
2190        let findings = vec![
2191            catch_all_error_finding(&boundary, "a", 1),
2192            catch_all_error_finding(&boundary, "b", 2),
2193        ];
2194
2195        assert!(analyze_workspace(&workspace, &findings).is_empty());
2196    }
2197
2198    /// `impl ... Error for ...` alone (no `Error`-named enum, no derive) is
2199    /// enough for the independent signal.
2200    #[test]
2201    fn a_manual_error_trait_impl_counts_as_the_independent_signal() {
2202        let dir = TempDir::new("pattern-manual-impl");
2203        let boundary = dir.join("boundary.rs");
2204        std::fs::write(
2205            &boundary,
2206            "pub fn a() -> Result<(), Box<dyn std::error::Error>> { Ok(()) }\n\
2207             pub fn b() -> Result<(), Box<dyn std::error::Error>> { Ok(()) }\n",
2208        )
2209        .unwrap();
2210        let errors = dir.join("errors.rs");
2211        std::fs::write(
2212            &errors,
2213            "struct Oops;\n\
2214             impl std::fmt::Display for Oops { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { Ok(()) } }\n\
2215             impl std::error::Error for Oops {}\n",
2216        )
2217        .unwrap();
2218
2219        let workspace = workspace_with_crate(dir.to_path_buf(), vec![boundary.clone(), errors]);
2220        let findings = vec![
2221            catch_all_error_finding(&boundary, "a", 1),
2222            catch_all_error_finding(&boundary, "b", 2),
2223        ];
2224
2225        assert_eq!(analyze_workspace(&workspace, &findings).len(), 1);
2226    }
2227
2228    /// The id is deterministic across repeated aggregation runs over the
2229    /// same inputs.
2230    #[test]
2231    fn candidate_id_is_deterministic() {
2232        let dir = TempDir::new("pattern-deterministic-id");
2233        let boundary = dir.join("boundary.rs");
2234        std::fs::write(
2235            &boundary,
2236            "pub fn a() -> Result<(), Box<dyn std::error::Error>> { Ok(()) }\n\
2237             pub fn b() -> Result<(), Box<dyn std::error::Error>> { Ok(()) }\n",
2238        )
2239        .unwrap();
2240        let errors = dir.join("errors.rs");
2241        std::fs::write(&errors, "enum FooError { Bad }\n").unwrap();
2242
2243        let workspace = workspace_with_crate(dir.to_path_buf(), vec![boundary.clone(), errors]);
2244        let findings = vec![
2245            catch_all_error_finding(&boundary, "a", 1),
2246            catch_all_error_finding(&boundary, "b", 2),
2247        ];
2248
2249        let first = analyze_workspace(&workspace, &findings);
2250        let second = analyze_workspace(&workspace, &findings);
2251        assert_eq!(first[0].id, second[0].id);
2252    }
2253
2254    /// `primitive-domain-value` (a): two `pub fn` signatures sharing a
2255    /// (parameter name, type) pair, one of them guarding the parameter with
2256    /// an early `return Err(...)` ⇒ one candidate with both evidence slots
2257    /// populated by the correct fundstellen.
2258    #[test]
2259    fn primitive_domain_value_two_signatures_plus_a_guard_produce_one_candidate() {
2260        let dir = TempDir::new("pattern-primitive-corroborated");
2261        let file = dir.join("lib.rs");
2262        std::fs::write(
2263            &file,
2264            "pub fn set_a(threshold: u32) {}\n\
2265             pub fn set_b(threshold: u32) -> Result<(), String> {\n\
2266             \x20   if threshold > 100 {\n\
2267             \x20       return Err(\"too big\".to_string());\n\
2268             \x20   }\n\
2269             \x20   Ok(())\n\
2270             }\n",
2271        )
2272        .unwrap();
2273
2274        let workspace = workspace_with_crate(dir.to_path_buf(), vec![file]);
2275        let candidates = analyze_workspace(&workspace, &[]);
2276
2277        assert_eq!(candidates.len(), 1);
2278        let candidate = &candidates[0];
2279        assert_eq!(candidate.pattern, RustPattern::ValidatedNewtype);
2280        assert_eq!(candidate.scope.krate, "fixture");
2281        assert_eq!(candidate.evidence.primary.locations.len(), 2);
2282        assert_eq!(candidate.evidence.independent.locations.len(), 1);
2283        assert_eq!(
2284            candidate.evidence.independent.locations[0]
2285                .item_path
2286                .as_deref(),
2287            Some("set_b")
2288        );
2289        assert!(!candidate.contraindications.is_empty());
2290        assert!(candidate.migration.len() >= 2);
2291    }
2292
2293    /// `primitive-domain-value` (b): only one signature, even with a guard
2294    /// ⇒ no candidate (a single occurrence is not a pattern).
2295    #[test]
2296    fn primitive_domain_value_single_signature_is_below_threshold() {
2297        let dir = TempDir::new("pattern-primitive-single");
2298        let file = dir.join("lib.rs");
2299        std::fs::write(
2300            &file,
2301            "pub fn set_a(threshold: u32) -> Result<(), String> {\n\
2302             \x20   if threshold > 100 {\n\
2303             \x20       return Err(\"too big\".to_string());\n\
2304             \x20   }\n\
2305             \x20   Ok(())\n\
2306             }\n",
2307        )
2308        .unwrap();
2309
2310        let workspace = workspace_with_crate(dir.to_path_buf(), vec![file]);
2311        assert!(analyze_workspace(&workspace, &[]).is_empty());
2312    }
2313
2314    /// `primitive-domain-value` (c): two signatures sharing the pair, but no
2315    /// validation guard anywhere ⇒ no candidate (only one signal).
2316    #[test]
2317    fn primitive_domain_value_without_any_guard_is_not_corroborated() {
2318        let dir = TempDir::new("pattern-primitive-unguarded");
2319        let file = dir.join("lib.rs");
2320        std::fs::write(
2321            &file,
2322            "pub fn set_a(threshold: u32) {}\n\
2323             pub fn set_b(threshold: u32) {}\n",
2324        )
2325        .unwrap();
2326
2327        let workspace = workspace_with_crate(dir.to_path_buf(), vec![file]);
2328        assert!(analyze_workspace(&workspace, &[]).is_empty());
2329    }
2330
2331    /// `boolean-state-cluster` (a): a function with three bool parameters
2332    /// and a condition combining two of them ⇒ one candidate.
2333    #[test]
2334    fn boolean_cluster_three_bools_plus_a_combined_condition_produce_one_candidate() {
2335        let dir = TempDir::new("pattern-bool-corroborated");
2336        let file = dir.join("lib.rs");
2337        std::fs::write(
2338            &file,
2339            "pub fn configure(verbose: bool, strict: bool, dry_run: bool) {\n\
2340             \x20   if verbose && strict {\n\
2341             \x20       do_thing();\n\
2342             \x20   }\n\
2343             \x20   let _ = dry_run;\n\
2344             }\n\
2345             fn do_thing() {}\n",
2346        )
2347        .unwrap();
2348
2349        let workspace = workspace_with_crate(dir.to_path_buf(), vec![file]);
2350        let candidates = analyze_workspace(&workspace, &[]);
2351
2352        assert_eq!(candidates.len(), 1);
2353        let candidate = &candidates[0];
2354        assert_eq!(candidate.pattern, RustPattern::OptionsStruct);
2355        assert_eq!(candidate.scope.krate, "fixture");
2356        assert_eq!(candidate.scope.modules, vec!["configure".to_string()]);
2357        assert!(!candidate.contraindications.is_empty());
2358    }
2359
2360    /// `boolean-state-cluster` (b): three bool parameters, but only
2361    /// independent single-flag checks, never combined ⇒ no candidate.
2362    #[test]
2363    fn boolean_cluster_without_a_combined_condition_is_not_corroborated() {
2364        let dir = TempDir::new("pattern-bool-independent-checks");
2365        let file = dir.join("lib.rs");
2366        std::fs::write(
2367            &file,
2368            "pub fn configure(verbose: bool, strict: bool, dry_run: bool) {\n\
2369             \x20   if verbose {\n\
2370             \x20       do_thing();\n\
2371             \x20   }\n\
2372             \x20   if strict {\n\
2373             \x20       do_thing();\n\
2374             \x20   }\n\
2375             \x20   if dry_run {\n\
2376             \x20       do_thing();\n\
2377             \x20   }\n\
2378             }\n\
2379             fn do_thing() {}\n",
2380        )
2381        .unwrap();
2382
2383        let workspace = workspace_with_crate(dir.to_path_buf(), vec![file]);
2384        assert!(analyze_workspace(&workspace, &[]).is_empty());
2385    }
2386
2387    /// `boolean-state-cluster` (c): only two bool parameters, even with a
2388    /// combined condition ⇒ no candidate (threshold of three not reached).
2389    #[test]
2390    fn boolean_cluster_with_only_two_bools_is_below_threshold() {
2391        let dir = TempDir::new("pattern-bool-below-threshold");
2392        let file = dir.join("lib.rs");
2393        std::fs::write(
2394            &file,
2395            "pub fn configure(verbose: bool, strict: bool) {\n\
2396             \x20   if verbose && strict {\n\
2397             \x20       do_thing();\n\
2398             \x20   }\n\
2399             }\n\
2400             fn do_thing() {}\n",
2401        )
2402        .unwrap();
2403
2404        let workspace = workspace_with_crate(dir.to_path_buf(), vec![file]);
2405        assert!(analyze_workspace(&workspace, &[]).is_empty());
2406    }
2407
2408    /// `boolean-state-cluster` + clippy corroboration (d): the same fixture
2409    /// as (a) already produces a candidate from the two AST signals alone.
2410    /// A matching `clippy::fn_params_excessive_bools` hit for the same
2411    /// function (same file, overlapping line range) adds a third
2412    /// `additional` evidence entry — purely additive, on top of the
2413    /// existing `primary`/`independent` pair.
2414    #[test]
2415    fn boolean_cluster_with_matching_clippy_hit_gains_a_third_evidence_entry() {
2416        let dir = TempDir::new("pattern-bool-clippy-corroborated");
2417        let file = dir.join("lib.rs");
2418        std::fs::write(
2419            &file,
2420            "pub fn configure(verbose: bool, strict: bool, dry_run: bool) {\n\
2421             \x20   if verbose && strict {\n\
2422             \x20       do_thing();\n\
2423             \x20   }\n\
2424             \x20   let _ = dry_run;\n\
2425             }\n\
2426             fn do_thing() {}\n",
2427        )
2428        .unwrap();
2429
2430        let workspace = workspace_with_crate(dir.to_path_buf(), vec![file]);
2431
2432        // Without `--clippy-json`: unchanged, no `additional` evidence —
2433        // proves the feature is fully backward-compatible/opt-in.
2434        let without_clippy = analyze_workspace(&workspace, &[]);
2435        assert_eq!(without_clippy.len(), 1);
2436        assert!(without_clippy[0].evidence.additional.is_empty());
2437
2438        // With a matching `--clippy-json` hit: a third evidence entry.
2439        let clippy_hits = vec![ClippyBoolParamsHit {
2440            file: PathBuf::from("lib.rs"),
2441            line_start: 1,
2442            line_end: 1,
2443        }];
2444        let with_clippy = analyze_workspace_with_clippy(&workspace, &[], &clippy_hits);
2445        assert_eq!(with_clippy.len(), 1);
2446        assert_eq!(with_clippy[0].evidence.additional.len(), 1);
2447        assert!(
2448            with_clippy[0].evidence.additional[0]
2449                .description
2450                .contains("fn_params_excessive_bools")
2451        );
2452    }
2453
2454    /// `boolean-state-cluster` + clippy corroboration (e): a clippy hit
2455    /// alone, for a function that does *not* independently satisfy the
2456    /// existing 2-signal AST check (bool params never combined in a
2457    /// condition, same fixture as (b)), must not create a candidate —
2458    /// clippy can only corroborate a candidate the AST signals already
2459    /// found, never create one by itself.
2460    #[test]
2461    fn boolean_cluster_clippy_hit_alone_does_not_create_a_candidate() {
2462        let dir = TempDir::new("pattern-bool-clippy-alone");
2463        let file = dir.join("lib.rs");
2464        std::fs::write(
2465            &file,
2466            "pub fn configure(verbose: bool, strict: bool, dry_run: bool) {\n\
2467             \x20   if verbose {\n\
2468             \x20       do_thing();\n\
2469             \x20   }\n\
2470             \x20   if strict {\n\
2471             \x20       do_thing();\n\
2472             \x20   }\n\
2473             \x20   if dry_run {\n\
2474             \x20       do_thing();\n\
2475             \x20   }\n\
2476             }\n\
2477             fn do_thing() {}\n",
2478        )
2479        .unwrap();
2480
2481        let workspace = workspace_with_crate(dir.to_path_buf(), vec![file]);
2482        let clippy_hits = vec![ClippyBoolParamsHit {
2483            file: PathBuf::from("lib.rs"),
2484            line_start: 1,
2485            line_end: 1,
2486        }];
2487
2488        assert!(analyze_workspace_with_clippy(&workspace, &[], &clippy_hits).is_empty());
2489    }
2490
2491    /// `public-invariant-bypass` (a): a `pub struct` with two `pub` fields
2492    /// and a constructor jointly validating both ⇒ one candidate.
2493    #[test]
2494    fn public_invariant_bypass_struct_plus_combo_validating_constructor_produce_one_candidate() {
2495        let dir = TempDir::new("pattern-invariant-corroborated");
2496        let file = dir.join("lib.rs");
2497        std::fs::write(
2498            &file,
2499            "pub struct Range {\n\
2500             \x20   pub low: u32,\n\
2501             \x20   pub high: u32,\n\
2502             }\n\
2503             impl Range {\n\
2504             \x20   pub fn new(low: u32, high: u32) -> Result<Self, String> {\n\
2505             \x20       if low >= high {\n\
2506             \x20           return Err(\"low must be less than high\".to_string());\n\
2507             \x20       }\n\
2508             \x20       Ok(Self { low, high })\n\
2509             \x20   }\n\
2510             }\n",
2511        )
2512        .unwrap();
2513
2514        let workspace = workspace_with_crate(dir.to_path_buf(), vec![file]);
2515        let candidates = analyze_workspace(&workspace, &[]);
2516
2517        assert_eq!(candidates.len(), 1);
2518        let candidate = &candidates[0];
2519        assert_eq!(candidate.pattern, RustPattern::SmartConstructor);
2520        assert_eq!(candidate.scope.krate, "fixture");
2521        assert_eq!(candidate.evidence.primary.locations.len(), 2);
2522        assert!(!candidate.evidence.independent.locations.is_empty());
2523        assert!(!candidate.contraindications.is_empty());
2524        assert!(candidate.migration.len() >= 2);
2525    }
2526
2527    /// `public-invariant-bypass` (b): the same struct/constructor, but
2528    /// `#[non_exhaustive]` on the struct ⇒ no candidate, regardless of what
2529    /// the constructor validates.
2530    #[test]
2531    fn public_invariant_bypass_non_exhaustive_struct_produces_no_candidate() {
2532        let dir = TempDir::new("pattern-invariant-non-exhaustive");
2533        let file = dir.join("lib.rs");
2534        std::fs::write(
2535            &file,
2536            "#[non_exhaustive]\n\
2537             pub struct Range {\n\
2538             \x20   pub low: u32,\n\
2539             \x20   pub high: u32,\n\
2540             }\n\
2541             impl Range {\n\
2542             \x20   pub fn new(low: u32, high: u32) -> Result<Self, String> {\n\
2543             \x20       if low >= high {\n\
2544             \x20           return Err(\"low must be less than high\".to_string());\n\
2545             \x20       }\n\
2546             \x20       Ok(Self { low, high })\n\
2547             \x20   }\n\
2548             }\n",
2549        )
2550        .unwrap();
2551
2552        let workspace = workspace_with_crate(dir.to_path_buf(), vec![file]);
2553        assert!(analyze_workspace(&workspace, &[]).is_empty());
2554    }
2555
2556    /// `public-invariant-bypass` (c): two `pub` fields, but the constructor
2557    /// only validates one field at a time (never a combination) ⇒ no
2558    /// candidate.
2559    #[test]
2560    fn public_invariant_bypass_single_field_validation_is_not_corroborated() {
2561        let dir = TempDir::new("pattern-invariant-single-field");
2562        let file = dir.join("lib.rs");
2563        std::fs::write(
2564            &file,
2565            "pub struct Range {\n\
2566             \x20   pub low: u32,\n\
2567             \x20   pub high: u32,\n\
2568             }\n\
2569             impl Range {\n\
2570             \x20   pub fn new(low: u32, high: u32) -> Result<Self, String> {\n\
2571             \x20       if low > 1000 {\n\
2572             \x20           return Err(\"too big\".to_string());\n\
2573             \x20       }\n\
2574             \x20       Ok(Self { low, high })\n\
2575             \x20   }\n\
2576             }\n",
2577        )
2578        .unwrap();
2579
2580        let workspace = workspace_with_crate(dir.to_path_buf(), vec![file]);
2581        assert!(analyze_workspace(&workspace, &[]).is_empty());
2582    }
2583
2584    /// `manual-resource-lifecycle` (a): a function calling `register(...)`
2585    /// and `unregister(...)`, and the crate has no `impl Drop` anywhere ⇒
2586    /// one candidate.
2587    #[test]
2588    fn manual_resource_lifecycle_register_unregister_without_drop_produces_one_candidate() {
2589        let dir = TempDir::new("pattern-resource-corroborated");
2590        let file = dir.join("lib.rs");
2591        std::fs::write(
2592            &file,
2593            "pub fn manage(handle: u32) {\n\
2594             \x20   register(handle);\n\
2595             \x20   unregister(handle);\n\
2596             }\n\
2597             fn register(_handle: u32) {}\n\
2598             fn unregister(_handle: u32) {}\n",
2599        )
2600        .unwrap();
2601
2602        let workspace = workspace_with_crate(dir.to_path_buf(), vec![file]);
2603        let candidates = analyze_workspace(&workspace, &[]);
2604
2605        assert_eq!(candidates.len(), 1);
2606        let candidate = &candidates[0];
2607        assert_eq!(candidate.pattern, RustPattern::RaiiGuard);
2608        assert_eq!(candidate.scope.krate, "fixture");
2609        assert!(!candidate.evidence.primary.locations.is_empty());
2610        assert_eq!(candidate.contraindications.len(), 3);
2611        assert!(candidate.migration.len() >= 2);
2612    }
2613
2614    /// `manual-resource-lifecycle` (b): the same acquire/release pair, but
2615    /// the crate has an `impl Drop for X` elsewhere ⇒ no candidate (the
2616    /// independent signal is missing).
2617    #[test]
2618    fn manual_resource_lifecycle_with_an_existing_drop_impl_is_not_corroborated() {
2619        let dir = TempDir::new("pattern-resource-has-drop");
2620        let file = dir.join("lib.rs");
2621        std::fs::write(
2622            &file,
2623            "pub fn manage(handle: u32) {\n\
2624             \x20   register(handle);\n\
2625             \x20   unregister(handle);\n\
2626             }\n\
2627             fn register(_handle: u32) {}\n\
2628             fn unregister(_handle: u32) {}\n\
2629             struct X;\n\
2630             impl Drop for X {\n\
2631             \x20   fn drop(&mut self) {}\n\
2632             }\n",
2633        )
2634        .unwrap();
2635
2636        let workspace = workspace_with_crate(dir.to_path_buf(), vec![file]);
2637        assert!(analyze_workspace(&workspace, &[]).is_empty());
2638    }
2639
2640    /// `manual-resource-lifecycle` (c): only `register(...)` without a
2641    /// matching `unregister(...)` ⇒ no candidate.
2642    #[test]
2643    fn manual_resource_lifecycle_without_a_matching_release_call_produces_no_candidate() {
2644        let dir = TempDir::new("pattern-resource-unmatched");
2645        let file = dir.join("lib.rs");
2646        std::fs::write(
2647            &file,
2648            "pub fn manage(handle: u32) {\n\
2649             \x20   register(handle);\n\
2650             }\n\
2651             fn register(_handle: u32) {}\n",
2652        )
2653        .unwrap();
2654
2655        let workspace = workspace_with_crate(dir.to_path_buf(), vec![file]);
2656        assert!(analyze_workspace(&workspace, &[]).is_empty());
2657    }
2658
2659    /// `stringly-error-boundary` (d) — unentscheidbar: the crate's only
2660    /// typed error lives behind `#[cfg(feature = "not-enabled-by-default")]`.
2661    /// A real build without that feature would never see `FooError` coexist
2662    /// with the two catch-all-error boundary functions, but
2663    /// `syn::parse_file` has no cfg resolution and parses every `cfg`
2664    /// branch regardless of actual feature activation (a documented,
2665    /// accepted Fast-Tier limitation, not a bug). This is a golden test of
2666    /// that honest, limited behavior: the candidate still fires, corroborated
2667    /// by evidence that might not actually coexist in any real build.
2668    #[test]
2669    fn stringly_error_boundary_cfg_gated_typed_error_still_corroborates() {
2670        let dir = TempDir::new("pattern-stringly-cfg-gated");
2671        let boundary = dir.join("boundary.rs");
2672        std::fs::write(
2673            &boundary,
2674            "pub fn a() -> Result<(), Box<dyn std::error::Error>> { Ok(()) }\n\
2675             pub fn b() -> Result<(), Box<dyn std::error::Error>> { Ok(()) }\n",
2676        )
2677        .unwrap();
2678        let errors = dir.join("errors.rs");
2679        std::fs::write(
2680            &errors,
2681            "#[cfg(feature = \"not-enabled-by-default\")]\n\
2682             enum FooError { Bad }\n",
2683        )
2684        .unwrap();
2685
2686        let workspace = workspace_with_crate(dir.to_path_buf(), vec![boundary.clone(), errors]);
2687        let findings = vec![
2688            catch_all_error_finding(&boundary, "a", 1),
2689            catch_all_error_finding(&boundary, "b", 2),
2690        ];
2691
2692        let candidates = analyze_workspace(&workspace, &findings);
2693        assert_eq!(candidates.len(), 1);
2694        assert_eq!(candidates[0].pattern, RustPattern::DomainError);
2695    }
2696
2697    /// `primitive-domain-value` (d) — unentscheidbar: the guarding signature
2698    /// only exists behind `#[cfg(feature = "not-enabled-by-default")]`. In a
2699    /// real build without that feature, `set_b` (and its validation guard)
2700    /// would not exist alongside `set_a`, but `syn::parse_file` has no cfg
2701    /// resolution and parses both regardless. Golden test of that honest,
2702    /// limited behavior — not a bug.
2703    #[test]
2704    fn primitive_domain_value_cfg_gated_guard_still_corroborates() {
2705        let dir = TempDir::new("pattern-primitive-cfg-gated");
2706        let file = dir.join("lib.rs");
2707        std::fs::write(
2708            &file,
2709            "pub fn set_a(threshold: u32) {}\n\
2710             #[cfg(feature = \"not-enabled-by-default\")]\n\
2711             pub fn set_b(threshold: u32) -> Result<(), String> {\n\
2712             \x20   if threshold > 100 {\n\
2713             \x20       return Err(\"too big\".to_string());\n\
2714             \x20   }\n\
2715             \x20   Ok(())\n\
2716             }\n",
2717        )
2718        .unwrap();
2719
2720        let workspace = workspace_with_crate(dir.to_path_buf(), vec![file]);
2721        let candidates = analyze_workspace(&workspace, &[]);
2722
2723        assert_eq!(candidates.len(), 1);
2724        assert_eq!(candidates[0].pattern, RustPattern::ValidatedNewtype);
2725    }
2726
2727    /// `boolean-state-cluster` (d) — unentscheidbar: the three-bool-parameter
2728    /// function that would trip this rule is produced entirely by a
2729    /// `macro_rules!` expansion, never written out in the source. `syn` sees
2730    /// the macro *definition* and the invocation `configure_impl!();` as an
2731    /// opaque `Item::Macro`, never the expanded `fn configure(...)` — the
2732    /// condition this rule looks for is structurally invisible to a
2733    /// `syn::parse_file`-based scanner. Proves the rule stays silent rather
2734    /// than guessing at what the expansion might contain.
2735    #[test]
2736    fn boolean_cluster_macro_generated_function_produces_no_candidate() {
2737        let dir = TempDir::new("pattern-bool-macro-generated");
2738        let file = dir.join("lib.rs");
2739        std::fs::write(
2740            &file,
2741            "macro_rules! configure_impl {\n\
2742             \x20   () => {\n\
2743             \x20       pub fn configure(verbose: bool, strict: bool, dry_run: bool) {\n\
2744             \x20           if verbose && strict {\n\
2745             \x20               do_thing();\n\
2746             \x20           }\n\
2747             \x20           let _ = dry_run;\n\
2748             \x20       }\n\
2749             \x20   };\n\
2750             }\n\
2751             configure_impl!();\n\
2752             fn do_thing() {}\n",
2753        )
2754        .unwrap();
2755
2756        let workspace = workspace_with_crate(dir.to_path_buf(), vec![file]);
2757        assert!(analyze_workspace(&workspace, &[]).is_empty());
2758    }
2759
2760    /// `public-invariant-bypass` (d) — unentscheidbar: `Range` carries a
2761    /// `#[derive(Builder)]`-shaped attribute whose expansion would generate
2762    /// the real validating constructor, but that constructor is never
2763    /// written out as source `syn` can see — only the derive macro
2764    /// invocation is visible. No `pub fn` in this file returns `Self`/
2765    /// `Range`, so `ConstructorVisitor` finds nothing to corroborate the
2766    /// struct fact with. Proves the rule only acts on what is literally in
2767    /// the AST, not on macro-expanded validation it cannot observe.
2768    #[test]
2769    fn public_invariant_bypass_derive_macro_constructor_produces_no_candidate() {
2770        let dir = TempDir::new("pattern-invariant-derive-macro");
2771        let file = dir.join("lib.rs");
2772        std::fs::write(
2773            &file,
2774            "#[derive(Builder)]\n\
2775             pub struct Range {\n\
2776             \x20   pub low: u32,\n\
2777             \x20   pub high: u32,\n\
2778             }\n",
2779        )
2780        .unwrap();
2781
2782        let workspace = workspace_with_crate(dir.to_path_buf(), vec![file]);
2783        assert!(analyze_workspace(&workspace, &[]).is_empty());
2784    }
2785
2786    /// `manual-resource-lifecycle` (d) — unentscheidbar: `register`/
2787    /// `unregister` are called on two unrelated types (`MetricRegistry`/
2788    /// `ListSubscription`) for two semantically unrelated operations that
2789    /// merely happen to share the acquire/release name pattern this rule
2790    /// matches on. The rule has no type resolution, so it cannot tell these
2791    /// apart from a genuine acquire/release pair on one resource — it fires
2792    /// anyway. This is not a bug to fix: it is the documented
2793    /// false-positive vector the rule's own contraindications warn about
2794    /// (see `build_manual_resource_lifecycle_candidate`'s second
2795    /// contraindication), and the reason the Gegenindikationen on this rule
2796    /// are mandatory.
2797    #[test]
2798    fn manual_resource_lifecycle_unrelated_types_sharing_call_names_still_fires() {
2799        let dir = TempDir::new("pattern-resource-coincidental-names");
2800        let file = dir.join("lib.rs");
2801        std::fs::write(
2802            &file,
2803            "struct MetricRegistry;\n\
2804             impl MetricRegistry {\n\
2805             \x20   fn register(&self, _id: u32) {}\n\
2806             }\n\
2807             struct ListSubscription;\n\
2808             impl ListSubscription {\n\
2809             \x20   fn unregister(&self) {}\n\
2810             }\n\
2811             pub fn unrelated_operations(id: u32) {\n\
2812             \x20   let registry = MetricRegistry;\n\
2813             \x20   let subscription = ListSubscription;\n\
2814             \x20   registry.register(id);\n\
2815             \x20   subscription.unregister();\n\
2816             }\n",
2817        )
2818        .unwrap();
2819
2820        let workspace = workspace_with_crate(dir.to_path_buf(), vec![file]);
2821        let candidates = analyze_workspace(&workspace, &[]);
2822
2823        assert_eq!(candidates.len(), 1);
2824        assert_eq!(candidates[0].pattern, RustPattern::RaiiGuard);
2825    }
2826
2827    /// The registry's curated `example.before` for `stringly-error-boundary`
2828    /// (see `rule_registry::RULE_REGISTRY`) must itself still trigger the
2829    /// rule — this is what keeps a landing-page-facing example from
2830    /// silently drifting away from what judge actually flags. This rule
2831    /// produces a `PatternCandidate`, not a `Finding` (see this module's own
2832    /// doc comment for why the two are deliberately kept separate), so the
2833    /// check reruns `analyze_workspace` exactly like
2834    /// `two_symptoms_plus_a_typed_error_produce_one_candidate` above and
2835    /// asserts on `.pattern` rather than on a `Finding`'s rule id.
2836    #[test]
2837    fn stringly_error_boundary_registry_example_still_triggers_the_rule() {
2838        let example = crate::rule_registry::lookup(STRINGLY_ERROR_BOUNDARY_RULE)
2839            .expect("stringly-error-boundary has a registry entry")
2840            .example
2841            .expect("stringly-error-boundary has a curated example")
2842            .before;
2843
2844        let dir = TempDir::new("pattern-registry-example-stringly");
2845        let file = dir.join("lib.rs");
2846        std::fs::write(&file, example).unwrap();
2847
2848        let workspace = workspace_with_crate(dir.to_path_buf(), vec![file.clone()]);
2849        let findings = vec![
2850            catch_all_error_finding(&file, "fetch_user", 1),
2851            catch_all_error_finding(&file, "fetch_order", 5),
2852        ];
2853
2854        let candidates = analyze_workspace(&workspace, &findings);
2855        assert_eq!(candidates.len(), 1);
2856        assert_eq!(candidates[0].pattern, RustPattern::DomainError);
2857    }
2858
2859    /// The registry's curated `example.before` for `primitive-domain-value`
2860    /// must itself still trigger the rule — see the drift-guard doc comment
2861    /// on `stringly_error_boundary_registry_example_still_triggers_the_rule`
2862    /// above for why this reruns `analyze_workspace` and asserts on
2863    /// `.pattern` instead of using a `Finding`-based helper.
2864    #[test]
2865    fn primitive_domain_value_registry_example_still_triggers_the_rule() {
2866        let example = crate::rule_registry::lookup(PRIMITIVE_DOMAIN_VALUE_RULE)
2867            .expect("primitive-domain-value has a registry entry")
2868            .example
2869            .expect("primitive-domain-value has a curated example")
2870            .before;
2871
2872        let dir = TempDir::new("pattern-registry-example-primitive");
2873        let file = dir.join("lib.rs");
2874        std::fs::write(&file, example).unwrap();
2875
2876        let workspace = workspace_with_crate(dir.to_path_buf(), vec![file]);
2877        let candidates = analyze_workspace(&workspace, &[]);
2878        assert_eq!(candidates.len(), 1);
2879        assert_eq!(candidates[0].pattern, RustPattern::ValidatedNewtype);
2880    }
2881
2882    /// The registry's curated `example.before` for `boolean-state-cluster`
2883    /// must itself still trigger the rule — see the drift-guard doc comment
2884    /// on `stringly_error_boundary_registry_example_still_triggers_the_rule`
2885    /// above for why this reruns `analyze_workspace` and asserts on
2886    /// `.pattern` instead of using a `Finding`-based helper.
2887    #[test]
2888    fn boolean_state_cluster_registry_example_still_triggers_the_rule() {
2889        let example = crate::rule_registry::lookup(BOOLEAN_STATE_CLUSTER_RULE)
2890            .expect("boolean-state-cluster has a registry entry")
2891            .example
2892            .expect("boolean-state-cluster has a curated example")
2893            .before;
2894
2895        let dir = TempDir::new("pattern-registry-example-boolean");
2896        let file = dir.join("lib.rs");
2897        std::fs::write(&file, example).unwrap();
2898
2899        let workspace = workspace_with_crate(dir.to_path_buf(), vec![file]);
2900        let candidates = analyze_workspace(&workspace, &[]);
2901        assert_eq!(candidates.len(), 1);
2902        assert_eq!(candidates[0].pattern, RustPattern::OptionsStruct);
2903    }
2904
2905    /// The registry's curated `example.before` for `public-invariant-bypass`
2906    /// must itself still trigger the rule — see the drift-guard doc comment
2907    /// on `stringly_error_boundary_registry_example_still_triggers_the_rule`
2908    /// above for why this reruns `analyze_workspace` and asserts on
2909    /// `.pattern` instead of using a `Finding`-based helper.
2910    #[test]
2911    fn public_invariant_bypass_registry_example_still_triggers_the_rule() {
2912        let example = crate::rule_registry::lookup(PUBLIC_INVARIANT_BYPASS_RULE)
2913            .expect("public-invariant-bypass has a registry entry")
2914            .example
2915            .expect("public-invariant-bypass has a curated example")
2916            .before;
2917
2918        let dir = TempDir::new("pattern-registry-example-invariant");
2919        let file = dir.join("lib.rs");
2920        std::fs::write(&file, example).unwrap();
2921
2922        let workspace = workspace_with_crate(dir.to_path_buf(), vec![file]);
2923        let candidates = analyze_workspace(&workspace, &[]);
2924        assert_eq!(candidates.len(), 1);
2925        assert_eq!(candidates[0].pattern, RustPattern::SmartConstructor);
2926    }
2927
2928    /// The registry's curated `example.before` for
2929    /// `manual-resource-lifecycle` must itself still trigger the rule — see
2930    /// the drift-guard doc comment on
2931    /// `stringly_error_boundary_registry_example_still_triggers_the_rule`
2932    /// above for why this reruns `analyze_workspace` and asserts on
2933    /// `.pattern` instead of using a `Finding`-based helper.
2934    #[test]
2935    fn manual_resource_lifecycle_registry_example_still_triggers_the_rule() {
2936        let example = crate::rule_registry::lookup(MANUAL_RESOURCE_LIFECYCLE_RULE)
2937            .expect("manual-resource-lifecycle has a registry entry")
2938            .example
2939            .expect("manual-resource-lifecycle has a curated example")
2940            .before;
2941
2942        let dir = TempDir::new("pattern-registry-example-resource");
2943        let file = dir.join("lib.rs");
2944        std::fs::write(&file, example).unwrap();
2945
2946        let workspace = workspace_with_crate(dir.to_path_buf(), vec![file]);
2947        let candidates = analyze_workspace(&workspace, &[]);
2948        assert_eq!(candidates.len(), 1);
2949        assert_eq!(candidates[0].pattern, RustPattern::RaiiGuard);
2950    }
2951}